calendar events features
This commit is contained in:
@@ -23,7 +23,8 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WomanSafe"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -125,16 +125,16 @@ interface WomanSafeApi {
|
||||
|
||||
// Calendar endpoints
|
||||
@GET("api/v1/calendar/entries")
|
||||
suspend fun getCalendarEntries(): Response<Any>
|
||||
suspend fun getCalendarEntries(): Response<CalendarEntriesResponse>
|
||||
|
||||
@POST("api/v1/calendar/entries")
|
||||
suspend fun createCalendarEntry(): Response<Any>
|
||||
@POST("api/v1/calendar/entry")
|
||||
suspend fun createCalendarEntry(@Body entry: CalendarEntryCreate): Response<CalendarEvent>
|
||||
|
||||
@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}")
|
||||
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}")
|
||||
suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response<Any>
|
||||
|
||||
@@ -260,7 +260,7 @@ data class CalendarEntry(
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class CalendarEntryResponse(
|
||||
data class LegacyCalendarEntryResponse( // Переименовано, чтобы избежать конфликта с CalendarModels.kt
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
|
||||
@@ -36,7 +36,7 @@ enum class SymptomType {
|
||||
|
||||
// Событие календаря
|
||||
data class CalendarEvent(
|
||||
val id: Int? = null,
|
||||
val id: String? = null, // Изменено с Int? на String?, так как в API используются UUID
|
||||
val date: LocalDate,
|
||||
val type: CalendarEventType,
|
||||
val isActual: Boolean = true, // true - фактическое, false - прогноз
|
||||
@@ -45,35 +45,91 @@ data class CalendarEvent(
|
||||
val notes: String = "",
|
||||
val flowIntensity: Int? = null, // Интенсивность выделений 1-5
|
||||
val createdAt: LocalDate = LocalDate.now(),
|
||||
val updatedAt: LocalDate = LocalDate.now()
|
||||
val updatedAt: LocalDate = LocalDate.now(),
|
||||
val isPredicted: Boolean = false // Добавлено поле для совместимости с API
|
||||
)
|
||||
|
||||
// Настройки цикла
|
||||
data class CycleSettings(
|
||||
val averageCycleLength: Int = 28, // Средняя длина цикла
|
||||
val averagePeriodLength: Int = 5, // Средняя длина менструации
|
||||
val lastPeriodStart: LocalDate? = null, // Последняя менструация
|
||||
val reminderDaysBefore: Int = 2, // За сколько дней напоминать
|
||||
val enablePredictions: Boolean = true,
|
||||
val enableReminders: Boolean = true
|
||||
val averageCycleLength: Int = 28,
|
||||
val averagePeriodLength: Int = 5,
|
||||
val lastPeriodStart: LocalDate? = null,
|
||||
val irregularCycles: Boolean = false,
|
||||
val trackSymptoms: Boolean = true,
|
||||
val trackMood: Boolean = true,
|
||||
val showPredictions: Boolean = true,
|
||||
val reminderDaysBefore: Int = 3 // За сколько дней напоминать о приближающемся цикле
|
||||
)
|
||||
|
||||
// Прогнозы цикла
|
||||
// Прогноз цикла
|
||||
data class CyclePrediction(
|
||||
val nextPeriodStart: LocalDate,
|
||||
val nextPeriodEnd: LocalDate,
|
||||
val nextOvulation: LocalDate,
|
||||
val fertileWindowStart: LocalDate,
|
||||
val fertileWindowEnd: LocalDate,
|
||||
val confidence: Float = 0.8f // Уверенность в прогнозе 0-1
|
||||
val confidence: Float = 0.85f // Добавлено значение достоверности прогноза в %
|
||||
)
|
||||
|
||||
// Статистика цикла
|
||||
data class CycleStatistics(
|
||||
val averageCycleLength: Float,
|
||||
val cycleVariation: Float, // Отклонение в днях
|
||||
val lastCycles: List<Int>, // Длины последних циклов
|
||||
val periodLengthAverage: Float,
|
||||
val commonSymptoms: List<SymptomType>,
|
||||
val moodPatterns: Map<CalendarEventType, MoodType>
|
||||
val averageCycleLength: Float = 0f,
|
||||
val cycleVariation: Float = 0f,
|
||||
val lastCycles: List<Int> = emptyList(),
|
||||
val periodLengthAverage: Float = 0f,
|
||||
val commonSymptoms: List<SymptomType> = emptyList(),
|
||||
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
|
||||
|
||||
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 val authInterceptor = Interceptor { chain ->
|
||||
|
||||
@@ -191,24 +191,20 @@ class ApiRepository {
|
||||
}
|
||||
|
||||
// Calendar methods
|
||||
suspend fun getCalendarEntries(): Response<Any> {
|
||||
suspend fun getCalendarEntries(): Response<CalendarEntriesResponse> {
|
||||
return apiService.getCalendarEntries()
|
||||
}
|
||||
|
||||
suspend fun createCalendarEntry(): Response<Any> {
|
||||
return apiService.createCalendarEntry()
|
||||
suspend fun createCalendarEntry(entry: CalendarEntryCreate): Response<CalendarEvent> {
|
||||
return apiService.createCalendarEntry(entry)
|
||||
}
|
||||
|
||||
suspend fun getCalendarEntry(entryId: Int): Response<Any> {
|
||||
return apiService.getCalendarEntry(entryId.toString())
|
||||
suspend fun updateCalendarEntry(id: String, entry: CalendarEntryUpdate): Response<CalendarEvent> {
|
||||
return apiService.updateCalendarEntry(id, entry)
|
||||
}
|
||||
|
||||
suspend fun updateCalendarEntry(entryId: Int): Response<Any> {
|
||||
return apiService.updateCalendarEntry(entryId.toString())
|
||||
}
|
||||
|
||||
suspend fun deleteCalendarEntry(entryId: Int): Response<Any> {
|
||||
return apiService.deleteCalendarEntry(entryId.toString())
|
||||
suspend fun deleteCalendarEntry(id: String): Response<Any> {
|
||||
return apiService.deleteCalendarEntry(id)
|
||||
}
|
||||
|
||||
suspend fun getCycleOverview(): Response<Any> {
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.graphics.Color
|
||||
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.sp
|
||||
import com.example.womansafe.data.model.*
|
||||
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||
import com.example.womansafe.util.DateUtils
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -623,12 +623,12 @@ private fun AddEventDialog(
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// Создаем строки по 2 симптома вручную, избегая chunked
|
||||
// Создаем строки по 2 симптома, безопасно обрабатывая границы массива
|
||||
val symptoms = SymptomType.values().toList()
|
||||
var i = 0
|
||||
while (i < symptoms.size) {
|
||||
// Обходим список с шагом 2, чтобы обрабатывать симптомы парами
|
||||
for (i in symptoms.indices step 2) {
|
||||
Row {
|
||||
// Первый симптом в строке
|
||||
// Первый симптом в строке (всегда существует)
|
||||
FilterChip(
|
||||
onClick = {
|
||||
val symptom = symptoms[i]
|
||||
@@ -645,7 +645,7 @@ private fun AddEventDialog(
|
||||
.padding(end = 4.dp)
|
||||
)
|
||||
|
||||
// Второй симптом в строке (если есть)
|
||||
// Второй симптом в строке (проверяем, что он существует)
|
||||
if (i + 1 < symptoms.size) {
|
||||
FilterChip(
|
||||
onClick = {
|
||||
@@ -667,7 +667,6 @@ private fun AddEventDialog(
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,6 +714,9 @@ private fun CycleSettingsDialog(
|
||||
var periodLength by remember { mutableStateOf(settings.averagePeriodLength.toString()) }
|
||||
var lastPeriodDate by remember { mutableStateOf(settings.lastPeriodStart) }
|
||||
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(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -728,7 +730,8 @@ private fun CycleSettingsDialog(
|
||||
onValueChange = { cycleLength = it },
|
||||
label = { Text("Средняя длина цикла (дни)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -736,7 +739,8 @@ private fun CycleSettingsDialog(
|
||||
onValueChange = { periodLength = it },
|
||||
label = { Text("Длина менструации (дни)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -744,9 +748,47 @@ private fun CycleSettingsDialog(
|
||||
onValueChange = { reminderDays = it },
|
||||
label = { Text("Напоминать за (дни)") },
|
||||
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: Добавить выбор даты последних месячных
|
||||
lastPeriodDate?.let { date ->
|
||||
Text(
|
||||
@@ -763,7 +805,10 @@ private fun CycleSettingsDialog(
|
||||
averageCycleLength = cycleLength.toIntOrNull() ?: settings.averageCycleLength,
|
||||
averagePeriodLength = periodLength.toIntOrNull() ?: settings.averagePeriodLength,
|
||||
reminderDaysBefore = reminderDays.toIntOrNull() ?: settings.reminderDaysBefore,
|
||||
lastPeriodStart = lastPeriodDate
|
||||
lastPeriodStart = lastPeriodDate,
|
||||
trackSymptoms = trackSymptoms,
|
||||
trackMood = trackMood,
|
||||
showPredictions = showPredictions
|
||||
)
|
||||
onSave(newSettings)
|
||||
}
|
||||
|
||||
@@ -38,62 +38,207 @@ class CalendarViewModel : ViewModel() {
|
||||
private var loadJob: Job? = null
|
||||
private var retryCount = 0
|
||||
private val maxRetryCount = 5
|
||||
private val cacheValidityDuration = 10 * 60 * 1000 // 10 минут в миллисекундах
|
||||
private val cacheValidityDuration = 30 * 60 * 1000 // 30 минут
|
||||
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 {
|
||||
loadCalendarData()
|
||||
loadCycleSettings()
|
||||
// Добавляем небольшую задержку для предотвращения одновременных запросов
|
||||
viewModelScope.launch {
|
||||
delay(100L * (0..5).random()) // Случайная задержка до 500 мс
|
||||
if (!initialized) {
|
||||
initialized = true
|
||||
loadInitialData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadInitialData() {
|
||||
debounceJob?.cancel()
|
||||
debounceJob = viewModelScope.launch {
|
||||
delay(300) // Добавляем задержку для дебаунсинга
|
||||
loadCalendarData()
|
||||
loadCycleSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCalendarData() {
|
||||
// Если данные были обновлены недавно и это не первая загрузка, не делаем запрос
|
||||
if (uiState.events.isNotEmpty() &&
|
||||
System.currentTimeMillis() - uiState.lastRefreshed < cacheValidityDuration) {
|
||||
// Если запрос уже выполняется глобально, не начинаем новый
|
||||
if (isRequestInProgress) {
|
||||
return
|
||||
}
|
||||
|
||||
// Отменяем предыдущий запрос, если он еще выполняется
|
||||
// Глобальная проверка интервала между запросами
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastRefreshTimestamp < GLOBAL_COOLDOWN) {
|
||||
return
|
||||
}
|
||||
|
||||
// Проверка интервала охлаждения при ошибках
|
||||
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()
|
||||
|
||||
loadJob = viewModelScope.launch {
|
||||
uiState = uiState.copy(isLoading = true, error = null)
|
||||
try {
|
||||
// Загружаем события календаря
|
||||
val response = repository.getCalendarEntries()
|
||||
if (response.isSuccessful) {
|
||||
// Сбрасываем счетчик повторных попыток при успехе
|
||||
retryCount = 0
|
||||
// Используем дебаунсинг для предотвращения частых запросов
|
||||
debounceJob = viewModelScope.launch {
|
||||
delay(300) // Задержка в 300 мс для дебаунсинга
|
||||
|
||||
// TODO: Преобразовать ответ API в события календаря
|
||||
generatePredictions()
|
||||
calculateStatistics()
|
||||
loadJob = launch {
|
||||
try {
|
||||
isRequestInProgress = true // Устанавливаем глобальный флаг запроса
|
||||
lastRefreshTimestamp = System.currentTimeMillis()
|
||||
|
||||
// Обновляем время последнего успешного запроса
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
lastRefreshed = System.currentTimeMillis()
|
||||
)
|
||||
} else if (response.code() == 429) {
|
||||
// Обработка Too Many Requests с экспоненциальным откатом
|
||||
handleRateLimitExceeded()
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка загрузки данных календаря: ${response.code()}"
|
||||
)
|
||||
uiState = uiState.copy(isLoading = true, error = null)
|
||||
|
||||
// Загружаем события календаря
|
||||
val response = repository.getCalendarEntries()
|
||||
if (response.isSuccessful) {
|
||||
// Сбрасываем счетчики ошибок при успехе
|
||||
errorCount = 0
|
||||
errorCooldownInterval = 5000L // Сбрасываем до начального значения
|
||||
retryCount = 0
|
||||
|
||||
// Обрабатываем ответ API
|
||||
response.body()?.let { calendarResponse ->
|
||||
// Преобразуем API ответ в объекты домена
|
||||
val events = processCalendarEntries(calendarResponse)
|
||||
|
||||
// Обновляем настройки и прогнозы из данных API
|
||||
updateCycleInfoFromResponse(calendarResponse.cycle_info)
|
||||
|
||||
// Генерируем прогнозы на основе данных
|
||||
generatePredictions()
|
||||
|
||||
// Рассчитываем статистику
|
||||
calculateStatistics()
|
||||
|
||||
// Обновляем состояние UI
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
events = events,
|
||||
lastRefreshed = System.currentTimeMillis(),
|
||||
error = null
|
||||
)
|
||||
}
|
||||
} else if (response.code() == 429) {
|
||||
// Обработка Too Many Requests с экспоненциальным откатом
|
||||
handleRateLimitExceeded()
|
||||
} else {
|
||||
// Увеличиваем счетчик ошибок
|
||||
handleApiError(response.code().toString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
// Увеличиваем счетчик ошибок
|
||||
handleApiError(e.message ?: "Неизвестная ошибка")
|
||||
} finally {
|
||||
isRequestInProgress = false // Сбрасываем флаг в любом случае
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка сети: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleApiError(errorMsg: String) {
|
||||
errorCount++
|
||||
lastErrorTimestamp = System.currentTimeMillis()
|
||||
|
||||
// Экспоненциально увеличиваем время ожидания при повторных ошибках
|
||||
if (errorCount >= MAX_ERROR_COUNT) {
|
||||
errorCooldownInterval = (errorCooldownInterval * 2).coerceAtMost(60000L) // максимум 1 минута
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
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() {
|
||||
if (retryCount < maxRetryCount) {
|
||||
retryCount++
|
||||
@@ -357,7 +502,43 @@ class CalendarViewModel : ViewModel() {
|
||||
|
||||
private suspend fun saveEventToServer(event: CalendarEvent) {
|
||||
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) {
|
||||
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