calendar events features

This commit is contained in:
2025-09-26 16:01:49 +09:00
parent 86b5df6c10
commit 6f969dbd1a
9 changed files with 421 additions and 86 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -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,

View File

@@ -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
)

View File

@@ -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 ->

View File

@@ -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> {

View File

@@ -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)
}

View File

@@ -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}")
}

View 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"
}
}