calendar events features
This commit is contained in:
@@ -23,7 +23,8 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.WomanSafe"
|
android:theme="@style/Theme.WomanSafe"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -125,16 +125,16 @@ interface WomanSafeApi {
|
|||||||
|
|
||||||
// Calendar endpoints
|
// Calendar endpoints
|
||||||
@GET("api/v1/calendar/entries")
|
@GET("api/v1/calendar/entries")
|
||||||
suspend fun getCalendarEntries(): Response<Any>
|
suspend fun getCalendarEntries(): Response<CalendarEntriesResponse>
|
||||||
|
|
||||||
@POST("api/v1/calendar/entries")
|
@POST("api/v1/calendar/entry")
|
||||||
suspend fun createCalendarEntry(): Response<Any>
|
suspend fun createCalendarEntry(@Body entry: CalendarEntryCreate): Response<CalendarEvent>
|
||||||
|
|
||||||
@GET("api/v1/calendar/entries/{entry_id}")
|
@GET("api/v1/calendar/entries/{entry_id}")
|
||||||
suspend fun getCalendarEntry(@Path("entry_id") entryId: String): Response<Any>
|
suspend fun getCalendarEntry(@Path("entry_id") entryId: String): Response<CalendarEvent>
|
||||||
|
|
||||||
@PUT("api/v1/calendar/entries/{entry_id}")
|
@PUT("api/v1/calendar/entries/{entry_id}")
|
||||||
suspend fun updateCalendarEntry(@Path("entry_id") entryId: String): Response<Any>
|
suspend fun updateCalendarEntry(@Path("entry_id") entryId: String, @Body entry: CalendarEntryUpdate): Response<CalendarEvent>
|
||||||
|
|
||||||
@DELETE("api/v1/calendar/entries/{entry_id}")
|
@DELETE("api/v1/calendar/entries/{entry_id}")
|
||||||
suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response<Any>
|
suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response<Any>
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ data class CalendarEntry(
|
|||||||
val notes: String? = null
|
val notes: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CalendarEntryResponse(
|
data class LegacyCalendarEntryResponse( // Переименовано, чтобы избежать конфликта с CalendarModels.kt
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ enum class SymptomType {
|
|||||||
|
|
||||||
// Событие календаря
|
// Событие календаря
|
||||||
data class CalendarEvent(
|
data class CalendarEvent(
|
||||||
val id: Int? = null,
|
val id: String? = null, // Изменено с Int? на String?, так как в API используются UUID
|
||||||
val date: LocalDate,
|
val date: LocalDate,
|
||||||
val type: CalendarEventType,
|
val type: CalendarEventType,
|
||||||
val isActual: Boolean = true, // true - фактическое, false - прогноз
|
val isActual: Boolean = true, // true - фактическое, false - прогноз
|
||||||
@@ -45,35 +45,91 @@ data class CalendarEvent(
|
|||||||
val notes: String = "",
|
val notes: String = "",
|
||||||
val flowIntensity: Int? = null, // Интенсивность выделений 1-5
|
val flowIntensity: Int? = null, // Интенсивность выделений 1-5
|
||||||
val createdAt: LocalDate = LocalDate.now(),
|
val createdAt: LocalDate = LocalDate.now(),
|
||||||
val updatedAt: LocalDate = LocalDate.now()
|
val updatedAt: LocalDate = LocalDate.now(),
|
||||||
|
val isPredicted: Boolean = false // Добавлено поле для совместимости с API
|
||||||
)
|
)
|
||||||
|
|
||||||
// Настройки цикла
|
// Настройки цикла
|
||||||
data class CycleSettings(
|
data class CycleSettings(
|
||||||
val averageCycleLength: Int = 28, // Средняя длина цикла
|
val averageCycleLength: Int = 28,
|
||||||
val averagePeriodLength: Int = 5, // Средняя длина менструации
|
val averagePeriodLength: Int = 5,
|
||||||
val lastPeriodStart: LocalDate? = null, // Последняя менструация
|
val lastPeriodStart: LocalDate? = null,
|
||||||
val reminderDaysBefore: Int = 2, // За сколько дней напоминать
|
val irregularCycles: Boolean = false,
|
||||||
val enablePredictions: Boolean = true,
|
val trackSymptoms: Boolean = true,
|
||||||
val enableReminders: Boolean = true
|
val trackMood: Boolean = true,
|
||||||
|
val showPredictions: Boolean = true,
|
||||||
|
val reminderDaysBefore: Int = 3 // За сколько дней напоминать о приближающемся цикле
|
||||||
)
|
)
|
||||||
|
|
||||||
// Прогнозы цикла
|
// Прогноз цикла
|
||||||
data class CyclePrediction(
|
data class CyclePrediction(
|
||||||
val nextPeriodStart: LocalDate,
|
val nextPeriodStart: LocalDate,
|
||||||
val nextPeriodEnd: LocalDate,
|
val nextPeriodEnd: LocalDate,
|
||||||
val nextOvulation: LocalDate,
|
val nextOvulation: LocalDate,
|
||||||
val fertileWindowStart: LocalDate,
|
val fertileWindowStart: LocalDate,
|
||||||
val fertileWindowEnd: LocalDate,
|
val fertileWindowEnd: LocalDate,
|
||||||
val confidence: Float = 0.8f // Уверенность в прогнозе 0-1
|
val confidence: Float = 0.85f // Добавлено значение достоверности прогноза в %
|
||||||
)
|
)
|
||||||
|
|
||||||
// Статистика цикла
|
// Статистика цикла
|
||||||
data class CycleStatistics(
|
data class CycleStatistics(
|
||||||
val averageCycleLength: Float,
|
val averageCycleLength: Float = 0f,
|
||||||
val cycleVariation: Float, // Отклонение в днях
|
val cycleVariation: Float = 0f,
|
||||||
val lastCycles: List<Int>, // Длины последних циклов
|
val lastCycles: List<Int> = emptyList(),
|
||||||
val periodLengthAverage: Float,
|
val periodLengthAverage: Float = 0f,
|
||||||
val commonSymptoms: List<SymptomType>,
|
val commonSymptoms: List<SymptomType> = emptyList(),
|
||||||
val moodPatterns: Map<CalendarEventType, MoodType>
|
val moodPatterns: Map<MoodType, Float> = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Модели для API
|
||||||
|
|
||||||
|
// Запрос на создание события в календаре
|
||||||
|
data class CalendarEntryCreate(
|
||||||
|
val date: String,
|
||||||
|
val type: String,
|
||||||
|
val mood: String? = null,
|
||||||
|
val symptoms: List<String> = emptyList(),
|
||||||
|
val notes: String? = null,
|
||||||
|
val flow_intensity: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Запрос на обновление события в календаре
|
||||||
|
data class CalendarEntryUpdate(
|
||||||
|
val date: String? = null,
|
||||||
|
val type: String? = null,
|
||||||
|
val mood: String? = null,
|
||||||
|
val symptoms: List<String>? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val flow_intensity: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// API ответ для отдельной записи календаря
|
||||||
|
data class CalendarEntryResponse(
|
||||||
|
val id: String,
|
||||||
|
val date: String,
|
||||||
|
val type: String,
|
||||||
|
val mood: String? = null,
|
||||||
|
val symptoms: List<String> = emptyList(),
|
||||||
|
val notes: String? = null,
|
||||||
|
val flow_intensity: Int? = null,
|
||||||
|
val is_predicted: Boolean = false,
|
||||||
|
val created_at: String? = null,
|
||||||
|
val updated_at: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// API ответ для информации о цикле
|
||||||
|
data class CycleInfoResponse(
|
||||||
|
val average_cycle_length: Int,
|
||||||
|
val average_period_length: Int,
|
||||||
|
val last_period_start: String?,
|
||||||
|
val next_period_predicted: String?,
|
||||||
|
val next_ovulation_predicted: String?,
|
||||||
|
val fertile_window_start: String?,
|
||||||
|
val fertile_window_end: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
// Полный API ответ для записей календаря
|
||||||
|
data class CalendarEntriesResponse(
|
||||||
|
val entries: List<CalendarEntryResponse>,
|
||||||
|
val cycle_info: CycleInfoResponse
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
object NetworkClient {
|
object NetworkClient {
|
||||||
private var BASE_URL = "http://192.168.0.103:8000/"
|
private var BASE_URL = "http://192.168.0.112:8000/"
|
||||||
private var authToken: String? = null
|
private var authToken: String? = null
|
||||||
|
|
||||||
private val authInterceptor = Interceptor { chain ->
|
private val authInterceptor = Interceptor { chain ->
|
||||||
|
|||||||
@@ -191,24 +191,20 @@ class ApiRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calendar methods
|
// Calendar methods
|
||||||
suspend fun getCalendarEntries(): Response<Any> {
|
suspend fun getCalendarEntries(): Response<CalendarEntriesResponse> {
|
||||||
return apiService.getCalendarEntries()
|
return apiService.getCalendarEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createCalendarEntry(): Response<Any> {
|
suspend fun createCalendarEntry(entry: CalendarEntryCreate): Response<CalendarEvent> {
|
||||||
return apiService.createCalendarEntry()
|
return apiService.createCalendarEntry(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCalendarEntry(entryId: Int): Response<Any> {
|
suspend fun updateCalendarEntry(id: String, entry: CalendarEntryUpdate): Response<CalendarEvent> {
|
||||||
return apiService.getCalendarEntry(entryId.toString())
|
return apiService.updateCalendarEntry(id, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateCalendarEntry(entryId: Int): Response<Any> {
|
suspend fun deleteCalendarEntry(id: String): Response<Any> {
|
||||||
return apiService.updateCalendarEntry(entryId.toString())
|
return apiService.deleteCalendarEntry(id)
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun deleteCalendarEntry(entryId: Int): Response<Any> {
|
|
||||||
return apiService.deleteCalendarEntry(entryId.toString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCycleOverview(): Response<Any> {
|
suspend fun getCycleOverview(): Response<Any> {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
@@ -20,16 +21,15 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.example.womansafe.data.model.*
|
import com.example.womansafe.data.model.*
|
||||||
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||||
import com.example.womansafe.util.DateUtils
|
import com.example.womansafe.util.DateUtils
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -623,12 +623,12 @@ private fun AddEventDialog(
|
|||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
// Создаем строки по 2 симптома вручную, избегая chunked
|
// Создаем строки по 2 симптома, безопасно обрабатывая границы массива
|
||||||
val symptoms = SymptomType.values().toList()
|
val symptoms = SymptomType.values().toList()
|
||||||
var i = 0
|
// Обходим список с шагом 2, чтобы обрабатывать симптомы парами
|
||||||
while (i < symptoms.size) {
|
for (i in symptoms.indices step 2) {
|
||||||
Row {
|
Row {
|
||||||
// Первый симптом в строке
|
// Первый симптом в строке (всегда существует)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = {
|
onClick = {
|
||||||
val symptom = symptoms[i]
|
val symptom = symptoms[i]
|
||||||
@@ -645,7 +645,7 @@ private fun AddEventDialog(
|
|||||||
.padding(end = 4.dp)
|
.padding(end = 4.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Второй симптом в строке (если есть)
|
// Второй симптом в строке (проверяем, что он существует)
|
||||||
if (i + 1 < symptoms.size) {
|
if (i + 1 < symptoms.size) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -667,7 +667,6 @@ private fun AddEventDialog(
|
|||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
i += 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,6 +714,9 @@ private fun CycleSettingsDialog(
|
|||||||
var periodLength by remember { mutableStateOf(settings.averagePeriodLength.toString()) }
|
var periodLength by remember { mutableStateOf(settings.averagePeriodLength.toString()) }
|
||||||
var lastPeriodDate by remember { mutableStateOf(settings.lastPeriodStart) }
|
var lastPeriodDate by remember { mutableStateOf(settings.lastPeriodStart) }
|
||||||
var reminderDays by remember { mutableStateOf(settings.reminderDaysBefore.toString()) }
|
var reminderDays by remember { mutableStateOf(settings.reminderDaysBefore.toString()) }
|
||||||
|
var trackSymptoms by remember { mutableStateOf(settings.trackSymptoms) }
|
||||||
|
var trackMood by remember { mutableStateOf(settings.trackMood) }
|
||||||
|
var showPredictions by remember { mutableStateOf(settings.showPredictions) }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -728,7 +730,8 @@ private fun CycleSettingsDialog(
|
|||||||
onValueChange = { cycleLength = it },
|
onValueChange = { cycleLength = it },
|
||||||
label = { Text("Средняя длина цикла (дни)") },
|
label = { Text("Средняя длина цикла (дни)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -736,7 +739,8 @@ private fun CycleSettingsDialog(
|
|||||||
onValueChange = { periodLength = it },
|
onValueChange = { periodLength = it },
|
||||||
label = { Text("Длина менструации (дни)") },
|
label = { Text("Длина менструации (дни)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -744,9 +748,47 @@ private fun CycleSettingsDialog(
|
|||||||
onValueChange = { reminderDays = it },
|
onValueChange = { reminderDays = it },
|
||||||
label = { Text("Напоминать за (дни)") },
|
label = { Text("Напоминать за (дни)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Переключатели для отслеживания симптомов и настроения
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("Отслеживать симптомы")
|
||||||
|
Switch(
|
||||||
|
checked = trackSymptoms,
|
||||||
|
onCheckedChange = { trackSymptoms = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("Отслеживать настроение")
|
||||||
|
Switch(
|
||||||
|
checked = trackMood,
|
||||||
|
onCheckedChange = { trackMood = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("Показывать прогнозы")
|
||||||
|
Switch(
|
||||||
|
checked = showPredictions,
|
||||||
|
onCheckedChange = { showPredictions = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Добавить выбор даты последних месячных
|
// TODO: Добавить выбор даты последних месячных
|
||||||
lastPeriodDate?.let { date ->
|
lastPeriodDate?.let { date ->
|
||||||
Text(
|
Text(
|
||||||
@@ -763,7 +805,10 @@ private fun CycleSettingsDialog(
|
|||||||
averageCycleLength = cycleLength.toIntOrNull() ?: settings.averageCycleLength,
|
averageCycleLength = cycleLength.toIntOrNull() ?: settings.averageCycleLength,
|
||||||
averagePeriodLength = periodLength.toIntOrNull() ?: settings.averagePeriodLength,
|
averagePeriodLength = periodLength.toIntOrNull() ?: settings.averagePeriodLength,
|
||||||
reminderDaysBefore = reminderDays.toIntOrNull() ?: settings.reminderDaysBefore,
|
reminderDaysBefore = reminderDays.toIntOrNull() ?: settings.reminderDaysBefore,
|
||||||
lastPeriodStart = lastPeriodDate
|
lastPeriodStart = lastPeriodDate,
|
||||||
|
trackSymptoms = trackSymptoms,
|
||||||
|
trackMood = trackMood,
|
||||||
|
showPredictions = showPredictions
|
||||||
)
|
)
|
||||||
onSave(newSettings)
|
onSave(newSettings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,60 +38,205 @@ class CalendarViewModel : ViewModel() {
|
|||||||
private var loadJob: Job? = null
|
private var loadJob: Job? = null
|
||||||
private var retryCount = 0
|
private var retryCount = 0
|
||||||
private val maxRetryCount = 5
|
private val maxRetryCount = 5
|
||||||
private val cacheValidityDuration = 10 * 60 * 1000 // 10 минут в миллисекундах
|
private val cacheValidityDuration = 30 * 60 * 1000 // 30 минут
|
||||||
private var debounceJob: Job? = null
|
private var debounceJob: Job? = null
|
||||||
|
|
||||||
|
// Для определения, инициализирован ли ViewModel уже
|
||||||
|
private var initialized = false
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Статические переменные для предотвращения одновременных запросов из разных экземпляров
|
||||||
|
@Volatile
|
||||||
|
private var isRequestInProgress = false
|
||||||
|
private var lastRefreshTimestamp = 0L
|
||||||
|
private const val GLOBAL_COOLDOWN = 2000L // Минимальный интервал между запросами (2 секунды)
|
||||||
|
// Максимальное количество неудачных запросов перед временным отключением
|
||||||
|
private const val MAX_ERROR_COUNT = 3
|
||||||
|
// Счетчик неудачных запросов
|
||||||
|
private var errorCount = 0
|
||||||
|
// Время последней ошибки
|
||||||
|
private var lastErrorTimestamp = 0L
|
||||||
|
// Интервал охлаждения при ошибках (увеличивается экспоненциально)
|
||||||
|
private var errorCooldownInterval = 5000L // Начинаем с 5 секунд
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
// Добавляем небольшую задержку для предотвращения одновременных запросов
|
||||||
|
viewModelScope.launch {
|
||||||
|
delay(100L * (0..5).random()) // Случайная задержка до 500 мс
|
||||||
|
if (!initialized) {
|
||||||
|
initialized = true
|
||||||
|
loadInitialData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInitialData() {
|
||||||
|
debounceJob?.cancel()
|
||||||
|
debounceJob = viewModelScope.launch {
|
||||||
|
delay(300) // Добавляем задержку для дебаунсинга
|
||||||
loadCalendarData()
|
loadCalendarData()
|
||||||
loadCycleSettings()
|
loadCycleSettings()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadCalendarData() {
|
fun loadCalendarData() {
|
||||||
// Если данные были обновлены недавно и это не первая загрузка, не делаем запрос
|
// Если запрос уже выполняется глобально, не начинаем новый
|
||||||
if (uiState.events.isNotEmpty() &&
|
if (isRequestInProgress) {
|
||||||
System.currentTimeMillis() - uiState.lastRefreshed < cacheValidityDuration) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отменяем предыдущий запрос, если он еще выполняется
|
// Глобальная проверка интервала между запросами
|
||||||
loadJob?.cancel()
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastRefreshTimestamp < GLOBAL_COOLDOWN) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loadJob = viewModelScope.launch {
|
// Проверка интервала охлаждения при ошибках
|
||||||
uiState = uiState.copy(isLoading = true, error = null)
|
if (errorCount >= MAX_ERROR_COUNT && now - lastErrorTimestamp < errorCooldownInterval) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Временное ограничение запросов из-за ошибок. Повторите через ${(errorCooldownInterval / 1000).toInt()} секунд."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если данные были обновлены недавно и это не первая загрузка, не делаем запрос
|
||||||
|
if (uiState.events.isNotEmpty() &&
|
||||||
|
now - uiState.lastRefreshed < cacheValidityDuration) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отменяем предыдущий запрос и дебаунс, если они выполняются
|
||||||
|
loadJob?.cancel()
|
||||||
|
debounceJob?.cancel()
|
||||||
|
|
||||||
|
// Используем дебаунсинг для предотвращения частых запросов
|
||||||
|
debounceJob = viewModelScope.launch {
|
||||||
|
delay(300) // Задержка в 300 мс для дебаунсинга
|
||||||
|
|
||||||
|
loadJob = launch {
|
||||||
try {
|
try {
|
||||||
|
isRequestInProgress = true // Устанавливаем глобальный флаг запроса
|
||||||
|
lastRefreshTimestamp = System.currentTimeMillis()
|
||||||
|
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
|
||||||
// Загружаем события календаря
|
// Загружаем события календаря
|
||||||
val response = repository.getCalendarEntries()
|
val response = repository.getCalendarEntries()
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
// Сбрасываем счетчик повторных попыток при успехе
|
// Сбрасываем счетчики ошибок при успехе
|
||||||
|
errorCount = 0
|
||||||
|
errorCooldownInterval = 5000L // Сбрасываем до начального значения
|
||||||
retryCount = 0
|
retryCount = 0
|
||||||
|
|
||||||
// TODO: Преобразовать ответ API в события календаря
|
// Обрабатываем ответ API
|
||||||
|
response.body()?.let { calendarResponse ->
|
||||||
|
// Преобразуем API ответ в объекты домена
|
||||||
|
val events = processCalendarEntries(calendarResponse)
|
||||||
|
|
||||||
|
// Обновляем настройки и прогнозы из данных API
|
||||||
|
updateCycleInfoFromResponse(calendarResponse.cycle_info)
|
||||||
|
|
||||||
|
// Генерируем прогнозы на основе данных
|
||||||
generatePredictions()
|
generatePredictions()
|
||||||
|
|
||||||
|
// Рассчитываем статистику
|
||||||
calculateStatistics()
|
calculateStatistics()
|
||||||
|
|
||||||
// Обновляем время последнего успешного запроса
|
// Обновляем состояние UI
|
||||||
uiState = uiState.copy(
|
uiState = uiState.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
lastRefreshed = System.currentTimeMillis()
|
events = events,
|
||||||
|
lastRefreshed = System.currentTimeMillis(),
|
||||||
|
error = null
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else if (response.code() == 429) {
|
} else if (response.code() == 429) {
|
||||||
// Обработка Too Many Requests с экспоненциальным откатом
|
// Обработка Too Many Requests с экспоненциальным откатом
|
||||||
handleRateLimitExceeded()
|
handleRateLimitExceeded()
|
||||||
} else {
|
} else {
|
||||||
uiState = uiState.copy(
|
// Увеличиваем счетчик ошибок
|
||||||
isLoading = false,
|
handleApiError(response.code().toString())
|
||||||
error = "Ошибка загрузки данных календаря: ${response.code()}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
|
|
||||||
|
// Увеличиваем счетчик ошибок
|
||||||
|
handleApiError(e.message ?: "Неизвестная ошибка")
|
||||||
|
} finally {
|
||||||
|
isRequestInProgress = false // Сбрасываем флаг в любом случае
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleApiError(errorMsg: String) {
|
||||||
|
errorCount++
|
||||||
|
lastErrorTimestamp = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// Экспоненциально увеличиваем время ожидания при повторных ошибках
|
||||||
|
if (errorCount >= MAX_ERROR_COUNT) {
|
||||||
|
errorCooldownInterval = (errorCooldownInterval * 2).coerceAtMost(60000L) // максимум 1 минута
|
||||||
uiState = uiState.copy(
|
uiState = uiState.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = "Ошибка сети: ${e.message}"
|
error = "Ошибка API: $errorMsg. Повторные запросы ограничены на ${errorCooldownInterval/1000} сек."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка API: $errorMsg"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Преобразует API-ответ в события календаря
|
||||||
|
private fun processCalendarEntries(response: CalendarEntriesResponse): Map<LocalDate, List<CalendarEvent>> {
|
||||||
|
val result = mutableMapOf<LocalDate, MutableList<CalendarEvent>>()
|
||||||
|
|
||||||
|
response.entries.forEach { entry ->
|
||||||
|
try {
|
||||||
|
val date = LocalDate.parse(entry.date)
|
||||||
|
val event = CalendarEvent(
|
||||||
|
id = entry.id,
|
||||||
|
date = date,
|
||||||
|
type = CalendarEventType.valueOf(entry.type),
|
||||||
|
isActual = !entry.is_predicted,
|
||||||
|
isPredicted = entry.is_predicted,
|
||||||
|
mood = entry.mood?.let { MoodType.valueOf(it) },
|
||||||
|
symptoms = entry.symptoms.mapNotNull {
|
||||||
|
try { SymptomType.valueOf(it) } catch (e: Exception) { null }
|
||||||
|
},
|
||||||
|
notes = entry.notes ?: "",
|
||||||
|
flowIntensity = entry.flow_intensity
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.containsKey(date)) {
|
||||||
|
result[date]?.add(event)
|
||||||
|
} else {
|
||||||
|
result[date] = mutableListOf(event)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Пропускаем некорректные записи
|
||||||
|
println("Ошибка обработки записи календаря: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляет информацию о цикле из API-ответа
|
||||||
|
private fun updateCycleInfoFromResponse(cycleInfo: CycleInfoResponse) {
|
||||||
|
val lastPeriodStart = try {
|
||||||
|
cycleInfo.last_period_start?.let { LocalDate.parse(it) }
|
||||||
|
} catch (e: Exception) { null }
|
||||||
|
|
||||||
|
val newSettings = uiState.settings.copy(
|
||||||
|
averageCycleLength = cycleInfo.average_cycle_length,
|
||||||
|
averagePeriodLength = cycleInfo.average_period_length,
|
||||||
|
lastPeriodStart = lastPeriodStart
|
||||||
|
)
|
||||||
|
|
||||||
|
uiState = uiState.copy(settings = newSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleRateLimitExceeded() {
|
private suspend fun handleRateLimitExceeded() {
|
||||||
@@ -357,7 +502,43 @@ class CalendarViewModel : ViewModel() {
|
|||||||
|
|
||||||
private suspend fun saveEventToServer(event: CalendarEvent) {
|
private suspend fun saveEventToServer(event: CalendarEvent) {
|
||||||
try {
|
try {
|
||||||
// TODO: Реализовать сохранение на сервер через API
|
// Преобразуем CalendarEvent в CalendarEntryCreate для API
|
||||||
|
val entryCreate = CalendarEntryCreate(
|
||||||
|
date = event.date.toString(),
|
||||||
|
type = event.type.name,
|
||||||
|
mood = event.mood?.name,
|
||||||
|
symptoms = event.symptoms.map { it.name },
|
||||||
|
notes = event.notes.ifEmpty { null },
|
||||||
|
flow_intensity = event.flowIntensity
|
||||||
|
)
|
||||||
|
|
||||||
|
// Отправляем данные на сервер
|
||||||
|
val response = repository.createCalendarEntry(entryCreate)
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
// Обновляем локальное событие с ID с сервера, если такой вернулся
|
||||||
|
response.body()?.let { serverEvent ->
|
||||||
|
val currentEvents = uiState.events.toMutableMap()
|
||||||
|
val dateEvents = currentEvents[event.date]?.toMutableList() ?: return@let
|
||||||
|
|
||||||
|
// Находим и заменяем событие, добавляя ему ID с сервера
|
||||||
|
val index = dateEvents.indexOfFirst {
|
||||||
|
it.type == event.type && it.date == event.date
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index != -1) {
|
||||||
|
dateEvents[index] = serverEvent
|
||||||
|
currentEvents[event.date] = dateEvents
|
||||||
|
uiState = uiState.copy(
|
||||||
|
events = currentEvents,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Обработка ошибки API
|
||||||
|
uiState = uiState.copy(error = "Ошибка сохранения на сервере: ${response.code()}")
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
uiState = uiState.copy(error = "Ошибка сохранения: ${e.message}")
|
uiState = uiState.copy(error = "Ошибка сохранения: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|||||||
56
docs/CALENDAR_API_RESPONSE.json
Normal file
56
docs/CALENDAR_API_RESPONSE.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": "uuid-1",
|
||||||
|
"date": "2025-09-10",
|
||||||
|
"type": "MENSTRUATION",
|
||||||
|
"mood": "GOOD",
|
||||||
|
"symptoms": ["CRAMPS", "FATIGUE"],
|
||||||
|
"notes": "День начала цикла",
|
||||||
|
"flow_intensity": 3,
|
||||||
|
"is_predicted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid-2",
|
||||||
|
"date": "2025-09-11",
|
||||||
|
"type": "MENSTRUATION",
|
||||||
|
"mood": "NEUTRAL",
|
||||||
|
"symptoms": ["CRAMPS"],
|
||||||
|
"notes": "",
|
||||||
|
"flow_intensity": 2,
|
||||||
|
"is_predicted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid-3",
|
||||||
|
"date": "2025-09-12",
|
||||||
|
"type": "MENSTRUATION",
|
||||||
|
"mood": "GOOD",
|
||||||
|
"symptoms": [],
|
||||||
|
"notes": "",
|
||||||
|
"flow_intensity": 1,
|
||||||
|
"is_predicted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid-4",
|
||||||
|
"date": "2025-10-08",
|
||||||
|
"type": "MENSTRUATION",
|
||||||
|
"is_predicted": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid-5",
|
||||||
|
"date": "2025-09-24",
|
||||||
|
"type": "OVULATION",
|
||||||
|
"is_predicted": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cycle_info": {
|
||||||
|
"average_cycle_length": 28,
|
||||||
|
"average_period_length": 5,
|
||||||
|
"last_period_start": "2025-09-10",
|
||||||
|
"next_period_predicted": "2025-10-08",
|
||||||
|
"next_ovulation_predicted": "2025-09-24",
|
||||||
|
"fertile_window_start": "2025-09-21",
|
||||||
|
"fertile_window_end": "2025-09-25"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user