From c00924be85a63f2d18a598287ec37243589c918a Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Tue, 14 Oct 2025 20:10:15 +0900 Subject: [PATCH] main commit --- .idea/deploymentTargetSelector.xml | 8 + .idea/vcs.xml | 6 + app/build.gradle.kts | 15 + .../2.json | 1602 +++++++++++++++++ .../smartsoltech/wellshe/data/AppDatabase.kt | 71 +- .../smartsoltech/wellshe/data/Converters.kt | 21 + .../wellshe/data/DatabaseMigration.kt | 98 + .../smartsoltech/wellshe/data/dao/BodyDao.kt | 151 ++ .../wellshe/data/dao/CycleForecastDao.kt | 33 + .../wellshe/data/dao/CycleHistoryDao.kt | 42 + .../wellshe/data/dao/CyclePeriodDao.kt | 24 + .../wellshe/data/dao/CycleSettingsDao.kt | 36 + .../kr/smartsoltech/wellshe/data/dao/Daos.kt | 45 - .../wellshe/data/dao/HealthRecordDao.kt | 29 +- .../wellshe/data/entity/BodyEntities.kt | 156 ++ .../data/entity/CycleForecastEntity.kt | 21 + .../wellshe/data/entity/CycleHistoryEntity.kt | 27 + .../wellshe/data/entity/CyclePeriodEntity.kt | 16 + .../data/entity/CycleSettingsEntity.kt | 47 + .../wellshe/data/entity/Entities.kt | 29 - .../data/entity/HealthRecordConverters.kt | 16 + .../wellshe/data/entity/HealthRecordEntity.kt | 22 + .../wellshe/data/repo/BodyRepo.kt | 119 ++ .../data/repository/CycleRepository.kt | 308 ++++ .../data/repository/HealthRepository.kt | 16 + .../data/repository/WellSheRepository.kt | 86 +- .../kr/smartsoltech/wellshe/di/AppModule.kt | 67 +- .../kr/smartsoltech/wellshe/di/CycleModule.kt | 59 + .../domain/analytics/CycleAnalytics.kt | 83 +- .../wellshe/domain/models/CycleForecast.kt | 95 + .../wellshe/domain/models/CycleSettings.kt | 116 ++ .../services/CycleSettingsExportService.kt | 172 ++ .../wellshe/model/CycleForecast.kt | 46 + .../wellshe/model/CycleSettings.kt | 10 + .../wellshe/model/JournalConverters.kt | 24 + .../wellshe/model/JournalDatabase.kt | 30 + .../wellshe/model/JournalEntry.kt | 22 + .../wellshe/model/JournalEntryDao.kt | 20 + .../wellshe/model/JournalEntryEntity.kt | 16 + .../wellshe/model/JournalRepository.kt | 39 + .../wellshe/ui/analytics/AnalyticsScreen.kt | 310 ++++ .../ui/analytics/AnalyticsViewModel.kt | 27 + .../wellshe/ui/body/BodyScreen.kt | 110 ++ .../wellshe/ui/body/BodyViewModel.kt | 125 ++ .../wellshe/ui/body/tabs/ActivityTab.kt | 176 ++ .../wellshe/ui/body/tabs/WaterTab.kt | 184 ++ .../wellshe/ui/body/tabs/WeightTab.kt | 176 ++ .../wellshe/ui/components/CommonComponents.kt | 210 +++ .../ui/components/WaterIntakeDialog.kt | 100 + .../ui/components/WeightInputDialog.kt | 122 ++ .../ui/components/WorkoutSelectionDialog.kt | 134 ++ .../wellshe/ui/cycle/CycleScreen.kt | 898 ++------- .../wellshe/ui/cycle/CycleSettingsDialog.kt | 93 + .../wellshe/ui/cycle/CycleUiState.kt | 41 + .../wellshe/ui/cycle/CycleViewModel.kt | 445 ++--- .../wellshe/ui/cycle/JournalEditorDialog.kt | 128 ++ .../ui/cycle/ModernDatePickerDialog.kt | 59 + .../wellshe/ui/cycle/SportContent.kt | 49 + .../wellshe/ui/cycle/WaterContent.kt | 57 + .../wellshe/ui/cycle/WeightContent.kt | 74 + .../ui/cycle/components/CycleCalendar.kt | 236 +++ .../ui/cycle/components/CycleComponents.kt | 17 + .../ui/cycle/components/QuickActions.kt | 149 ++ .../settings/CycleSettingsContentPart1.kt | 539 ++++++ .../settings/CycleSettingsContentPart2.kt | 475 +++++ .../ui/cycle/settings/CycleSettingsScreen.kt | 231 +++ .../cycle/settings/CycleSettingsViewModel.kt | 571 ++++++ .../wellshe/ui/dashboard/DashboardScreen.kt | 23 +- .../ui/dashboard/DashboardViewModel.kt | 57 +- .../wellshe/ui/fitness/FitnessViewModel.kt | 124 +- .../wellshe/ui/health/HealthOverviewScreen.kt | 63 +- .../wellshe/ui/health/HealthScreen.kt | 60 +- .../wellshe/ui/health/HealthViewModel.kt | 134 +- .../wellshe/ui/health/SportScreen.kt | 77 + .../wellshe/ui/health/SportViewModel.kt | 57 + .../wellshe/ui/health/WaterScreen.kt | 64 + .../wellshe/ui/health/WaterViewModel.kt | 45 + .../wellshe/ui/health/WeightScreen.kt | 71 + .../wellshe/ui/health/WeightViewModel.kt | 37 + .../wellshe/ui/mood/MoodScreen.kt | 258 +++ .../wellshe/ui/mood/MoodViewModel.kt | 33 + .../wellshe/ui/navigation/AppNavGraph.kt | 42 + .../wellshe/ui/navigation/BottomNavItem.kt | 52 + .../wellshe/ui/navigation/BottomNavigation.kt | 117 ++ .../wellshe/ui/navigation/Navigation.kt | 176 -- .../ui/navigation/WellSheNavigation.kt | 43 + .../wellshe/ui/profile/ProfileScreen.kt | 457 +++-- .../wellshe/ui/settings/SettingsScreen.kt | 238 ++- .../wellshe/ui/settings/SettingsViewModel.kt | 90 +- .../kr/smartsoltech/wellshe/ui/theme/Color.kt | 18 + .../wellshe/ui/theme/CustomColors.kt | 10 + .../kr/smartsoltech/wellshe/ui/theme/Shape.kt | 13 + .../kr/smartsoltech/wellshe/ui/theme/Theme.kt | 83 +- .../kr/smartsoltech/wellshe/ui/theme/Type.kt | 75 +- .../workers/CycleNotificationManager.kt | 247 +++ app/src/main/res/drawable/ic_notification.xml | 11 + .../domain/analytics/CycleAnalyticsTest.kt | 74 +- .../domain/analytics/SleepAnalyticsTest.kt | 20 +- settings.gradle.kts | 1 + 99 files changed, 10569 insertions(+), 1880 deletions(-) create mode 100644 .idea/vcs.xml create mode 100644 app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/2.json create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/Converters.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/DatabaseMigration.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/dao/BodyDao.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/dao/CycleForecastDao.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/dao/CycleHistoryDao.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/dao/CyclePeriodDao.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/dao/CycleSettingsDao.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/entity/BodyEntities.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/entity/CycleForecastEntity.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/entity/CycleHistoryEntity.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/entity/CyclePeriodEntity.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/entity/CycleSettingsEntity.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/entity/HealthRecordConverters.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/entity/HealthRecordEntity.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/repo/BodyRepo.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/repository/CycleRepository.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/data/repository/HealthRepository.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/di/CycleModule.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/domain/models/CycleForecast.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/domain/models/CycleSettings.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/domain/services/CycleSettingsExportService.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/model/CycleForecast.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/model/CycleSettings.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/model/JournalConverters.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/model/JournalDatabase.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/model/JournalEntry.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/model/JournalEntryDao.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/model/JournalEntryEntity.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/model/JournalRepository.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/analytics/AnalyticsScreen.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/analytics/AnalyticsViewModel.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/body/BodyScreen.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/body/BodyViewModel.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/body/tabs/ActivityTab.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/body/tabs/WaterTab.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/body/tabs/WeightTab.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/components/CommonComponents.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/components/WaterIntakeDialog.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/components/WeightInputDialog.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/components/WorkoutSelectionDialog.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleSettingsDialog.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleUiState.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/JournalEditorDialog.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/ModernDatePickerDialog.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/SportContent.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/WaterContent.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/WeightContent.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/components/CycleCalendar.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/components/CycleComponents.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/components/QuickActions.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsContentPart1.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsContentPart2.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsScreen.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsViewModel.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/health/SportScreen.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/health/SportViewModel.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/health/WaterScreen.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/health/WaterViewModel.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/health/WeightScreen.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/health/WeightViewModel.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/mood/MoodScreen.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/mood/MoodViewModel.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/AppNavGraph.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/BottomNavItem.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/BottomNavigation.kt delete mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/Navigation.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/WellSheNavigation.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/theme/CustomColors.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Shape.kt create mode 100644 app/src/main/java/kr/smartsoltech/wellshe/workers/CycleNotificationManager.kt create mode 100644 app/src/main/res/drawable/ic_notification.xml diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..4077c07 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6f9be20..fb02aeb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,6 +17,16 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // Добавляем путь для экспорта схемы Room + javaCompileOptions { + annotationProcessorOptions { + arguments += mapOf( + "room.schemaLocation" to "$projectDir/schemas", + "room.incremental" to "true" + ) + } + } } buildTypes { @@ -67,6 +77,11 @@ dependencies { implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("com.google.code.gson:gson:2.10.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + implementation("com.github.PhilJay:MPAndroidChart:v3.1.0") + implementation("com.squareup.moshi:moshi:1.15.0") + implementation("com.squareup.moshi:moshi-kotlin:1.15.0") + implementation("com.squareup.moshi:moshi-adapters:1.15.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2") testImplementation(libs.junit) testImplementation("io.mockk:mockk:1.13.8") diff --git a/app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/2.json b/app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/2.json new file mode 100644 index 0000000..7419ca9 --- /dev/null +++ b/app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/2.json @@ -0,0 +1,1602 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "4d290634d6fbf2a1f2dbd0d4f7f78be4", + "entities": [ + { + "tableName": "water_logs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sleep_logs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` INTEGER NOT NULL, `bedTime` TEXT NOT NULL, `wakeTime` TEXT NOT NULL, `duration` REAL NOT NULL, `quality` TEXT NOT NULL, `notes` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bedTime", + "columnName": "bedTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wakeTime", + "columnName": "wakeTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "workouts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` INTEGER NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `duration` INTEGER NOT NULL, `caloriesBurned` INTEGER NOT NULL, `intensity` TEXT NOT NULL, `notes` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "caloriesBurned", + "columnName": "caloriesBurned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "intensity", + "columnName": "intensity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "calories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` INTEGER NOT NULL, `consumed` INTEGER NOT NULL, `burned` INTEGER NOT NULL, `target` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "burned", + "columnName": "burned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "target", + "columnName": "target", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "steps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `distance` REAL NOT NULL, `caloriesBurned` INTEGER NOT NULL, `target` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distance", + "columnName": "distance", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "caloriesBurned", + "columnName": "caloriesBurned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "target", + "columnName": "target", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user_profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `age` INTEGER NOT NULL, `height` INTEGER NOT NULL, `weight` REAL NOT NULL, `targetWeight` REAL NOT NULL, `activityLevel` TEXT NOT NULL, `dailyWaterGoal` INTEGER NOT NULL, `dailyCalorieGoal` INTEGER NOT NULL, `dailyStepsGoal` INTEGER NOT NULL, `cycleLength` INTEGER NOT NULL, `periodLength` INTEGER NOT NULL, `lastPeriodDate` INTEGER, `profileImagePath` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "age", + "columnName": "age", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "targetWeight", + "columnName": "targetWeight", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "activityLevel", + "columnName": "activityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dailyWaterGoal", + "columnName": "dailyWaterGoal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dailyCalorieGoal", + "columnName": "dailyCalorieGoal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dailyStepsGoal", + "columnName": "dailyStepsGoal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cycleLength", + "columnName": "cycleLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "periodLength", + "columnName": "periodLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPeriodDate", + "columnName": "lastPeriodDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileImagePath", + "columnName": "profileImagePath", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "WorkoutSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, `exerciseId` INTEGER NOT NULL, `kcalTotal` REAL, `distanceKm` REAL, `notes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "exerciseId", + "columnName": "exerciseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kcalTotal", + "columnName": "kcalTotal", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "distanceKm", + "columnName": "distanceKm", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_WorkoutSession_startedAt", + "unique": false, + "columnNames": [ + "startedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `${TABLE_NAME}` (`startedAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "WorkoutSessionParam", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sessionId` INTEGER NOT NULL, `key` TEXT NOT NULL, `valueNum` REAL, `valueText` TEXT, `unit` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueNum", + "columnName": "valueNum", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "valueText", + "columnName": "valueText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "WorkoutEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sessionId` INTEGER NOT NULL, `ts` INTEGER NOT NULL, `eventType` TEXT NOT NULL, `payloadJson` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "cycle_periods", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `startDate` INTEGER NOT NULL, `endDate` INTEGER, `flow` TEXT NOT NULL, `symptoms` TEXT NOT NULL, `mood` TEXT NOT NULL, `cycleLength` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symptoms", + "columnName": "symptoms", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mood", + "columnName": "mood", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cycleLength", + "columnName": "cycleLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "health_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` INTEGER NOT NULL, `weight` REAL, `heartRate` INTEGER, `bloodPressureS` INTEGER, `bloodPressureD` INTEGER, `temperature` REAL, `mood` TEXT, `energyLevel` INTEGER, `stressLevel` INTEGER, `symptoms` TEXT, `notes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "heartRate", + "columnName": "heartRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bloodPressureS", + "columnName": "bloodPressureS", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bloodPressureD", + "columnName": "bloodPressureD", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "temperature", + "columnName": "temperature", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "mood", + "columnName": "mood", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "energyLevel", + "columnName": "energyLevel", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stressLevel", + "columnName": "stressLevel", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "symptoms", + "columnName": "symptoms", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "cycle_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baselineCycleLength` INTEGER NOT NULL, `cycleVariabilityDays` INTEGER NOT NULL, `periodLengthDays` INTEGER NOT NULL, `lutealPhaseDays` TEXT NOT NULL, `lastPeriodStart` INTEGER, `ovulationMethod` TEXT NOT NULL, `allowManualOvulation` INTEGER NOT NULL, `hormonalContraception` TEXT NOT NULL, `isPregnant` INTEGER NOT NULL, `isPostpartum` INTEGER NOT NULL, `isLactating` INTEGER NOT NULL, `perimenopause` INTEGER NOT NULL, `historyWindowCycles` INTEGER NOT NULL, `excludeOutliers` INTEGER NOT NULL, `tempUnit` TEXT NOT NULL, `bbtTimeWindow` TEXT NOT NULL, `timezone` TEXT NOT NULL, `periodReminderDaysBefore` INTEGER NOT NULL, `ovulationReminderDaysBefore` INTEGER NOT NULL, `pmsWindowDays` INTEGER NOT NULL, `deviationAlertDays` INTEGER NOT NULL, `fertileWindowMode` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baselineCycleLength", + "columnName": "baselineCycleLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cycleVariabilityDays", + "columnName": "cycleVariabilityDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "periodLengthDays", + "columnName": "periodLengthDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lutealPhaseDays", + "columnName": "lutealPhaseDays", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPeriodStart", + "columnName": "lastPeriodStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ovulationMethod", + "columnName": "ovulationMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "allowManualOvulation", + "columnName": "allowManualOvulation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hormonalContraception", + "columnName": "hormonalContraception", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPregnant", + "columnName": "isPregnant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPostpartum", + "columnName": "isPostpartum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLactating", + "columnName": "isLactating", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "perimenopause", + "columnName": "perimenopause", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "historyWindowCycles", + "columnName": "historyWindowCycles", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "excludeOutliers", + "columnName": "excludeOutliers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tempUnit", + "columnName": "tempUnit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bbtTimeWindow", + "columnName": "bbtTimeWindow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "periodReminderDaysBefore", + "columnName": "periodReminderDaysBefore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ovulationReminderDaysBefore", + "columnName": "ovulationReminderDaysBefore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pmsWindowDays", + "columnName": "pmsWindowDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviationAlertDays", + "columnName": "deviationAlertDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fertileWindowMode", + "columnName": "fertileWindowMode", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "cycle_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `periodStart` INTEGER NOT NULL, `periodEnd` INTEGER, `ovulationDate` INTEGER, `notes` TEXT NOT NULL, `atypical` INTEGER NOT NULL, `flow` TEXT NOT NULL, `symptoms` TEXT NOT NULL, `mood` TEXT NOT NULL, `cycleLength` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "periodStart", + "columnName": "periodStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "periodEnd", + "columnName": "periodEnd", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ovulationDate", + "columnName": "ovulationDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "atypical", + "columnName": "atypical", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symptoms", + "columnName": "symptoms", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mood", + "columnName": "mood", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cycleLength", + "columnName": "cycleLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_cycle_history_periodStart", + "unique": true, + "columnNames": [ + "periodStart" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cycle_history_periodStart` ON `${TABLE_NAME}` (`periodStart`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "cycle_forecast", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nextPeriodStart` INTEGER, `nextOvulation` INTEGER, `fertileStart` INTEGER, `fertileEnd` INTEGER, `pmsStart` INTEGER, `updatedAt` INTEGER NOT NULL, `isReliable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nextPeriodStart", + "columnName": "nextPeriodStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nextOvulation", + "columnName": "nextOvulation", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fertileStart", + "columnName": "fertileStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fertileEnd", + "columnName": "fertileEnd", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmsStart", + "columnName": "pmsStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReliable", + "columnName": "isReliable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Nutrient", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `code` TEXT NOT NULL, `name` TEXT NOT NULL, `unit` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Nutrient_code", + "unique": true, + "columnNames": [ + "code" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Nutrient_code` ON `${TABLE_NAME}` (`code`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "Beverage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `brand` TEXT, `category` TEXT NOT NULL, `source` TEXT NOT NULL, `sourceRef` TEXT NOT NULL, `isCaffeinated` INTEGER NOT NULL, `isSweetened` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "brand", + "columnName": "brand", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceRef", + "columnName": "sourceRef", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isCaffeinated", + "columnName": "isCaffeinated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSweetened", + "columnName": "isSweetened", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BeverageServing", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `beverageId` INTEGER NOT NULL, `label` TEXT NOT NULL, `volumeMl` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "beverageId", + "columnName": "beverageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeMl", + "columnName": "volumeMl", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BeverageServingNutrient", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `servingId` INTEGER NOT NULL, `nutrientId` INTEGER NOT NULL, `amountPerServing` REAL NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "servingId", + "columnName": "servingId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nutrientId", + "columnName": "nutrientId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountPerServing", + "columnName": "amountPerServing", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "WaterLog", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ts` INTEGER NOT NULL, `volumeMl` INTEGER NOT NULL, `source` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "volumeMl", + "columnName": "volumeMl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_WaterLog_ts", + "unique": false, + "columnNames": [ + "ts" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_WaterLog_ts` ON `${TABLE_NAME}` (`ts`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "BeverageLog", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ts` INTEGER NOT NULL, `beverageId` INTEGER NOT NULL, `servingId` INTEGER NOT NULL, `servingsCount` INTEGER NOT NULL, `notes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "beverageId", + "columnName": "beverageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "servingId", + "columnName": "servingId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "servingsCount", + "columnName": "servingsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BeverageLog_ts", + "unique": false, + "columnNames": [ + "ts" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BeverageLog_ts` ON `${TABLE_NAME}` (`ts`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "BeverageLogNutrient", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `beverageLogId` INTEGER NOT NULL, `nutrientId` INTEGER NOT NULL, `amountTotal` REAL NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "beverageLogId", + "columnName": "beverageLogId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nutrientId", + "columnName": "nutrientId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountTotal", + "columnName": "amountTotal", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "WeightLog", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ts` INTEGER NOT NULL, `weightKg` REAL NOT NULL, `source` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weightKg", + "columnName": "weightKg", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_WeightLog_ts", + "unique": false, + "columnNames": [ + "ts" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_WeightLog_ts` ON `${TABLE_NAME}` (`ts`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "Exercise", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `category` TEXT NOT NULL, `description` TEXT, `metValue` REAL, `source` TEXT NOT NULL, `sourceRef` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metValue", + "columnName": "metValue", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceRef", + "columnName": "sourceRef", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ExerciseParam", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `exerciseId` INTEGER NOT NULL, `key` TEXT NOT NULL, `valueType` TEXT NOT NULL, `unit` TEXT, `required` INTEGER NOT NULL, `defaultValue` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exerciseId", + "columnName": "exerciseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "valueType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "required", + "columnName": "required", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ExerciseFormula", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `exerciseId` INTEGER NOT NULL, `name` TEXT NOT NULL, `exprKcal` TEXT NOT NULL, `notes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exerciseId", + "columnName": "exerciseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exprKcal", + "columnName": "exprKcal", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ExerciseFormulaVar", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `formulaId` INTEGER NOT NULL, `varKey` TEXT NOT NULL, `required` INTEGER NOT NULL, `unit` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "formulaId", + "columnName": "formulaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "varKey", + "columnName": "varKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "required", + "columnName": "required", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "CatalogVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localVersion` INTEGER NOT NULL, `source` TEXT NOT NULL, `lastSyncAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localVersion", + "columnName": "localVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSyncAt", + "columnName": "lastSyncAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_CatalogVersion_source", + "unique": true, + "columnNames": [ + "source" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_CatalogVersion_source` ON `${TABLE_NAME}` (`source`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4d290634d6fbf2a1f2dbd0d4f7f78be4')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/AppDatabase.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/AppDatabase.kt index c41599d..67b89fa 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/data/AppDatabase.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/AppDatabase.kt @@ -5,30 +5,79 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import kr.smartsoltech.wellshe.data.entity.* import kr.smartsoltech.wellshe.data.dao.* -import kr.smartsoltech.wellshe.data.converter.DateConverters +import java.time.LocalDate +import androidx.room.TypeConverter @Database( entities = [ + // Основные сущности WaterLogEntity::class, - WorkoutEntity::class, SleepLogEntity::class, - CyclePeriodEntity::class, - HealthRecordEntity::class, + WorkoutEntity::class, CalorieEntity::class, StepsEntity::class, - UserProfileEntity::class + UserProfileEntity::class, + WorkoutSession::class, + WorkoutSessionParam::class, + WorkoutEvent::class, + CyclePeriodEntity::class, + HealthRecordEntity::class, + + // Новые сущности модуля "Настройки цикла" + CycleSettingsEntity::class, + CycleHistoryEntity::class, + CycleForecastEntity::class, + + // Дополнительные сущности из BodyEntities.kt + Nutrient::class, + Beverage::class, + BeverageServing::class, + BeverageServingNutrient::class, + WaterLog::class, + BeverageLog::class, + BeverageLogNutrient::class, + WeightLog::class, + Exercise::class, + ExerciseParam::class, + ExerciseFormula::class, + ExerciseFormulaVar::class, + CatalogVersion::class ], - version = 1, - exportSchema = false + version = 2, + exportSchema = true ) -@TypeConverters(DateConverters::class) +@TypeConverters(LocalDateConverter::class, InstantConverter::class, StringListConverter::class) abstract class AppDatabase : RoomDatabase() { abstract fun waterLogDao(): WaterLogDao - abstract fun workoutDao(): WorkoutDao abstract fun sleepLogDao(): SleepLogDao - abstract fun cyclePeriodDao(): CyclePeriodDao - abstract fun healthRecordDao(): HealthRecordDao + abstract fun workoutDao(): WorkoutDao abstract fun calorieDao(): CalorieDao abstract fun stepsDao(): StepsDao abstract fun userProfileDao(): UserProfileDao + abstract fun cyclePeriodDao(): CyclePeriodDao + abstract fun healthRecordDao(): HealthRecordDao + + // Новые DAO для модуля "Настройки цикла" + abstract fun cycleSettingsDao(): CycleSettingsDao + abstract fun cycleHistoryDao(): CycleHistoryDao + abstract fun cycleForecastDao(): CycleForecastDao + + // Дополнительные DAO для repo + abstract fun beverageLogDao(): BeverageLogDao + abstract fun beverageLogNutrientDao(): BeverageLogNutrientDao + abstract fun beverageServingNutrientDao(): BeverageServingNutrientDao + abstract fun weightLogDao(): WeightLogDao + abstract fun workoutSessionDao(): WorkoutSessionDao + abstract fun workoutSessionParamDao(): WorkoutSessionParamDao + abstract fun workoutEventDao(): WorkoutEventDao + abstract fun exerciseDao(): ExerciseDao + abstract fun exerciseFormulaDao(): ExerciseFormulaDao + abstract fun exerciseFormulaVarDao(): ExerciseFormulaVarDao +} + +class LocalDateConverter { + @TypeConverter + fun fromTimestamp(value: Long?): LocalDate? = value?.let { java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault()).toLocalDate() } + @TypeConverter + fun dateToTimestamp(date: LocalDate?): Long? = date?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli() } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/Converters.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/Converters.kt new file mode 100644 index 0000000..c7a286b --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/Converters.kt @@ -0,0 +1,21 @@ +package kr.smartsoltech.wellshe.data + +import androidx.room.TypeConverter +import java.time.Instant + +class InstantConverter { + @TypeConverter + fun fromTimestamp(value: Long?): Instant? = value?.let { Instant.ofEpochMilli(it) } + @TypeConverter + fun instantToTimestamp(instant: Instant?): Long? = instant?.toEpochMilli() +} + +class StringListConverter { + @TypeConverter + fun fromString(value: String?): List? = value?.let { + if (it.isEmpty()) emptyList() else it.split("||") + } + @TypeConverter + fun listToString(list: List?): String = list?.joinToString("||") ?: "" +} + diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/DatabaseMigration.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/DatabaseMigration.kt new file mode 100644 index 0000000..2badc29 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/DatabaseMigration.kt @@ -0,0 +1,98 @@ +package kr.smartsoltech.wellshe.data + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Миграция базы данных с версии 1 на версию 2. + * Добавляет таблицы для модуля "Настройки цикла". + */ +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + // Создание таблицы cycle_settings + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `cycle_settings` ( + `id` INTEGER NOT NULL, + `baselineCycleLength` INTEGER NOT NULL, + `cycleVariabilityDays` INTEGER NOT NULL, + `periodLengthDays` INTEGER NOT NULL, + `lutealPhaseDays` TEXT NOT NULL, + `lastPeriodStart` INTEGER, + `ovulationMethod` TEXT NOT NULL, + `allowManualOvulation` INTEGER NOT NULL, + `hormonalContraception` TEXT NOT NULL, + `isPregnant` INTEGER NOT NULL, + `isPostpartum` INTEGER NOT NULL, + `isLactating` INTEGER NOT NULL, + `perimenopause` INTEGER NOT NULL, + `historyWindowCycles` INTEGER NOT NULL, + `excludeOutliers` INTEGER NOT NULL, + `tempUnit` TEXT NOT NULL, + `bbtTimeWindow` TEXT NOT NULL, + `timezone` TEXT NOT NULL, + `periodReminderDaysBefore` INTEGER NOT NULL, + `ovulationReminderDaysBefore` INTEGER NOT NULL, + `pmsWindowDays` INTEGER NOT NULL, + `deviationAlertDays` INTEGER NOT NULL, + `fertileWindowMode` TEXT NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + + // Создание таблицы cycle_history + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `cycle_history` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `periodStart` INTEGER NOT NULL, + `periodEnd` INTEGER, + `ovulationDate` INTEGER, + `notes` TEXT NOT NULL, + `atypical` INTEGER NOT NULL, + `flow` TEXT NOT NULL DEFAULT '', + `symptoms` TEXT NOT NULL DEFAULT '', + `mood` TEXT NOT NULL DEFAULT '', + `cycleLength` INTEGER + ) + """.trimIndent() + ) + + // Индекс для cycle_history по периоду начала + database.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_cycle_history_periodStart` ON `cycle_history` (`periodStart`)" + ) + + // Создание таблицы cycle_forecast + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `cycle_forecast` ( + `id` INTEGER NOT NULL, + `nextPeriodStart` INTEGER, + `nextOvulation` INTEGER, + `fertileStart` INTEGER, + `fertileEnd` INTEGER, + `pmsStart` INTEGER, + `updatedAt` INTEGER NOT NULL, + `isReliable` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + + // Импорт существующих данных из таблицы cycle_periods в cycle_history + database.execSQL( + """ + INSERT OR IGNORE INTO cycle_history (periodStart, periodEnd, notes, atypical) + SELECT startDate, endDate, + CASE WHEN flow != '' OR mood != '' OR symptoms != '' + THEN 'Flow: ' || flow || ', Mood: ' || mood + ELSE '' + END, + 0 + FROM cycle_periods + """.trimIndent() + ) + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/dao/BodyDao.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/BodyDao.kt new file mode 100644 index 0000000..6b1467e --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/BodyDao.kt @@ -0,0 +1,151 @@ +package kr.smartsoltech.wellshe.data.dao + +import androidx.room.* +import kr.smartsoltech.wellshe.data.entity.* +import java.time.Instant +import java.time.LocalDate + +@Dao +interface NutrientDao { + @Query("SELECT * FROM Nutrient WHERE code = :code LIMIT 1") + suspend fun getByCode(code: String): Nutrient? + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(nutrient: Nutrient): Long + @Query("SELECT * FROM Nutrient") + suspend fun getAll(): List +} + +@Dao +interface BeverageDao { + @Query("SELECT * FROM Beverage WHERE id = :id") + suspend fun getById(id: Long): Beverage? + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(beverage: Beverage): Long + @Query("SELECT * FROM Beverage WHERE name LIKE :query LIMIT 20") + suspend fun search(query: String): List +} + +@Dao +interface BeverageServingDao { + @Query("SELECT * FROM BeverageServing WHERE beverageId = :beverageId") + suspend fun getByBeverage(beverageId: Long): List + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(serving: BeverageServing): Long +} + +@Dao +interface BeverageServingNutrientDao { + @Query("SELECT * FROM BeverageServingNutrient WHERE servingId = :servingId") + suspend fun getByServing(servingId: Long): List + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(nutrient: BeverageServingNutrient): Long +} + +@Dao +interface WaterLogDao { + @Query("SELECT * FROM water_logs WHERE date = :date ORDER BY timestamp DESC") + suspend fun getWaterLogsForDate(date: LocalDate): List + + @Query("SELECT SUM(amount) FROM water_logs WHERE date = :date") + suspend fun getTotalWaterForDate(date: LocalDate): Int? + + @Insert + suspend fun insertWaterLog(waterLog: WaterLogEntity) + + @Delete + suspend fun deleteWaterLog(waterLog: WaterLogEntity) +} + +@Dao +interface BeverageLogDao { + @Query("SELECT * FROM BeverageLog WHERE ts BETWEEN :from AND :to ORDER BY ts DESC") + suspend fun getLogs(from: Instant, to: Instant): List + @Insert + suspend fun insert(log: BeverageLog): Long +} + +@Dao +interface BeverageLogNutrientDao { + @Query("SELECT * FROM BeverageLogNutrient WHERE beverageLogId = :beverageLogId") + suspend fun getByLog(beverageLogId: Long): List + @Insert + suspend fun insert(nutrient: BeverageLogNutrient): Long +} + +@Dao +interface WeightLogDao { + @Query("SELECT * FROM WeightLog ORDER BY ts DESC LIMIT 1") + suspend fun getLatestWeightKg(): WeightLog? + @Insert + suspend fun insert(log: WeightLog): Long + @Query("SELECT * FROM WeightLog WHERE ts BETWEEN :from AND :to ORDER BY ts DESC") + suspend fun getLogs(from: Instant, to: Instant): List +} + +@Dao +interface ExerciseDao { + @Query("SELECT * FROM Exercise WHERE id = :id") + suspend fun getById(id: Long): Exercise? + @Query("SELECT * FROM Exercise WHERE name LIKE :query LIMIT 20") + suspend fun search(query: String): List + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(exercise: Exercise): Long +} + +@Dao +interface ExerciseParamDao { + @Query("SELECT * FROM ExerciseParam WHERE exerciseId = :exerciseId") + suspend fun getByExercise(exerciseId: Long): List + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(param: ExerciseParam): Long +} + +@Dao +interface ExerciseFormulaDao { + @Query("SELECT * FROM ExerciseFormula WHERE exerciseId = :exerciseId") + suspend fun getByExercise(exerciseId: Long): List + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(formula: ExerciseFormula): Long +} + +@Dao +interface ExerciseFormulaVarDao { + @Query("SELECT * FROM ExerciseFormulaVar WHERE formulaId = :formulaId") + suspend fun getByFormula(formulaId: Long): List + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(varDef: ExerciseFormulaVar): Long +} + +@Dao +interface WorkoutSessionDao { + @Query("SELECT * FROM WorkoutSession WHERE startedAt BETWEEN :from AND :to ORDER BY startedAt DESC") + suspend fun getSessions(from: Instant, to: Instant): List + @Insert + suspend fun insert(session: WorkoutSession): Long + @Query("SELECT * FROM WorkoutSession WHERE id = :id") + suspend fun getById(id: Long): WorkoutSession? +} + +@Dao +interface WorkoutSessionParamDao { + @Query("SELECT * FROM WorkoutSessionParam WHERE sessionId = :sessionId") + suspend fun getBySession(sessionId: Long): List + @Insert + suspend fun insert(param: WorkoutSessionParam): Long +} + +@Dao +interface WorkoutEventDao { + @Query("SELECT * FROM WorkoutEvent WHERE sessionId = :sessionId ORDER BY ts ASC") + suspend fun getBySession(sessionId: Long): List + @Insert + suspend fun insert(event: WorkoutEvent): Long +} + +@Dao +interface CatalogVersionDao { + @Query("SELECT * FROM CatalogVersion WHERE source = :source LIMIT 1") + suspend fun getBySource(source: String): CatalogVersion? + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(version: CatalogVersion): Long +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/dao/CycleForecastDao.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/CycleForecastDao.kt new file mode 100644 index 0000000..ec91918 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/CycleForecastDao.kt @@ -0,0 +1,33 @@ +package kr.smartsoltech.wellshe.data.dao + +import androidx.room.* +import kotlinx.coroutines.flow.Flow +import kr.smartsoltech.wellshe.data.entity.CycleForecastEntity + +@Dao +interface CycleForecastDao { + @Query("SELECT * FROM cycle_forecast WHERE id = 1") + fun getForecastFlow(): Flow + + @Query("SELECT * FROM cycle_forecast WHERE id = 1") + suspend fun getForecast(): CycleForecastEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(forecast: CycleForecastEntity): Long + + @Update + suspend fun update(forecast: CycleForecastEntity) + + @Transaction + suspend fun insertOrUpdate(forecast: CycleForecastEntity) { + val existing = getForecast() + if (existing == null) { + insert(forecast) + } else { + update(forecast) + } + } + + @Query("DELETE FROM cycle_forecast") + suspend fun deleteAll() +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/dao/CycleHistoryDao.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/CycleHistoryDao.kt new file mode 100644 index 0000000..27a5b9c --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/CycleHistoryDao.kt @@ -0,0 +1,42 @@ +package kr.smartsoltech.wellshe.data.dao + +import androidx.room.* +import kotlinx.coroutines.flow.Flow +import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity +import java.time.LocalDate + +@Dao +interface CycleHistoryDao { + @Query("SELECT * FROM cycle_history ORDER BY periodStart DESC") + fun getAllFlow(): Flow> + + @Query("SELECT * FROM cycle_history ORDER BY periodStart DESC") + suspend fun getAll(): List + + @Query("SELECT * FROM cycle_history ORDER BY periodStart DESC LIMIT :limit") + suspend fun getRecentCycles(limit: Int): List + + @Query("SELECT * FROM cycle_history WHERE atypical = 0 ORDER BY periodStart DESC LIMIT :limit") + suspend fun getRecentTypicalCycles(limit: Int): List + + @Query("SELECT * FROM cycle_history WHERE id = :id") + suspend fun getById(id: Long): CycleHistoryEntity? + + @Query("SELECT * FROM cycle_history WHERE periodStart = :date") + suspend fun getByStartDate(date: LocalDate): CycleHistoryEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(cycle: CycleHistoryEntity): Long + + @Update + suspend fun update(cycle: CycleHistoryEntity) + + @Delete + suspend fun delete(cycle: CycleHistoryEntity) + + @Query("DELETE FROM cycle_history") + suspend fun deleteAll() + + @Query("SELECT * FROM cycle_history WHERE periodStart BETWEEN :startDate AND :endDate") + suspend fun getCyclesInRange(startDate: LocalDate, endDate: LocalDate): List +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/dao/CyclePeriodDao.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/CyclePeriodDao.kt new file mode 100644 index 0000000..9f5d417 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/CyclePeriodDao.kt @@ -0,0 +1,24 @@ +package kr.smartsoltech.wellshe.data.dao + +import androidx.room.* +import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity +import java.time.LocalDate + +@Dao +interface CyclePeriodDao { + @Query("SELECT * FROM cycle_periods ORDER BY startDate DESC") + suspend fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(period: CyclePeriodEntity): Long + + @Update + suspend fun update(period: CyclePeriodEntity) + + @Delete + suspend fun delete(period: CyclePeriodEntity) + + @Query("SELECT * FROM cycle_periods WHERE startDate = :date LIMIT 1") + suspend fun getByStartDate(date: LocalDate): CyclePeriodEntity? +} + diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/dao/CycleSettingsDao.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/CycleSettingsDao.kt new file mode 100644 index 0000000..f68bb67 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/CycleSettingsDao.kt @@ -0,0 +1,36 @@ +package kr.smartsoltech.wellshe.data.dao + +import androidx.room.* +import kotlinx.coroutines.flow.Flow +import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity + +@Dao +interface CycleSettingsDao { + @Query("SELECT * FROM cycle_settings WHERE id = 1") + fun getSettingsFlow(): Flow + + @Query("SELECT * FROM cycle_settings WHERE id = 1") + suspend fun getSettings(): CycleSettingsEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(settings: CycleSettingsEntity): Long + + @Update + suspend fun update(settings: CycleSettingsEntity) + + @Transaction + suspend fun insertOrUpdate(settings: CycleSettingsEntity) { + val existing = getSettings() + if (existing == null) { + insert(settings) + } else { + update(settings) + } + } + + @Query("DELETE FROM cycle_settings") + suspend fun deleteAll() + + @Query("UPDATE cycle_settings SET lastPeriodStart = :date WHERE id = 1") + suspend fun updateLastPeriodStart(date: java.time.LocalDate) +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/dao/Daos.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/Daos.kt index 69abb93..2400fb5 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/data/dao/Daos.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/Daos.kt @@ -5,51 +5,6 @@ import kotlinx.coroutines.flow.Flow import kr.smartsoltech.wellshe.data.entity.* import java.time.LocalDate -@Dao -interface WaterLogDao { - @Query("SELECT * FROM water_logs WHERE date = :date ORDER BY timestamp DESC") - fun getWaterLogsForDate(date: LocalDate): Flow> - - @Query("SELECT SUM(amount) FROM water_logs WHERE date = :date") - suspend fun getTotalWaterForDate(date: LocalDate): Int? - - @Insert - suspend fun insertWaterLog(waterLog: WaterLogEntity) - - @Delete - suspend fun deleteWaterLog(waterLog: WaterLogEntity) - - @Query("SELECT * FROM water_logs WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC") - fun getWaterLogsForPeriod(startDate: LocalDate, endDate: LocalDate): Flow> -} - -@Dao -interface CyclePeriodDao { - @Query("SELECT * FROM cycle_periods ORDER BY startDate DESC") - fun getAllPeriods(): Flow> - - @Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT 1") - suspend fun getLastPeriod(): CyclePeriodEntity? - - @Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT 1") - fun getCurrentPeriod(): Flow - - @Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT :limit") - fun getRecentPeriods(limit: Int): Flow> - - @Insert - suspend fun insertPeriod(period: CyclePeriodEntity) - - @Update - suspend fun updatePeriod(period: CyclePeriodEntity) - - @Delete - suspend fun deletePeriod(period: CyclePeriodEntity) - - @Query("SELECT * FROM cycle_periods WHERE startDate BETWEEN :startDate AND :endDate") - fun getPeriodsInRange(startDate: LocalDate, endDate: LocalDate): Flow> -} - @Dao interface SleepLogDao { @Query("SELECT * FROM sleep_logs WHERE date = :date") diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/dao/HealthRecordDao.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/HealthRecordDao.kt index 613bdc9..21083cd 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/data/dao/HealthRecordDao.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/dao/HealthRecordDao.kt @@ -1,36 +1,29 @@ package kr.smartsoltech.wellshe.data.dao import androidx.room.* -import kotlinx.coroutines.flow.Flow import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity import java.time.LocalDate @Dao interface HealthRecordDao { - @Query("SELECT * FROM health_records WHERE date = :date") - suspend fun getHealthRecordForDate(date: LocalDate): HealthRecordEntity? - - @Query("SELECT * FROM health_records ORDER BY date DESC LIMIT :limit") - fun getRecentHealthRecords(limit: Int = 30): Flow> + @Query("SELECT * FROM health_records ORDER BY date DESC") + suspend fun getAll(): List @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertHealthRecord(record: HealthRecordEntity) + suspend fun insert(record: HealthRecordEntity): Long @Update - suspend fun updateHealthRecord(record: HealthRecordEntity) + suspend fun update(record: HealthRecordEntity) @Delete - suspend fun deleteHealthRecord(record: HealthRecordEntity) + suspend fun delete(record: HealthRecordEntity) - @Query("DELETE FROM health_records WHERE id = :id") - suspend fun deleteHealthRecordById(id: Long) + @Query("SELECT * FROM health_records WHERE date = :date LIMIT 1") + suspend fun getByDate(date: LocalDate): HealthRecordEntity? - @Query("SELECT * FROM health_records WHERE date BETWEEN :startDate AND :endDate ORDER BY date") - suspend fun getHealthRecordsInRange(startDate: LocalDate, endDate: LocalDate): List + @Query("SELECT * FROM health_records ORDER BY date DESC") + fun getAllFlow(): kotlinx.coroutines.flow.Flow> - @Query("SELECT AVG(weight) FROM health_records WHERE weight IS NOT NULL AND date BETWEEN :startDate AND :endDate") - suspend fun getAverageWeight(startDate: LocalDate, endDate: LocalDate): Float? - - @Query("SELECT AVG(heartRate) FROM health_records WHERE heartRate IS NOT NULL AND date BETWEEN :startDate AND :endDate") - suspend fun getAverageHeartRate(startDate: LocalDate, endDate: LocalDate): Float? + @Query("SELECT * FROM health_records WHERE date = :date LIMIT 1") + fun getByDateFlow(date: LocalDate): kotlinx.coroutines.flow.Flow } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/entity/BodyEntities.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/BodyEntities.kt new file mode 100644 index 0000000..01821b9 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/BodyEntities.kt @@ -0,0 +1,156 @@ +package kr.smartsoltech.wellshe.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.Index +import java.time.Instant + +@Entity(tableName = "Nutrient", indices = [Index("code", unique = true)]) +data class Nutrient( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val code: String, + val name: String, + val unit: String +) + +@Entity(tableName = "Beverage") +data class Beverage( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + val brand: String?, + val category: String, + val source: String, + val sourceRef: String, + val isCaffeinated: Boolean, + val isSweetened: Boolean, + val createdAt: Instant +) + +@Entity(tableName = "BeverageServing") +data class BeverageServing( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val beverageId: Long, + val label: String, + val volumeMl: Int +) + +@Entity(tableName = "BeverageServingNutrient") +data class BeverageServingNutrient( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val servingId: Long, + val nutrientId: Long, + val amountPerServing: Float +) + +@Entity(tableName = "WaterLog", indices = [Index("ts")]) +data class WaterLog( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val ts: Instant, + val volumeMl: Int, + val source: String +) + +@Entity(tableName = "BeverageLog", indices = [Index("ts")]) +data class BeverageLog( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val ts: Instant, + val beverageId: Long, + val servingId: Long, + val servingsCount: Int, + val notes: String? +) + +@Entity(tableName = "BeverageLogNutrient") +data class BeverageLogNutrient( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val beverageLogId: Long, + val nutrientId: Long, + val amountTotal: Float +) + +@Entity(tableName = "WeightLog", indices = [Index("ts")]) +data class WeightLog( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val ts: Instant, + val weightKg: Float, + val source: String +) + +@Entity(tableName = "Exercise") +data class Exercise( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + val category: String, + val description: String?, + val metValue: Float?, + val source: String, + val sourceRef: String? +) + +@Entity(tableName = "ExerciseParam") +data class ExerciseParam( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val exerciseId: Long, + val key: String, + val valueType: String, + val unit: String?, + val required: Boolean, + val defaultValue: String? +) + +@Entity(tableName = "ExerciseFormula") +data class ExerciseFormula( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val exerciseId: Long, + val name: String, + val exprKcal: String, + val notes: String? +) + +@Entity(tableName = "ExerciseFormulaVar") +data class ExerciseFormulaVar( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val formulaId: Long, + val varKey: String, + val required: Boolean, + val unit: String? +) + +@Entity(tableName = "WorkoutSession", indices = [Index("startedAt")]) +data class WorkoutSession( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val startedAt: Instant, + val endedAt: Instant?, + val exerciseId: Long, + val kcalTotal: Float?, + val distanceKm: Float?, + val notes: String? +) + +@Entity(tableName = "WorkoutSessionParam") +data class WorkoutSessionParam( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val sessionId: Long, + val key: String, + val valueNum: Float?, + val valueText: String?, + val unit: String? +) + +@Entity(tableName = "WorkoutEvent") +data class WorkoutEvent( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val sessionId: Long, + val ts: Instant, + val eventType: String, + val payloadJson: String +) + +@Entity(tableName = "CatalogVersion", indices = [Index("source", unique = true)]) +data class CatalogVersion( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val localVersion: Int, + val source: String, + val lastSyncAt: Instant +) + diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/entity/CycleForecastEntity.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/CycleForecastEntity.kt new file mode 100644 index 0000000..6114094 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/CycleForecastEntity.kt @@ -0,0 +1,21 @@ +package kr.smartsoltech.wellshe.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.Instant +import java.time.LocalDate + +/** + * Кэш прогнозов цикла для быстрого доступа в UI. + */ +@Entity(tableName = "cycle_forecast") +data class CycleForecastEntity( + @PrimaryKey val id: Int = 1, // Singleton + val nextPeriodStart: LocalDate? = null, + val nextOvulation: LocalDate? = null, + val fertileStart: LocalDate? = null, + val fertileEnd: LocalDate? = null, + val pmsStart: LocalDate? = null, + val updatedAt: Instant = Instant.now(), + val isReliable: Boolean = true // Flag для пониженной точности при определенных статусах +) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/entity/CycleHistoryEntity.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/CycleHistoryEntity.kt new file mode 100644 index 0000000..5274c6a --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/CycleHistoryEntity.kt @@ -0,0 +1,27 @@ +package kr.smartsoltech.wellshe.data.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import java.time.LocalDate + +/** + * История циклов для расчета прогнозов и анализа. + */ +@Entity( + tableName = "cycle_history", + indices = [Index(value = ["periodStart"], unique = true)] +) +data class CycleHistoryEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val periodStart: LocalDate, + val periodEnd: LocalDate? = null, + val ovulationDate: LocalDate? = null, + val notes: String = "", + val atypical: Boolean = false, + // Добавляем поля для соответствия с CyclePeriodEntity + val flow: String = "", + val symptoms: List = emptyList(), + val mood: String = "", + val cycleLength: Int? = null +) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/entity/CyclePeriodEntity.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/CyclePeriodEntity.kt new file mode 100644 index 0000000..d60579c --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/CyclePeriodEntity.kt @@ -0,0 +1,16 @@ +package kr.smartsoltech.wellshe.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.LocalDate + +@Entity(tableName = "cycle_periods") +data class CyclePeriodEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val startDate: LocalDate, + val endDate: LocalDate?, + val flow: String = "", + val symptoms: List = emptyList(), + val mood: String = "", + val cycleLength: Int? = null +) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/entity/CycleSettingsEntity.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/CycleSettingsEntity.kt new file mode 100644 index 0000000..cfa6771 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/CycleSettingsEntity.kt @@ -0,0 +1,47 @@ +package kr.smartsoltech.wellshe.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.LocalDate + +/** + * Основные настройки для модуля отслеживания менструального цикла. + */ +@Entity(tableName = "cycle_settings") +data class CycleSettingsEntity( + @PrimaryKey val id: Int = 1, // Singleton + + // Основные параметры цикла + val baselineCycleLength: Int = 28, + val cycleVariabilityDays: Int = 3, + val periodLengthDays: Int = 5, + val lutealPhaseDays: String = "auto", // "auto" или число (8-17) + val lastPeriodStart: LocalDate? = null, + + // Метод определения овуляции + val ovulationMethod: String = "auto", // auto, bbt, lh_test, cervical_mucus, medical + val allowManualOvulation: Boolean = false, + + // Статусы влияющие на точность + val hormonalContraception: String = "none", // none, coc, iud, implant, other + val isPregnant: Boolean = false, + val isPostpartum: Boolean = false, + val isLactating: Boolean = false, + val perimenopause: Boolean = false, + + // Настройки истории и исключения выбросов + val historyWindowCycles: Int = 6, + val excludeOutliers: Boolean = true, + + // Сенсоры и единицы измерения + val tempUnit: String = "C", // C или F + val bbtTimeWindow: String = "06:00-10:00", + val timezone: String = "Asia/Seoul", + + // Уведомления + val periodReminderDaysBefore: Int = 2, + val ovulationReminderDaysBefore: Int = 1, + val pmsWindowDays: Int = 3, + val deviationAlertDays: Int = 5, + val fertileWindowMode: String = "balanced" // conservative, balanced, broad +) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/entity/Entities.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/Entities.kt index d78834a..35e0441 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/data/entity/Entities.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/Entities.kt @@ -13,18 +13,6 @@ data class WaterLogEntity( val timestamp: Long = System.currentTimeMillis() ) -@Entity(tableName = "cycle_periods") -data class CyclePeriodEntity( - @PrimaryKey(autoGenerate = true) - val id: Long = 0, - val startDate: LocalDate, - val endDate: LocalDate?, - val cycleLength: Int = 28, - val flow: String = "medium", // light, medium, heavy - val symptoms: String = "", // JSON строка симптомов - val mood: String = "neutral" -) - @Entity(tableName = "sleep_logs") data class SleepLogEntity( @PrimaryKey(autoGenerate = true) @@ -37,23 +25,6 @@ data class SleepLogEntity( val notes: String = "" ) -@Entity(tableName = "health_records") -data class HealthRecordEntity( - @PrimaryKey(autoGenerate = true) - val id: Long = 0, - val date: LocalDate, - val weight: Float? = null, - val heartRate: Int? = null, - val bloodPressureS: Int? = null, // систолическое - val bloodPressureD: Int? = null, // диастолическое - val temperature: Float? = null, - val mood: String = "neutral", - val energyLevel: Int = 5, // 1-10 - val stressLevel: Int = 5, // 1-10 - val symptoms: String = "", // JSON строка симптомов - val notes: String = "" -) - @Entity(tableName = "workouts") data class WorkoutEntity( @PrimaryKey(autoGenerate = true) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/entity/HealthRecordConverters.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/HealthRecordConverters.kt new file mode 100644 index 0000000..13b38c0 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/HealthRecordConverters.kt @@ -0,0 +1,16 @@ +package kr.smartsoltech.wellshe.data.entity + +import androidx.room.TypeConverter + +class HealthRecordConverters { + @TypeConverter + fun fromSymptomsList(list: List?): String? { + return list?.joinToString(separator = "|") + } + + @TypeConverter + fun toSymptomsList(data: String?): List? { + return data?.split("|")?.filter { it.isNotEmpty() } + } +} + diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/entity/HealthRecordEntity.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/HealthRecordEntity.kt new file mode 100644 index 0000000..26766e9 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/entity/HealthRecordEntity.kt @@ -0,0 +1,22 @@ +package kr.smartsoltech.wellshe.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.LocalDate + +@Entity(tableName = "health_records") +data class HealthRecordEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val date: LocalDate, + val weight: Float?, + val heartRate: Int?, + val bloodPressureS: Int?, + val bloodPressureD: Int?, + val temperature: Float?, + val mood: String?, + val energyLevel: Int?, + val stressLevel: Int?, + val symptoms: List?, + val notes: String? +) + diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/repo/BodyRepo.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/repo/BodyRepo.kt new file mode 100644 index 0000000..96ee5f2 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/repo/BodyRepo.kt @@ -0,0 +1,119 @@ +package kr.smartsoltech.wellshe.data.repo + +import kr.smartsoltech.wellshe.data.dao.* +import kr.smartsoltech.wellshe.data.entity.* +import java.time.Instant + +class BeverageCatalogRepository( + private val beverageDao: BeverageDao, + private val servingDao: BeverageServingDao, + private val servingNutrientDao: BeverageServingNutrientDao +) { + suspend fun search(query: String): List = beverageDao.search("%$query%") + suspend fun getServings(beverageId: Long): List = servingDao.getByBeverage(beverageId) + suspend fun getNutrients(servingId: Long): List = servingNutrientDao.getByServing(servingId) + // Методы syncFromUsda(), syncFromOpenFoodFacts() будут реализованы отдельно +} + +class DrinkLogger( + private val waterLogDao: WaterLogDao, + private val beverageLogDao: BeverageLogDao, + private val beverageLogNutrientDao: BeverageLogNutrientDao, + private val servingNutrientDao: BeverageServingNutrientDao +) { + suspend fun logWater(ts: Instant, volumeMl: Int, source: String = "manual") { + waterLogDao.insertWaterLog(WaterLogEntity(date = ts.atZone(java.time.ZoneId.systemDefault()).toLocalDate(), amount = volumeMl, timestamp = ts.toEpochMilli())) + } + suspend fun logBeverage(ts: Instant, beverageId: Long, servingId: Long, servingsCount: Int, notes: String? = null) { + val logId = beverageLogDao.insert(BeverageLog(ts = ts, beverageId = beverageId, servingId = servingId, servingsCount = servingsCount, notes = notes)) + val nutrients = servingNutrientDao.getByServing(servingId) + nutrients.forEach { n -> + beverageLogNutrientDao.insert( + BeverageLogNutrient( + beverageLogId = logId, + nutrientId = n.nutrientId, + amountTotal = n.amountPerServing * servingsCount + ) + ) + } + } + suspend fun getWaterHistory(days: Int): List { + val today = java.time.LocalDate.now() + return (0 until days).map { offset -> + val date = today.minusDays(offset.toLong()) + waterLogDao.getTotalWaterForDate(date) ?: 0 + }.reversed() + } +} + +class WeightRepository(private val weightLogDao: WeightLogDao) { + suspend fun addWeight(ts: Instant, kg: Float, source: String = "manual") { + weightLogDao.insert(WeightLog(ts = ts, weightKg = kg, source = source)) + } + suspend fun getLatestWeightKg(): Float? = weightLogDao.getLatestWeightKg()?.weightKg + suspend fun getWeightHistory(days: Int): List> { + val today = java.time.LocalDate.now() + return (0 until days).map { offset -> + val date = today.minusDays(offset.toLong()) + val logs = weightLogDao.getLogs(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant(), date.plusDays(1).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()) + val weight = logs.firstOrNull()?.weightKg ?: 0f + date.toString() to weight + }.reversed() + } +} + +class ExerciseCatalogRepository( + private val exerciseDao: ExerciseDao, + private val paramDao: ExerciseParamDao, + private val formulaDao: ExerciseFormulaDao +) { + suspend fun searchExercises(query: String): List = exerciseDao.search("%$query%") + suspend fun getParams(exerciseId: Long): List = paramDao.getByExercise(exerciseId) + suspend fun getFormulas(exerciseId: Long): List = formulaDao.getByExercise(exerciseId) + // Методы syncFromWger(), syncMetTables() будут реализованы отдельно +} + +class WorkoutService( + private val sessionDao: WorkoutSessionDao, + private val paramDao: WorkoutSessionParamDao, + private val eventDao: WorkoutEventDao, + private val weightRepo: WeightRepository, + private val formulaDao: ExerciseFormulaDao, + private val formulaVarDao: ExerciseFormulaVarDao, + private val exerciseDao: ExerciseDao +) { + suspend fun searchExercises(query: String): List = exerciseDao.search("%$query%") + suspend fun getSessions(days: Int): List { + val now = java.time.Instant.now() + val start = now.minusSeconds(days * 24 * 3600L) + return sessionDao.getSessions(start, now) + } + suspend fun stopSession(sessionId: Long) { + val session = sessionDao.getById(sessionId) + if (session != null && session.endedAt == null) { + sessionDao.insert(session.copy(endedAt = java.time.Instant.now())) + eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = java.time.Instant.now(), eventType = "stop", payloadJson = "{}")) + } + } + suspend fun startSession(exerciseId: Long): Long { + val baseWeightKg = weightRepo.getLatestWeightKg() ?: 70f + val sessionId = sessionDao.insert( + WorkoutSession( + startedAt = Instant.now(), + endedAt = null, + exerciseId = exerciseId, + kcalTotal = null, + distanceKm = null, + notes = null + ) + ) + paramDao.insert(WorkoutSessionParam(sessionId = sessionId, key = "baseWeightKg", valueNum = baseWeightKg, valueText = null, unit = "kg")) + eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = Instant.now(), eventType = "start", payloadJson = "{}")) + return sessionId + } + suspend fun updateParam(sessionId: Long, key: String, valueNum: Float?, valueText: String?, unit: String?) { + paramDao.insert(WorkoutSessionParam(sessionId = sessionId, key = key, valueNum = valueNum, valueText = valueText, unit = unit)) + eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = Instant.now(), eventType = "param_change", payloadJson = "{\"key\":\"$key\"}")) + } + // tick(sessionId) и stopSession(sessionId) будут реализованы с расчетом калорий по формуле +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/repository/CycleRepository.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/repository/CycleRepository.kt new file mode 100644 index 0000000..1a2fcaf --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/repository/CycleRepository.kt @@ -0,0 +1,308 @@ +package kr.smartsoltech.wellshe.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kr.smartsoltech.wellshe.data.dao.CycleForecastDao +import kr.smartsoltech.wellshe.data.dao.CycleHistoryDao +import kr.smartsoltech.wellshe.data.dao.CycleSettingsDao +import kr.smartsoltech.wellshe.data.entity.CycleForecastEntity +import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity +import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity +import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity +import kr.smartsoltech.wellshe.domain.models.CycleForecast +import kr.smartsoltech.wellshe.domain.models.CycleSettings +import java.time.Instant +import java.time.LocalDate +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Репозиторий для работы с данными цикла, настройками и прогнозами. + */ +@Singleton +class CycleRepository @Inject constructor( + private val settingsDao: CycleSettingsDao, + private val historyDao: CycleHistoryDao, + private val forecastDao: CycleForecastDao +) { + // Настройки цикла + fun getSettingsFlow(): Flow = settingsDao.getSettingsFlow() + + suspend fun getSettings(): CycleSettingsEntity? = settingsDao.getSettings() + + suspend fun saveSettings(settings: CycleSettingsEntity) { + settingsDao.insertOrUpdate(settings) + recalculateForecasts() // Пересчитываем прогнозы при изменении настроек + } + + suspend fun updateLastPeriodStart(date: LocalDate) { + val settings = settingsDao.getSettings() ?: createDefaultSettings() + settingsDao.insertOrUpdate(settings.copy(lastPeriodStart = date)) + recalculateForecasts() + } + + private suspend fun createDefaultSettings(): CycleSettingsEntity { + return CycleSettingsEntity() + } + + suspend fun resetToDefaults() { + settingsDao.insertOrUpdate(CycleSettingsEntity()) + recalculateForecasts() + } + + // История циклов + fun getAllHistoryFlow(): Flow> = historyDao.getAllFlow() + + suspend fun getAllHistory(): List = historyDao.getAll() + + suspend fun getRecentCycles(limit: Int): List = historyDao.getRecentCycles(limit) + + suspend fun getRecentTypicalCycles(limit: Int): List = + historyDao.getRecentTypicalCycles(limit) + + suspend fun addCycleToHistory(cycle: CycleHistoryEntity): Long { + val id = historyDao.insert(cycle) + recalculateForecasts() + return id + } + + suspend fun updateCycleInHistory(cycle: CycleHistoryEntity) { + historyDao.update(cycle) + recalculateForecasts() + } + + suspend fun deleteCycleFromHistory(cycle: CycleHistoryEntity) { + historyDao.delete(cycle) + recalculateForecasts() + } + + suspend fun markCycleAsAtypical(id: Long, atypical: Boolean) { + historyDao.getById(id)?.let { cycle -> + historyDao.update(cycle.copy(atypical = atypical)) + recalculateForecasts() + } + } + + // Прогнозы + fun getForecastFlow(): Flow = forecastDao.getForecastFlow() + + suspend fun getForecast(): CycleForecastEntity? = forecastDao.getForecast() + + /** + * Пересчитывает прогнозы на основе текущих настроек и истории циклов. + * Вызывается автоматически при изменении настроек или истории. + */ + suspend fun recalculateForecasts() { + val settings = settingsDao.getSettings() ?: return + val history = if (settings.excludeOutliers) { + historyDao.getRecentTypicalCycles(settings.historyWindowCycles) + } else { + historyDao.getRecentCycles(settings.historyWindowCycles) + } + + val forecast = calculateForecast(settings, history) + forecastDao.insertOrUpdate(forecast) + + // Здесь также можно вызвать планирование уведомлений на основе новых прогнозов + scheduleNotifications(forecast) + } + + /** + * Расчет прогноза цикла на основе настроек и истории. + */ + private fun calculateForecast( + settings: CycleSettingsEntity, + history: List + ): CycleForecastEntity { + // Определяем надежность прогноза + val isReliable = !(settings.isPregnant || settings.isPostpartum || + settings.isLactating || settings.perimenopause || + settings.hormonalContraception != "none") + + // Если нет истории и нет даты последней менструации, не можем сделать прогноз + if (history.isEmpty() && settings.lastPeriodStart == null) { + return CycleForecastEntity( + isReliable = isReliable, + updatedAt = Instant.now() + ) + } + + // Находим дату последней менструации + val lastPeriodStart = settings.lastPeriodStart ?: history.firstOrNull()?.periodStart + + if (lastPeriodStart == null) { + return CycleForecastEntity( + isReliable = isReliable, + updatedAt = Instant.now() + ) + } + + // Рассчитываем средний цикл на основе истории или используем базовые настройки + val cycleLength = if (history.size >= 2) { + calculateAverageCycleLength(history) + } else { + settings.baselineCycleLength + } + + // Рассчитываем л��теиновую фазу + val lutealPhase = if (settings.lutealPhaseDays == "auto") { + 14 // Стандартная длина лютеиновой фазы + } else { + try { + settings.lutealPhaseDays.toInt() + } catch (e: NumberFormatException) { + 14 + } + } + + // Рассчитываем даты + val today = LocalDate.now() + val daysSinceLastPeriod = today.toEpochDay() - lastPeriodStart.toEpochDay() + val nextPeriodStart = if (daysSinceLastPeriod >= cycleLength) { + lastPeriodStart.plusDays(cycleLength.toLong() * (daysSinceLastPeriod / cycleLength + 1)) + } else { + lastPeriodStart.plusDays(cycleLength.toLong()) + } + + val ovulationDate = nextPeriodStart.minusDays(lutealPhase.toLong()) + + // Рассчитываем фертильное окно в зависимости от режима + val fertileWindowStart = when (settings.fertileWindowMode) { + "conservative" -> ovulationDate.minusDays(3) + "balanced" -> ovulationDate.minusDays(5) + "broad" -> ovulationDate.minusDays(7) + else -> ovulationDate.minusDays(5) // По умолчанию сбалансированный режим + } + + val fertileWindowEnd = ovulationDate // День овуляции - последний фертильный день + + // Рассчитываем начало ПМС + val pmsStart = nextPeriodStart.minusDays(settings.pmsWindowDays.toLong()) + + return CycleForecastEntity( + nextPeriodStart = nextPeriodStart, + nextOvulation = ovulationDate, + fertileStart = fertileWindowStart, + fertileEnd = fertileWindowEnd, + pmsStart = pmsStart, + isReliable = isReliable, + updatedAt = Instant.now() + ) + } + + /** + * Рассчитывает среднюю длину цикла на основе истории. + */ + private fun calculateAverageCycleLength(history: List): Int { + if (history.size < 2) return 28 // Стандартный цикл если недостаточно данных + + // Сортируем по дате начала + val sortedHistory = history.sortedBy { it.periodStart } + + // Рассчитываем ��лину между началом каждого цикла + val cycleLengths = mutableListOf() + for (i in 0 until sortedHistory.size - 1) { + val current = sortedHistory[i] + val next = sortedHistory[i + 1] + val length = (next.periodStart.toEpochDay() - current.periodStart.toEpochDay()).toInt() + + // Исключаем выбросы (слишком короткие или длинные циклы) + if (length >= 18 && length <= 60) { + cycleLengths.add(length) + } + } + + // Если после фильтрации нет данных, возвращаем стандартный цикл + if (cycleLengths.isEmpty()) return 28 + + // Возвращаем среднюю длину цикла, округленную до целого + return cycleLengths.average().toInt() + } + + /** + * Планирует уведомления на основе рассчитанных прогнозов. + */ + private suspend fun scheduleNotifications(forecast: CycleForecastEntity) { + // Это заглушка для метода планирования уведомлений + // Реальная реализация будет добавлена позже в классе NotificationManager + } + + /** + * Экспортирует настройки цикла в JSON строку. + */ + suspend fun exportSettingsToJson(): String { + // В реальной реализации здесь будет использоваться библиотека для сериализации в JSON + // Например, Gson или Moshi + return "{}" // Заглушка + } + + /** + * Импортирует настройки цикла из JSON строки. + */ + suspend fun importSettingsFromJson(json: String): Boolean { + // В реальной реализации здесь будет использоваться библиотека для десериализации из JSON + return true // Заглушка + } + + // Методы для работы с периодами (CyclePeriodEntity) + suspend fun getAllPeriods(): List { + // Получаем все периоды из истории и преобразуем их в CyclePeriodEntity + val history = historyDao.getAll() + return history.map { historyEntity -> + CyclePeriodEntity( + id = historyEntity.id, + startDate = historyEntity.periodStart, + endDate = historyEntity.periodEnd, + flow = historyEntity.flow, + symptoms = historyEntity.symptoms, + mood = historyEntity.mood, + cycleLength = historyEntity.cycleLength + ) + } + } + + suspend fun insertPeriod(period: CyclePeriodEntity): Long { + // Преобразуем CyclePeriodEntity в CycleHistoryEntity для сохранения + val historyEntity = CycleHistoryEntity( + id = period.id, + periodStart = period.startDate, + periodEnd = period.endDate, + flow = period.flow, + symptoms = period.symptoms, + mood = period.mood, + cycleLength = period.cycleLength, + atypical = false // по умолчанию не отмечаем как нетипичный + ) + return addCycleToHistory(historyEntity) + } + + suspend fun updatePeriod(period: CyclePeriodEntity) { + // Преобразуем CyclePeriodEntity в CycleHistoryEntity для обновления + val historyEntity = CycleHistoryEntity( + id = period.id, + periodStart = period.startDate, + periodEnd = period.endDate, + flow = period.flow, + symptoms = period.symptoms, + mood = period.mood, + cycleLength = period.cycleLength, + atypical = false // сохраняем существующее значение, если возможно + ) + updateCycleInHistory(historyEntity) + } + + suspend fun deletePeriod(period: CyclePeriodEntity) { + val historyEntity = CycleHistoryEntity( + id = period.id, + periodStart = period.startDate, + periodEnd = period.endDate, + flow = period.flow, + symptoms = period.symptoms, + mood = period.mood, + cycleLength = period.cycleLength, + atypical = false + ) + deleteCycleFromHistory(historyEntity) + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/repository/HealthRepository.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/repository/HealthRepository.kt new file mode 100644 index 0000000..92bedfd --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/repository/HealthRepository.kt @@ -0,0 +1,16 @@ +package kr.smartsoltech.wellshe.data.repository + +import kr.smartsoltech.wellshe.data.dao.HealthRecordDao +import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HealthRepository @Inject constructor(private val dao: HealthRecordDao) { + suspend fun getAllRecords(): List = dao.getAll() + suspend fun insertRecord(record: HealthRecordEntity): Long = dao.insert(record) + suspend fun updateRecord(record: HealthRecordEntity) = dao.update(record) + suspend fun deleteRecord(record: HealthRecordEntity) = dao.delete(record) + suspend fun getRecordByDate(date: java.time.LocalDate): HealthRecordEntity? = dao.getByDate(date) +} + diff --git a/app/src/main/java/kr/smartsoltech/wellshe/data/repository/WellSheRepository.kt b/app/src/main/java/kr/smartsoltech/wellshe/data/repository/WellSheRepository.kt index 2b9c242..cc8ad1b 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/data/repository/WellSheRepository.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/data/repository/WellSheRepository.kt @@ -1,11 +1,16 @@ package kr.smartsoltech.wellshe.data.repository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kr.smartsoltech.wellshe.data.dao.* import kr.smartsoltech.wellshe.data.entity.* -import kr.smartsoltech.wellshe.domain.model.* +import kr.smartsoltech.wellshe.domain.model.AppSettings +import kr.smartsoltech.wellshe.domain.model.FitnessData +import kr.smartsoltech.wellshe.domain.model.User +import kr.smartsoltech.wellshe.domain.model.WaterIntake +import kr.smartsoltech.wellshe.domain.model.WorkoutSession import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime @@ -68,18 +73,19 @@ class WellSheRepository @Inject constructor( } fun getWaterIntakeForDate(date: LocalDate): Flow> { - return waterLogDao.getWaterLogsForDate(date).map { entities -> - entities.map { entity -> + return flow { + val entities = waterLogDao.getWaterLogsForDate(date) + emit(entities.map { entity -> WaterIntake( id = entity.id, date = entity.date, - time = LocalTime.ofInstant( - java.time.Instant.ofEpochMilli(entity.timestamp), - java.time.ZoneId.systemDefault() + time = LocalTime.of( + (entity.timestamp % (24 * 60 * 60 * 1000) / (60 * 60 * 1000)).toInt(), + ((entity.timestamp % (60 * 60 * 1000)) / (60 * 1000)).toInt() ), amount = entity.amount / 1000f // конвертируем в литры ) - } + }) } } @@ -189,23 +195,32 @@ class WellSheRepository @Inject constructor( // ================= suspend fun addPeriod(startDate: LocalDate, endDate: LocalDate?, flow: String, symptoms: List, mood: String) { - cyclePeriodDao.insertPeriod( - CyclePeriodEntity( - startDate = startDate, + val period = CyclePeriodEntity( + startDate = startDate, + endDate = endDate, + flow = flow, + symptoms = symptoms, + mood = mood + ) + cyclePeriodDao.insert(period) + } + + suspend fun updatePeriod(periodId: Long, endDate: LocalDate?, flow: String, symptoms: List, mood: String) { + val periods = cyclePeriodDao.getAll() + val existingPeriod = periods.firstOrNull { it.id == periodId } + if (existingPeriod != null) { + val updatedPeriod = existingPeriod.copy( endDate = endDate, flow = flow, - symptoms = symptoms.joinToString(","), + symptoms = symptoms, mood = mood ) - ) + cyclePeriodDao.update(updatedPeriod) + } } - fun getCurrentCyclePeriod(): Flow { - return cyclePeriodDao.getCurrentPeriod() - } - - fun getRecentPeriods(): Flow> { - return cyclePeriodDao.getRecentPeriods(6) + suspend fun getRecentPeriods(): List { + return cyclePeriodDao.getAll().take(6) } // ================= @@ -281,13 +296,34 @@ class WellSheRepository @Inject constructor( // ЗДОРОВЬЕ // ================= - fun getTodayHealthData(): Flow { - // TODO: Реализовать получение данных о здоровье за сегодня - return flowOf(null) + fun getTodayHealthData(): kotlinx.coroutines.flow.Flow { + val today = LocalDate.now() + return healthRecordDao.getByDateFlow(today) } - suspend fun updateHealthRecord(record: HealthRecord) { - // TODO: Реализовать обновление записи о здоровье + fun getAllHealthRecords(): kotlinx.coroutines.flow.Flow> { + return healthRecordDao.getAllFlow() + } + + fun getRecentHealthRecords(limit: Int = 10): kotlinx.coroutines.flow.Flow> { + return healthRecordDao.getAllFlow().map { records: List -> + records.sortedByDescending { r -> r.date }.take(limit) + } + } + + suspend fun saveHealthRecord(record: HealthRecordEntity) { + if (record.id != 0L) { + healthRecordDao.update(record) + } else { + healthRecordDao.insert(record) + } + } + + suspend fun deleteHealthRecord(recordId: Long) { + val record = healthRecordDao.getAll().firstOrNull { it.id == recordId } + if (record != null) { + healthRecordDao.delete(record) + } } // ================= @@ -322,7 +358,9 @@ class WellSheRepository @Inject constructor( } fun getWaterLogsForDate(date: LocalDate): Flow> { - return waterLogDao.getWaterLogsForDate(date) + return flow { + emit(waterLogDao.getWaterLogsForDate(date)) + } } } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/di/AppModule.kt b/app/src/main/java/kr/smartsoltech/wellshe/di/AppModule.kt index 47d9fdb..4d89f46 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/di/AppModule.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/di/AppModule.kt @@ -10,6 +10,10 @@ import dagger.hilt.components.SingletonComponent import kr.smartsoltech.wellshe.data.AppDatabase import kr.smartsoltech.wellshe.data.datastore.DataStoreManager import kr.smartsoltech.wellshe.data.dao.* +import kr.smartsoltech.wellshe.data.repo.DrinkLogger +import kr.smartsoltech.wellshe.data.repo.WeightRepository +import kr.smartsoltech.wellshe.data.repo.WorkoutService +import kr.smartsoltech.wellshe.data.MIGRATION_1_2 import javax.inject.Singleton @Module @@ -28,7 +32,10 @@ object AppModule { context, AppDatabase::class.java, "well_she_db" - ).fallbackToDestructiveMigration().build() + ) + .addMigrations(MIGRATION_1_2) + .fallbackToDestructiveMigration() + .build() // DAO providers @Provides @@ -55,6 +62,64 @@ object AppModule { @Provides fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao() + // DAO для BodyRepo + @Provides + fun provideBeverageLogDao(database: AppDatabase): BeverageLogDao = database.beverageLogDao() + + @Provides + fun provideBeverageLogNutrientDao(database: AppDatabase): BeverageLogNutrientDao = database.beverageLogNutrientDao() + + @Provides + fun provideBeverageServingNutrientDao(database: AppDatabase): BeverageServingNutrientDao = database.beverageServingNutrientDao() + + @Provides + fun provideWeightLogDao(database: AppDatabase): WeightLogDao = database.weightLogDao() + + @Provides + fun provideWorkoutSessionDao(database: AppDatabase): WorkoutSessionDao = database.workoutSessionDao() + + @Provides + fun provideWorkoutSessionParamDao(database: AppDatabase): WorkoutSessionParamDao = database.workoutSessionParamDao() + + @Provides + fun provideWorkoutEventDao(database: AppDatabase): WorkoutEventDao = database.workoutEventDao() + + @Provides + fun provideExerciseDao(database: AppDatabase): ExerciseDao = database.exerciseDao() + + @Provides + fun provideExerciseFormulaDao(database: AppDatabase): ExerciseFormulaDao = database.exerciseFormulaDao() + + @Provides + fun provideExerciseFormulaVarDao(database: AppDatabase): ExerciseFormulaVarDao = database.exerciseFormulaVarDao() + + // Repo providers + @Provides + @Singleton + fun provideDrinkLogger( + waterLogDao: WaterLogDao, + beverageLogDao: BeverageLogDao, + beverageLogNutrientDao: BeverageLogNutrientDao, + servingNutrientDao: BeverageServingNutrientDao + ): DrinkLogger = DrinkLogger(waterLogDao, beverageLogDao, beverageLogNutrientDao, servingNutrientDao) + + @Provides + @Singleton + fun provideWeightRepository(weightLogDao: WeightLogDao): WeightRepository = + WeightRepository(weightLogDao) + + @Provides + @Singleton + fun provideWorkoutService( + sessionDao: WorkoutSessionDao, + paramDao: WorkoutSessionParamDao, + eventDao: WorkoutEventDao, + weightRepo: WeightRepository, + formulaDao: ExerciseFormulaDao, + formulaVarDao: ExerciseFormulaVarDao, + exerciseDao: ExerciseDao + ): WorkoutService = WorkoutService(sessionDao, paramDao, eventDao, weightRepo, formulaDao, formulaVarDao, exerciseDao) + // Repository @Provides @Singleton diff --git a/app/src/main/java/kr/smartsoltech/wellshe/di/CycleModule.kt b/app/src/main/java/kr/smartsoltech/wellshe/di/CycleModule.kt new file mode 100644 index 0000000..0e0947f --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/di/CycleModule.kt @@ -0,0 +1,59 @@ +package kr.smartsoltech.wellshe.di + +import android.content.Context +import androidx.work.WorkManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kr.smartsoltech.wellshe.data.dao.CycleForecastDao +import kr.smartsoltech.wellshe.data.dao.CycleHistoryDao +import kr.smartsoltech.wellshe.data.dao.CycleSettingsDao +import kr.smartsoltech.wellshe.data.repository.CycleRepository +import kr.smartsoltech.wellshe.domain.services.CycleSettingsExportService +import kr.smartsoltech.wellshe.workers.CycleNotificationManager +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object CycleModule { + + @Provides + @Singleton + fun provideCycleRepository( + settingsDao: CycleSettingsDao, + historyDao: CycleHistoryDao, + forecastDao: CycleForecastDao + ): CycleRepository = CycleRepository(settingsDao, historyDao, forecastDao) + + @Provides + @Singleton + fun provideCycleSettingsExportService(): CycleSettingsExportService = + CycleSettingsExportService() + + @Provides + @Singleton + fun provideWorkManager(@ApplicationContext context: Context): WorkManager = + WorkManager.getInstance(context) + + @Provides + @Singleton + fun provideCycleNotificationManager( + @ApplicationContext context: Context, + workManager: WorkManager + ): CycleNotificationManager = CycleNotificationManager(context, workManager) + + // DAO providers + @Provides + fun provideCycleSettingsDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleSettingsDao = + database.cycleSettingsDao() + + @Provides + fun provideCycleHistoryDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleHistoryDao = + database.cycleHistoryDao() + + @Provides + fun provideCycleForecastDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleForecastDao = + database.cycleForecastDao() +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/domain/analytics/CycleAnalytics.kt b/app/src/main/java/kr/smartsoltech/wellshe/domain/analytics/CycleAnalytics.kt index 9dbf265..d6be472 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/domain/analytics/CycleAnalytics.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/domain/analytics/CycleAnalytics.kt @@ -1,19 +1,34 @@ package kr.smartsoltech.wellshe.domain.analytics import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity +import kr.smartsoltech.wellshe.data.entity.CycleStatsEntity +import java.time.LocalDate import java.time.ZoneId +data class CycleForecast( + val nextStart: Long?, + val fertileWindow: Pair?, + val confidence: String +) + +data class CycleStats( + val avgCycle: Int, + val variance: Double, + val lutealLen: Int +) + object CycleAnalytics { /** * Прогноз следующей менструации и фертильного окна * @param periods список последних периодов - * @param stats статистика цикла (вычисляется автоматически) + * @param statsEntity статистика цикла из базы (опционально) * @return прогноз: дата, фертильное окно, доверие */ - fun forecast(periods: List, stats: CycleStats? = null): CycleForecast { + fun forecast(periods: List, statsEntity: CycleStatsEntity? = null): CycleForecast { if (periods.isEmpty()) return CycleForecast(null, null, "низкая") - val calculatedStats = stats ?: calculateStats(periods) + val calculatedStats = calculateStats(periods) + val lastPeriod = periods.first() val lastStartDate = lastPeriod.startDate val lastStartTs = lastStartDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000 @@ -49,27 +64,53 @@ object CycleAnalytics { val cycleLengths = periods.take(periods.size - 1).mapIndexed { index, period -> val nextPeriod = periods[index + 1] java.time.temporal.ChronoUnit.DAYS.between(nextPeriod.startDate, period.startDate).toInt() + }.filter { it > 0 } + + if (cycleLengths.isEmpty()) { + return CycleStats(avgCycle = 28, variance = 5.0, lutealLen = 14) } val avgCycle = cycleLengths.average().toInt() - val variance = cycleLengths.map { (it - avgCycle) * (it - avgCycle) }.average() + val variance = if (cycleLengths.size > 1) { + cycleLengths.map { (it - avgCycle) * (it - avgCycle) }.average() + } else { + 5.0 + } - return CycleStats( - avgCycle = avgCycle, - variance = variance, - lutealLen = 14 // стандартная лютеиновая фаза - ) + // Примерная лютеиновая фаза (обычно 14 дней) + val lutealLen = 14 + + return CycleStats(avgCycle, variance, lutealLen) + } + + /** + * Анализ регулярности цикла + */ + fun analyzeRegularity(periods: List): String { + val stats = calculateStats(periods) + return when { + stats.variance < 2 -> "Очень регулярный" + stats.variance < 5 -> "Регулярный" + stats.variance < 10 -> "Умеренно регулярный" + else -> "Нерегулярный" + } + } + + /** + * Предсказание следующих дат + */ + fun predictNextPeriods(periods: List, count: Int = 3): List { + if (periods.isEmpty()) return emptyList() + + val stats = calculateStats(periods) + val lastPeriod = periods.first() + val predictions = mutableListOf() + + for (i in 1..count) { + val nextDate = lastPeriod.startDate.plusDays((stats.avgCycle * i).toLong()) + predictions.add(nextDate) + } + + return predictions } } - -data class CycleForecast( - val nextStart: Long?, - val fertileWindow: Pair?, - val confidence: String -) - -data class CycleStats( - val avgCycle: Int, - val variance: Double, - val lutealLen: Int -) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/domain/models/CycleForecast.kt b/app/src/main/java/kr/smartsoltech/wellshe/domain/models/CycleForecast.kt new file mode 100644 index 0000000..ddf3a99 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/domain/models/CycleForecast.kt @@ -0,0 +1,95 @@ +package kr.smartsoltech.wellshe.domain.models + +import java.time.LocalDate + +/** + * Доменная модель для истории циклов. + */ +data class CycleHistory( + val id: Long = 0, + val periodStart: LocalDate, + val periodEnd: LocalDate? = null, + val ovulationDate: LocalDate? = null, + val notes: String = "", + val atypical: Boolean = false +) + +/** + * Доменная модель для прогнозов цикла. + */ +data class CycleForecast( + val nextPeriodStart: LocalDate? = null, + val nextOvulation: LocalDate? = null, + val fertileStart: LocalDate? = null, + val fertileEnd: LocalDate? = null, + val pmsStart: LocalDate? = null, + val isReliable: Boolean = true, // Флаг для пониженной точности + val currentCyclePhase: CyclePhase = CyclePhase.UNKNOWN +) + +/** + * Фаза менструального цикла. + */ +enum class CyclePhase { + MENSTRUATION, // Менструация + FOLLICULAR, // Фолликулярная фаза (после менструации до фертильного окна) + FERTILE, // Фертильное окно + OVULATION, // День овуляции + LUTEAL, // Лютеиновая фаза (после овуляции) + PMS, // ПМС (последние дни перед менструацией) + UNKNOWN; // Неизвестная фаза (например, при недостатке данных) + + companion object { + /** + * Определяет текущую фазу цикла на основе прогноза и текущей даты. + */ + fun determinePhase( + today: LocalDate = LocalDate.now(), + nextPeriodStart: LocalDate?, + lastPeriodStart: LocalDate?, + fertileStart: LocalDate?, + fertileEnd: LocalDate?, + ovulationDate: LocalDate?, + pmsStart: LocalDate?, + periodLengthDays: Int = 5 + ): CyclePhase { + if (lastPeriodStart == null || nextPeriodStart == null) return UNKNOWN + + // Определяем конец последней менструации + val lastPeriodEnd = lastPeriodStart.plusDays(periodLengthDays.toLong() - 1) + + return when { + // Период менструации + (today.isEqual(lastPeriodStart) || today.isAfter(lastPeriodStart)) && + (today.isEqual(lastPeriodEnd) || today.isBefore(lastPeriodEnd)) -> MENSTRUATION + + // День овуляции + ovulationDate != null && today.isEqual(ovulationDate) -> OVULATION + + // Фертильное окно + fertileStart != null && fertileEnd != null && + (today.isEqual(fertileStart) || today.isAfter(fertileStart)) && + (today.isEqual(fertileEnd) || today.isBefore(fertileEnd)) && + (ovulationDate == null || !today.isEqual(ovulationDate)) -> FERTILE + + // ПМС + pmsStart != null && + (today.isEqual(pmsStart) || today.isAfter(pmsStart)) && + today.isBefore(nextPeriodStart) -> PMS + + // Лютеиновая фаза (после овуляции/фертильного окна до ПМС) + ovulationDate != null && fertileEnd != null && pmsStart != null && + today.isAfter(fertileEnd) && + today.isBefore(pmsStart) -> LUTEAL + + // Фолликулярная фаза (после менструации до фертильного окна) + lastPeriodEnd != null && fertileStart != null && + today.isAfter(lastPeriodEnd) && + today.isBefore(fertileStart) -> FOLLICULAR + + // Если не удалось определить фазу + else -> UNKNOWN + } + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/domain/models/CycleSettings.kt b/app/src/main/java/kr/smartsoltech/wellshe/domain/models/CycleSettings.kt new file mode 100644 index 0000000..76c93a0 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/domain/models/CycleSettings.kt @@ -0,0 +1,116 @@ +package kr.smartsoltech.wellshe.domain.models + +import java.time.LocalDate + +/** + * Доменная модель для настроек цикла. + */ +data class CycleSettings( + // Основные параметры цикла + val baselineCycleLength: Int = 28, + val cycleVariabilityDays: Int = 3, + val periodLengthDays: Int = 5, + val lutealPhaseDays: String = "auto", // "auto" или число (8-17) + val lastPeriodStart: LocalDate? = null, + + // Метод определения овуляции + val ovulationMethod: OvulationMethod = OvulationMethod.AUTO, + val allowManualOvulation: Boolean = false, + + // Статусы влияющие на точность + val hormonalContraception: HormonalContraceptionType = HormonalContraceptionType.NONE, + val isPregnant: Boolean = false, + val isPostpartum: Boolean = false, + val isLactating: Boolean = false, + val perimenopause: Boolean = false, + + // Настройки истории и исключения выбросов + val historyWindowCycles: Int = 6, + val excludeOutliers: Boolean = true, + + // Сенсоры и единицы измерения + val tempUnit: TemperatureUnit = TemperatureUnit.CELSIUS, + val bbtTimeWindow: String = "06:00-10:00", + val timezone: String = "Asia/Seoul", + + // Уведомления + val periodReminderDaysBefore: Int = 2, + val ovulationReminderDaysBefore: Int = 1, + val pmsWindowDays: Int = 3, + val deviationAlertDays: Int = 5, + val fertileWindowMode: FertileWindowMode = FertileWindowMode.BALANCED +) + +/** + * Метод определения овуляции + */ +enum class OvulationMethod { + AUTO, BBT, LH_TEST, CERVICAL_MUCUS, MEDICAL; + + companion object { + fun fromString(value: String): OvulationMethod = when (value.lowercase()) { + "bbt" -> BBT + "lh_test" -> LH_TEST + "cervical_mucus" -> CERVICAL_MUCUS + "medical" -> MEDICAL + else -> AUTO + } + } + + fun toStorageString(): String = this.name.lowercase() +} + +/** + * Тип гормональной контрацепции + */ +enum class HormonalContraceptionType { + NONE, COC, IUD, IMPLANT, OTHER; + + companion object { + fun fromString(value: String): HormonalContraceptionType = when (value.lowercase()) { + "coc" -> COC + "iud" -> IUD + "implant" -> IMPLANT + "other" -> OTHER + else -> NONE + } + } + + fun toStorageString(): String = this.name.lowercase() +} + +/** + * Единицы измерения температуры + */ +enum class TemperatureUnit { + CELSIUS, FAHRENHEIT; + + companion object { + fun fromString(value: String): TemperatureUnit = when (value.uppercase()) { + "F" -> FAHRENHEIT + else -> CELSIUS + } + } + + fun toStorageString(): String = when (this) { + CELSIUS -> "C" + FAHRENHEIT -> "F" + } +} + +/** + * Режим определения фертильного окна + */ +enum class FertileWindowMode { + CONSERVATIVE, BALANCED, BROAD; + + companion object { + fun fromString(value: String): FertileWindowMode = when (value.lowercase()) { + "conservative" -> CONSERVATIVE + "broad" -> BROAD + else -> BALANCED + } + } + + fun toStorageString(): String = this.name.lowercase() +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/domain/services/CycleSettingsExportService.kt b/app/src/main/java/kr/smartsoltech/wellshe/domain/services/CycleSettingsExportService.kt new file mode 100644 index 0000000..b047762 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/domain/services/CycleSettingsExportService.kt @@ -0,0 +1,172 @@ +package kr.smartsoltech.wellshe.domain.services + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity +import kr.smartsoltech.wellshe.domain.models.* +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Класс для импорта и экспорта настроек цикла в JSON формате + */ +@Singleton +class CycleSettingsExportService @Inject constructor() { + + // Создаем адаптер для LocalDate вне класса LocalDateAdapter + private val localDateAdapter = object : JsonAdapter() { + override fun fromJson(reader: com.squareup.moshi.JsonReader): LocalDate? { + return try { + val dateString = reader.nextString() + LocalDate.parse(dateString) + } catch (e: Exception) { + null + } + } + + override fun toJson(writer: com.squareup.moshi.JsonWriter, value: LocalDate?) { + if (value == null) { + writer.nullValue() + } else { + writer.value(value.toString()) + } + } + } + + // Настройка Moshi для сериализации/десериализации + private val moshi: Moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .add(Date::class.java, Rfc3339DateJsonAdapter()) + .add(LocalDate::class.java, localDateAdapter) + .build() + + // Адаптер для сериализации/десериализации настроек цикла + private val settingsAdapter: JsonAdapter = moshi.adapter(CycleSettingsJsonDto::class.java) + + /** + * Экспортирует настройки в формат JSON + */ + fun exportSettingsToJson(settings: CycleSettingsEntity): String { + val jsonDto = convertToJsonDto(settings) + return settingsAdapter.toJson(jsonDto) + } + + /** + * Импортирует настройки из JSON + * @return Импортированные настройки или null в случае ошибки + */ + fun importSettingsFromJson(json: String): CycleSettingsEntity? { + return try { + val jsonDto = settingsAdapter.fromJson(json) + jsonDto?.let { convertToEntity(it) } + } catch (e: Exception) { + null + } + } + + /** + * Конвертирует Entity в DTO для экспорта в JSON + */ + private fun convertToJsonDto(entity: CycleSettingsEntity): CycleSettingsJsonDto { + return CycleSettingsJsonDto( + baselineCycleLength = entity.baselineCycleLength, + cycleVariabilityDays = entity.cycleVariabilityDays, + periodLengthDays = entity.periodLengthDays, + lutealPhaseDays = entity.lutealPhaseDays, + lastPeriodStart = entity.lastPeriodStart, + ovulationMethod = entity.ovulationMethod, + allowManualOvulation = entity.allowManualOvulation, + hormonalContraception = entity.hormonalContraception, + isPregnant = entity.isPregnant, + isPostpartum = entity.isPostpartum, + isLactating = entity.isLactating, + perimenopause = entity.perimenopause, + historyWindowCycles = entity.historyWindowCycles, + excludeOutliers = entity.excludeOutliers, + tempUnit = entity.tempUnit, + bbtTimeWindow = entity.bbtTimeWindow, + timezone = entity.timezone, + periodReminderDaysBefore = entity.periodReminderDaysBefore, + ovulationReminderDaysBefore = entity.ovulationReminderDaysBefore, + pmsWindowDays = entity.pmsWindowDays, + deviationAlertDays = entity.deviationAlertDays, + fertileWindowMode = entity.fertileWindowMode + ) + } + + /** + * Конвертирует DTO в Entity + */ + private fun convertToEntity(dto: CycleSettingsJsonDto): CycleSettingsEntity { + return CycleSettingsEntity( + id = 1, // Singleton ID + baselineCycleLength = dto.baselineCycleLength.coerceIn(18, 60), + cycleVariabilityDays = dto.cycleVariabilityDays.coerceIn(0, 10), + periodLengthDays = dto.periodLengthDays.coerceIn(1, 10), + lutealPhaseDays = dto.lutealPhaseDays, // Валидация будет в ViewModel + lastPeriodStart = dto.lastPeriodStart, + ovulationMethod = dto.ovulationMethod, + allowManualOvulation = dto.allowManualOvulation, + hormonalContraception = dto.hormonalContraception, + isPregnant = dto.isPregnant, + isPostpartum = dto.isPostpartum, + isLactating = dto.isLactating, + perimenopause = dto.perimenopause, + historyWindowCycles = dto.historyWindowCycles, + excludeOutliers = dto.excludeOutliers, + tempUnit = dto.tempUnit, + bbtTimeWindow = dto.bbtTimeWindow, + timezone = dto.timezone, + periodReminderDaysBefore = dto.periodReminderDaysBefore.coerceIn(0, 7), + ovulationReminderDaysBefore = dto.ovulationReminderDaysBefore.coerceIn(0, 7), + pmsWindowDays = dto.pmsWindowDays.coerceIn(1, 7), + deviationAlertDays = dto.deviationAlertDays.coerceIn(1, 14), + fertileWindowMode = dto.fertileWindowMode + ) + } + + /** + * DTO для сериализации/десериализации настроек цикла в JSON + */ + data class CycleSettingsJsonDto( + // Основные параметры цикла + val baselineCycleLength: Int = 28, + val cycleVariabilityDays: Int = 3, + val periodLengthDays: Int = 5, + val lutealPhaseDays: String = "auto", + val lastPeriodStart: LocalDate? = null, + + // Метод определения овуляции + val ovulationMethod: String = "auto", + val allowManualOvulation: Boolean = false, + + // Статусы влияющие на точность + val hormonalContraception: String = "none", + val isPregnant: Boolean = false, + val isPostpartum: Boolean = false, + val isLactating: Boolean = false, + val perimenopause: Boolean = false, + + // Настройки истории и исключения выбросов + val historyWindowCycles: Int = 6, + val excludeOutliers: Boolean = true, + + // Сенсоры и единицы измерения + val tempUnit: String = "C", + val bbtTimeWindow: String = "06:00-10:00", + val timezone: String = "Asia/Seoul", + + // Уведомления + val periodReminderDaysBefore: Int = 2, + val ovulationReminderDaysBefore: Int = 1, + val pmsWindowDays: Int = 3, + val deviationAlertDays: Int = 5, + val fertileWindowMode: String = "balanced" + ) +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/model/CycleForecast.kt b/app/src/main/java/kr/smartsoltech/wellshe/model/CycleForecast.kt new file mode 100644 index 0000000..0aff99b --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/model/CycleForecast.kt @@ -0,0 +1,46 @@ +package kr.smartsoltech.wellshe.model + +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.DateTimeFormatter + +/** + * Модель данных для прогноза менструального цикла + */ +data class CycleForecast( + val nextPeriodStart: LocalDate, + val nextOvulation: LocalDate, + val fertileStart: LocalDate, + val fertileEnd: LocalDate, + val pmsStart: LocalDate, + val periodLengthDays: Int +) { + val periodEnd: LocalDate get() = nextPeriodStart.plusDays(periodLengthDays.toLong() - 1) +} + +/** + * Расчет прогноза цикла на основе настроек + */ +fun computeForecast(settings: CycleSettings): CycleForecast { + val nextPeriod = settings.lastPeriodStart.plusDays(settings.baselineLength.toLong()) + val ovulation = nextPeriod.minusDays(settings.lutealDays.toLong()) + val fertileStart = ovulation.minusDays(5) + val fertileEnd = ovulation + val pmsStart = nextPeriod.minusDays(3) + + return CycleForecast( + nextPeriodStart = nextPeriod, + nextOvulation = ovulation, + fertileStart = fertileStart, + fertileEnd = fertileEnd, + pmsStart = pmsStart, + periodLengthDays = settings.periodLength + ) +} + +/** + * Форматирует дату в формате "DD MMM" + */ +fun fmt(date: LocalDate): String { + return date.format(DateTimeFormatter.ofPattern("dd MMM")) +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/model/CycleSettings.kt b/app/src/main/java/kr/smartsoltech/wellshe/model/CycleSettings.kt new file mode 100644 index 0000000..e5603da --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/model/CycleSettings.kt @@ -0,0 +1,10 @@ +package kr.smartsoltech.wellshe.model + +import java.time.LocalDate + +data class CycleSettings( + val baselineLength: Int = 28, + val periodLength: Int = 5, + val lutealDays: Int = 14, + val lastPeriodStart: LocalDate +) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/model/JournalConverters.kt b/app/src/main/java/kr/smartsoltech/wellshe/model/JournalConverters.kt new file mode 100644 index 0000000..7153c4c --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/model/JournalConverters.kt @@ -0,0 +1,24 @@ +package kr.smartsoltech.wellshe.model + +import androidx.room.TypeConverter +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.time.LocalDate + +class JournalConverters { + @TypeConverter + fun fromLocalDate(date: LocalDate): String = date.toString() + + @TypeConverter + fun toLocalDate(dateString: String): LocalDate = LocalDate.parse(dateString) + + @TypeConverter + fun fromMediaList(media: List): String = Gson().toJson(media) + + @TypeConverter + fun toMediaList(mediaString: String): List { + val type = object : TypeToken>() {}.type + return Gson().fromJson(mediaString, type) + } +} + diff --git a/app/src/main/java/kr/smartsoltech/wellshe/model/JournalDatabase.kt b/app/src/main/java/kr/smartsoltech/wellshe/model/JournalDatabase.kt new file mode 100644 index 0000000..428694c --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/model/JournalDatabase.kt @@ -0,0 +1,30 @@ +package kr.smartsoltech.wellshe.model + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters + +@Database(entities = [JournalEntryEntity::class], version = 1, exportSchema = false) +@TypeConverters(JournalConverters::class) +abstract class JournalDatabase : RoomDatabase() { + abstract fun journalEntryDao(): JournalEntryDao + + companion object { + @Volatile + private var INSTANCE: JournalDatabase? = null + + fun getDatabase(context: Context): JournalDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + JournalDatabase::class.java, + "journal_database" + ).build() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/model/JournalEntry.kt b/app/src/main/java/kr/smartsoltech/wellshe/model/JournalEntry.kt new file mode 100644 index 0000000..13665a9 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/model/JournalEntry.kt @@ -0,0 +1,22 @@ +package kr.smartsoltech.wellshe.model + +import java.time.LocalDate + +// Модель записи дневника + data class JournalEntry( + val id: Long = 0L, // уникальный идентификатор + val date: LocalDate, + val text: String, + val media: List = emptyList() +) + +// Модель медиафайла (картинка, видео, музыка) +data class JournalMedia( + val uri: String, + val type: MediaType +) + +enum class MediaType { + IMAGE, VIDEO, AUDIO +} + diff --git a/app/src/main/java/kr/smartsoltech/wellshe/model/JournalEntryDao.kt b/app/src/main/java/kr/smartsoltech/wellshe/model/JournalEntryDao.kt new file mode 100644 index 0000000..3dcbbb2 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/model/JournalEntryDao.kt @@ -0,0 +1,20 @@ +package kr.smartsoltech.wellshe.model + +import androidx.room.* +import java.time.LocalDate + +@Dao +interface JournalEntryDao { + @Query("SELECT * FROM journal_entries WHERE date = :date") + suspend fun getEntriesByDate(date: LocalDate): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertEntry(entry: JournalEntryEntity): Long + + @Update + suspend fun updateEntry(entry: JournalEntryEntity) + + @Delete + suspend fun deleteEntry(entry: JournalEntryEntity) +} + diff --git a/app/src/main/java/kr/smartsoltech/wellshe/model/JournalEntryEntity.kt b/app/src/main/java/kr/smartsoltech/wellshe/model/JournalEntryEntity.kt new file mode 100644 index 0000000..cb70337 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/model/JournalEntryEntity.kt @@ -0,0 +1,16 @@ +package kr.smartsoltech.wellshe.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import java.time.LocalDate + +@Entity(tableName = "journal_entries") +data class JournalEntryEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0L, + val date: LocalDate, + val text: String, + val media: String // сериализованный список медиа (например, JSON) +) + diff --git a/app/src/main/java/kr/smartsoltech/wellshe/model/JournalRepository.kt b/app/src/main/java/kr/smartsoltech/wellshe/model/JournalRepository.kt new file mode 100644 index 0000000..9333b22 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/model/JournalRepository.kt @@ -0,0 +1,39 @@ +package kr.smartsoltech.wellshe.model + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.time.LocalDate + +class JournalRepository(private val dao: JournalEntryDao) { + suspend fun getEntriesByDate(date: LocalDate): List = withContext(Dispatchers.IO) { + dao.getEntriesByDate(date).map { entity -> + JournalEntry( + id = entity.id, + date = entity.date, + text = entity.text, + media = JournalConverters().toMediaList(entity.media) + ) + } + } + + suspend fun addOrUpdateEntry(entry: JournalEntry) = withContext(Dispatchers.IO) { + val entity = JournalEntryEntity( + id = entry.id, + date = entry.date, + text = entry.text, + media = JournalConverters().fromMediaList(entry.media) + ) + dao.insertEntry(entity) + } + + suspend fun deleteEntry(entry: JournalEntry) = withContext(Dispatchers.IO) { + val entity = JournalEntryEntity( + id = entry.id, + date = entry.date, + text = entry.text, + media = JournalConverters().fromMediaList(entry.media) + ) + dao.deleteEntry(entity) + } +} + diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/analytics/AnalyticsScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/analytics/AnalyticsScreen.kt new file mode 100644 index 0000000..d1b0da4 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/analytics/AnalyticsScreen.kt @@ -0,0 +1,310 @@ +package kr.smartsoltech.wellshe.ui.analytics + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kr.smartsoltech.wellshe.ui.components.InfoCard +import kr.smartsoltech.wellshe.ui.components.KPI +import kr.smartsoltech.wellshe.ui.theme.* + +/** + * Экран "Аналитика" с данными и графиками + */ +@Composable +fun AnalyticsScreen( + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + + // Состояния для выбранного временного диапазона + var selectedRange by remember { mutableStateOf("7d") } + val rangeLabels = mapOf( + "7d" to "7 дней", + "30d" to "30 дней", + "90d" to "90 дней" + ) + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Заголовок и селектор периода + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Аналитика", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + + // Выпадающее меню для выбора периода + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Период:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + var expanded by remember { mutableStateOf(false) } + + OutlinedButton( + onClick = { expanded = true }, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Text(rangeLabels[selectedRange] ?: "7 дней") + Spacer(Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "Выбрать период" + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + rangeLabels.forEach { (key, value) -> + DropdownMenuItem( + text = { Text(value) }, + onClick = { + selectedRange = key + expanded = false + } + ) + } + } + } + } + + // KPI блоки + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + KPI( + title = "Стабильность цикла", + value = "Высокая", + tone = Color(0xFF4CAF50), + modifier = Modifier.weight(1f) + ) + + KPI( + title = "Гидратация", + value = "~85% нормы", + tone = Color(0xFF2196F3), + modifier = Modifier.weight(1f) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + KPI( + title = "Вес (${rangeLabels[selectedRange]})", + value = "−0.6 кг", + tone = Color(0xFFE91E63), + modifier = Modifier.weight(1f) + ) + + KPI( + title = "Калории (спорт)", + value = "+2.9k", + tone = Color(0xFF4CAF50), + modifier = Modifier.weight(1f) + ) + } + + // Графики + AnalyticsChart( + title = "Динамика веса (${rangeLabels[selectedRange]})", + subtitle = "Тренд: медленное снижение веса, без резких скачков." + ) + + AnalyticsChart( + title = "Гидратация (${rangeLabels[selectedRange]})", + subtitle = "Среднее за период ~1950 мл/день." + ) + + AnalyticsChart( + title = "Сожжённые калории (${rangeLabels[selectedRange]})", + subtitle = "Пики в выходные — неплохая стратегия, добавь лёгкую сессию в среду." + ) + + // Корреляции + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = AnalyticsTabColor.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Корреляции с фазами", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + // Корреляционные линии + CorrelationItem("Гидратация ↔ энергия", 0.42f) + CorrelationItem("Сон ↔ настроение", 0.58f) + CorrelationItem("ПМС ↔ боль", 0.66f) + CorrelationItem("Вес ↔ вода", -0.18f) + } + } + + // Инсайт недели + InfoCard( + title = "Инсайт недели", + content = "Лучшая тренировка — суббота (600 ккал). Пиковая продуктивность совпадает с фолликулярной фазой. Вес ↓ на 0.6 кг — отличный тренд." + ) + } +} + +/** + * Карточка с графиком для аналитики + */ +@Composable +fun AnalyticsChart( + title: String, + subtitle: String +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + // Заглушка для графика (в реальном приложении использовать библиотеку графиков) + Box( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .padding(vertical = 8.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)), + contentAlignment = Alignment.Center + ) { + // В реальном приложении здесь будет график + Text( + text = "Графическое представление данных", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Элемент для отображения корреляции с визуализацией + */ +@Composable +fun CorrelationItem( + title: String, + value: Float +) { + val color = when { + value > 0.3f -> Color(0xFF4CAF50) // Зеленый для положительных + value < -0.3f -> Color(0xFFE91E63) // Красный для отрицательных + else -> Color(0xFF9E9E9E) // Серый для нейтральных + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Полоса корреляции + Box( + modifier = Modifier + .width(80.dp) + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + Box( + modifier = Modifier + .width((80 * kotlin.math.abs(value)).dp) + .height(8.dp) + .background(color) + ) + } + + // Числовое значение + Text( + text = (if (value > 0) "+" else "") + String.format("%.2f", value), + style = MaterialTheme.typography.labelSmall, + color = color + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun AnalyticsScreenPreview() { + WellSheTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + AnalyticsScreen() + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/analytics/AnalyticsViewModel.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/analytics/AnalyticsViewModel.kt new file mode 100644 index 0000000..04efc1f --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/analytics/AnalyticsViewModel.kt @@ -0,0 +1,27 @@ +package kr.smartsoltech.wellshe.ui.analytics + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@HiltViewModel +class AnalyticsViewModel @Inject constructor() : ViewModel() { + // Данные для экрана аналитики + private val _cycleStability = MutableStateFlow("Высокая") + val cycleStability: StateFlow = _cycleStability.asStateFlow() + + private val _hydration = MutableStateFlow(85) + val hydration: StateFlow = _hydration.asStateFlow() + + private val _weightChange = MutableStateFlow(-0.6f) + val weightChange: StateFlow = _weightChange.asStateFlow() + + private val _caloriesBurned = MutableStateFlow(2900) + val caloriesBurned: StateFlow = _caloriesBurned.asStateFlow() + + private val _weeklyInsight = MutableStateFlow("Лучшие тренировки приходятся на выходные; добавь лёгкое кардио в среду.") + val weeklyInsight: StateFlow = _weeklyInsight.asStateFlow() +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/body/BodyScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/body/BodyScreen.kt new file mode 100644 index 0000000..2cbefbd --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/body/BodyScreen.kt @@ -0,0 +1,110 @@ +package kr.smartsoltech.wellshe.ui.body + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kr.smartsoltech.wellshe.ui.body.tabs.ActivityTab +import kr.smartsoltech.wellshe.ui.body.tabs.WaterTab +import kr.smartsoltech.wellshe.ui.body.tabs.WeightTab +import kr.smartsoltech.wellshe.ui.theme.* + +/** + * Основной экран "Тело" с вкладками для воды, веса и активности + */ +@Composable +fun BodyScreen( + modifier: Modifier = Modifier +) { + var selectedTabIndex by remember { mutableStateOf(0) } + val tabs = listOf("Вода", "Вес", "Активность") + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Вкладки + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.large), + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + divider = {}, + indicator = {} + ) { + tabs.forEachIndexed { index, title -> + val selected = selectedTabIndex == index + val tabColor = when(index) { + 0 -> if (selected) BodyTabColor else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + 1 -> if (selected) Color(0xFFFCE4EC) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + 2 -> if (selected) Color(0xFFE0F2F1) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + + Tab( + selected = selected, + onClick = { selectedTabIndex = index }, + modifier = Modifier + .padding(4.dp) + .clip(MaterialTheme.shapes.large) + .background(tabColor) + ) { + val emoji = when(index) { + 0 -> "💧" + 1 -> "⚖️" + 2 -> "🏃‍♀️" + else -> "" + } + Text( + text = "$emoji $title", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) + ) + } + } + } + + // Содержимое вкладки + when (selectedTabIndex) { + 0 -> WaterTab() + 1 -> WeightTab() + 2 -> ActivityTab() + } + } +} + +@Preview(showBackground = true) +@Composable +fun BodyScreenPreview() { + WellSheTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + BodyScreen() + } + } +} + +@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +@Composable +fun BodyScreenDarkPreview() { + WellSheTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + BodyScreen() + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/body/BodyViewModel.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/body/BodyViewModel.kt new file mode 100644 index 0000000..dff7b30 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/body/BodyViewModel.kt @@ -0,0 +1,125 @@ +package kr.smartsoltech.wellshe.ui.body + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +class BodyViewModel @Inject constructor( +) : ViewModel() { + + // Данные для вкладки "Вода" + private val _waterData = MutableStateFlow>(listOf(1800, 1950, 2050, 1900, 2100, 2300, 2000)) + val waterData: StateFlow> = _waterData.asStateFlow() + + private val _todayWaterAmount = MutableStateFlow(1800) + val todayWaterAmount: StateFlow = _todayWaterAmount.asStateFlow() + + private val _waterGoal = MutableStateFlow(2000) // 2л по умолчанию в мл + val waterGoal: StateFlow = _waterGoal.asStateFlow() + + // Данные для вкладки "Вес" + private val _weightHistory = MutableStateFlow>>( + listOf( + "Пн" to 58.9f, + "Вт" to 58.7f, + "Ср" to 58.6f, + "Чт" to 58.5f, + "Пт" to 58.4f, + "Сб" to 58.4f, + "Вс" to 58.3f + ) + ) + val weightHistory: StateFlow>> = _weightHistory.asStateFlow() + + private val _currentWeight = MutableStateFlow(58.4f) + val currentWeight: StateFlow = _currentWeight.asStateFlow() + + private val _weeklyWeightDifference = MutableStateFlow(-0.6f) + val weeklyWeightDifference: StateFlow = _weeklyWeightDifference.asStateFlow() + + // Данные для вкладки "Активность" + private val _activityData = MutableStateFlow>( + listOf( + WorkoutData("Пн", 250), + WorkoutData("Вт", 350), + WorkoutData("Ср", 410), + WorkoutData("Чт", 280), + WorkoutData("Пт", 500), + WorkoutData("Сб", 600), + WorkoutData("Вс", 420) + ) + ) + val activityData: StateFlow> = _activityData.asStateFlow() + + private val _todayCaloriesBurned = MutableStateFlow(312) + val todayCaloriesBurned: StateFlow = _todayCaloriesBurned.asStateFlow() + + private val _bestWorkoutDay = MutableStateFlow?>(Pair("Сб", 600)) + val bestWorkoutDay: StateFlow?> = _bestWorkoutDay.asStateFlow() + + init { + loadWaterData() + loadWeightData() + loadActivityData() + } + + fun loadWaterData() { + // Здесь будет логика загрузки данных о воде + // Сейчас используем тестовые данные + } + + fun loadWeightData() { + // Здесь будет логика загрузки данных о весе + // Сейчас используем тестовые данные + } + + fun loadActivityData() { + // Здесь будет логика загрузки данных об активности + // Сейчас используем тестовые данные + } + + fun addWater(amount: Int) { + val currentAmount = _todayWaterAmount.value + _todayWaterAmount.value = currentAmount + amount + + // Обновляем данные в истории + val updatedWaterData = _waterData.value.toMutableList() + if (updatedWaterData.isNotEmpty()) { + updatedWaterData[updatedWaterData.size - 1] = _todayWaterAmount.value + _waterData.value = updatedWaterData + } + } + + fun logWeight(weightKg: Float) { + _currentWeight.value = weightKg + + // Обновляем историю веса + val updatedWeightHistory = _weightHistory.value.toMutableList() + if (updatedWeightHistory.isNotEmpty()) { + val lastDay = updatedWeightHistory.last().first + updatedWeightHistory[updatedWeightHistory.size - 1] = lastDay to weightKg + _weightHistory.value = updatedWeightHistory + + // Пересчитываем разницу за неделю + val firstWeight = updatedWeightHistory.first().second + _weeklyWeightDifference.value = weightKg - firstWeight + } + } + + fun startWorkout(exerciseId: Long) { + // Здесь будет логика начала тренировки + } +} + +data class WorkoutData( + val date: String, + val caloriesBurned: Int +) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/body/tabs/ActivityTab.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/body/tabs/ActivityTab.kt new file mode 100644 index 0000000..ab4de37 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/body/tabs/ActivityTab.kt @@ -0,0 +1,176 @@ +package kr.smartsoltech.wellshe.ui.body.tabs + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.DirectionsRun +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +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.unit.dp +import androidx.compose.ui.tooling.preview.Preview +import kr.smartsoltech.wellshe.ui.components.InfoCard +import kr.smartsoltech.wellshe.ui.theme.WellSheTheme + +/** + * Содержимое вкладки "Активность" экрана "Тело" + */ +@Composable +fun ActivityTab( + modifier: Modifier = Modifier +) { + // Данные для отображения (в реальном приложении должны поступать из ViewModel) + val caloriesBurned = "+312 ккал" + val activityDescription = "Бег 8 км/ч · 35 мин" + val activityColor = Color(0xFF4CAF50) // Зеленый для активности + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Основная карточка с активностью + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xFFE0F2F1).copy(alpha = 0.3f) // Светло-зеленый фон + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Заголовок + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.DirectionsRun, + contentDescription = null, + tint = activityColor + ) + Text( + text = "Активность", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = activityColor + ) + } + + Button( + onClick = { /*TODO*/ }, + colors = ButtonDefaults.buttonColors( + containerColor = activityColor + ) + ) { + Icon(Icons.Default.Add, contentDescription = "Начать активность") + Spacer(modifier = Modifier.width(4.dp)) + Text("Начать") + } + } + + // Отображение калорий + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 16.dp) + ) { + Text( + text = caloriesBurned, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = activityDescription, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // График сожженных калорий + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.LocalFireDepartment, + contentDescription = null, + tint = Color(0xFFFF9800) + ) + Text( + text = "Сожжённые калории (7 дней)", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium + ) + } + + // График (заглушка, в реальном приложении использовать библиотеку графиков) + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .padding(vertical = 16.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Text( + "График сожженных калорий", + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Divider() + + // Аналитика + Text( + text = "Средний расход 416 ккал/день, лучший день — 600 ккал (сб)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // История активностей + InfoCard( + title = "Недавние активности", + content = "Бег 35 мин, Йога 40 мин, Плавание 30 мин, Велосипед 45 мин, Ходьба 60 мин" + ) + + // Рекомендации + InfoCard( + title = "Рекомендация", + content = "Добавь 1 лёгкую кардио-сессию в среду для более равномерного распределения нагрузки в течение недели." + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ActivityTabPreview() { + WellSheTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + ActivityTab(Modifier.padding(16.dp)) + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/body/tabs/WaterTab.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/body/tabs/WaterTab.kt new file mode 100644 index 0000000..27da8e4 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/body/tabs/WaterTab.kt @@ -0,0 +1,184 @@ +package kr.smartsoltech.wellshe.ui.body.tabs + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.TrendingUp +import androidx.compose.material.icons.filled.WaterDrop +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview +import kr.smartsoltech.wellshe.ui.components.InfoCard +import kr.smartsoltech.wellshe.ui.components.ProgressWithLabel +import kr.smartsoltech.wellshe.ui.theme.WaterColor +import kr.smartsoltech.wellshe.ui.theme.WellSheTheme + +/** + * Содержимое вкладки "Вода" экрана "Тело" + */ +@Composable +fun WaterTab( + modifier: Modifier = Modifier +) { + // Данные для отображения (в реальном приложении должны поступать из ViewModel) + val currentWater = 1800 + val targetWater = 2000 + val progress = currentWater.toFloat() / targetWater + val percentageText = "${(progress * 100).toInt()}% дневной нормы" + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Основная карточка с водой + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Заголовок + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.WaterDrop, + contentDescription = null, + tint = WaterColor + ) + Text( + text = "Вода", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = WaterColor + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { /*TODO*/ }) { + Icon(Icons.Default.Search, contentDescription = "Поиск напитка") + Spacer(modifier = Modifier.width(4.dp)) + Text("Поиск") + } + + Button(onClick = { /*TODO*/ }) { + Icon(Icons.Default.Add, contentDescription = "Добавить воду") + Spacer(modifier = Modifier.width(4.dp)) + Text("Выпито") + } + } + } + + // Прогресс воды + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 16.dp) + ) { + Text( + text = "$currentWater / $targetWater мл", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + ProgressWithLabel( + progress = progress, + label = percentageText, + color = WaterColor + ) + } + + // Тренд + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.TrendingUp, + contentDescription = null, + tint = WaterColor + ) + Text( + text = "Тренд за неделю", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium + ) + } + + // График (заглушка, в реальном приложении использовать библиотеку графиков) + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .padding(vertical = 16.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Text( + "График пот��ебления воды", + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Divider() + + // Статистика + Text( + text = "Энергия из напитков: 139 ккал • Сахара: 35 г • Вода: ~2.0 л", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Недавно добавленные напитки + InfoCard( + title = "Недавно добавленные напитки", + content = "Вода 250 мл, Чай зеленый 200 мл, Кофе 150 мл, Сок яблочный 200 мл" + ) + + // Рекомендации + InfoCard( + title = "Рекомендация", + content = "Старайтесь пить больше воды утром. Это поможет ускорить метаболизм и увеличить энергию на весь день." + ) + } +} + +@Preview(showBackground = true) +@Composable +fun WaterTabPreview() { + WellSheTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + WaterTab(Modifier.padding(16.dp)) + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/body/tabs/WeightTab.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/body/tabs/WeightTab.kt new file mode 100644 index 0000000..cce3ecb --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/body/tabs/WeightTab.kt @@ -0,0 +1,176 @@ +package kr.smartsoltech.wellshe.ui.body.tabs + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Scale +import androidx.compose.material.icons.filled.Spa +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +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.unit.dp +import androidx.compose.ui.tooling.preview.Preview +import kr.smartsoltech.wellshe.ui.components.InfoCard +import kr.smartsoltech.wellshe.ui.theme.WellSheTheme + +/** + * Содержимое вкладки "Вес" экрана "Тело" + */ +@Composable +fun WeightTab( + modifier: Modifier = Modifier +) { + // Данные для отображения (в реальном приложении должны поступать из ViewModel) + val currentWeight = "58.4 кг" + val weightChange = "−0.6 кг за неделю" + val weightColor = Color(0xFFEC407A) // Розовый для веса + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Основная карточка с весом + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xFFFCE4EC).copy(alpha = 0.3f) // Светло-розовый фон + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Заголовок + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Scale, + contentDescription = null, + tint = weightColor + ) + Text( + text = "Вес", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = weightColor + ) + } + + Button( + onClick = { /*TODO*/ }, + colors = ButtonDefaults.buttonColors( + containerColor = weightColor + ) + ) { + Icon(Icons.Default.Add, contentDescription = "Добавить запись веса") + Spacer(modifier = Modifier.width(4.dp)) + Text("Добавить") + } + } + + // Отображение веса + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 16.dp) + ) { + Text( + text = currentWeight, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = weightChange, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // График динамики веса (заглушка) + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Spa, + contentDescription = null, + tint = weightColor + ) + Text( + text = "Динамика веса", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium + ) + } + + // График (заглушка, в реальном приложении использовать библиотеку графиков) + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .padding(vertical = 16.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Text( + "График изменения веса", + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Divider() + + // Прогноз + Text( + text = "Прогноз: при сохранении режима целевой вес 57.5 кг через 3 недели", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // История записей + InfoCard( + title = "История записей", + content = "Пн: 58.9 кг, Вт: 58.7 кг, Ср: 58.6 кг, Чт: 58.5 кг, Пт: 58.4 кг, Сб: 58.4 кг, Вс: 58.3 кг" + ) + + // Рекомендации + InfoCard( + title = "Анализ", + content = "Стабильное снижение веса показывает хороший прогресс. Для оптимального результата рекомендуется пить больше воды и добавить умеренные кардио-нагрузки." + ) + } +} + +@Preview(showBackground = true) +@Composable +fun WeightTabPreview() { + WellSheTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + WeightTab(Modifier.padding(16.dp)) + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/components/CommonComponents.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/components/CommonComponents.kt new file mode 100644 index 0000000..e274835 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/components/CommonComponents.kt @@ -0,0 +1,210 @@ +package kr.smartsoltech.wellshe.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +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.unit.dp +import androidx.compose.ui.text.style.TextAlign + +/** + * Карточка с информацией + */ +@Composable +fun StatCard( + title: String, + value: String, + tone: Color? = null, + modifier: Modifier = Modifier +) { + val backgroundColor = tone?.copy(alpha = 0.15f) ?: MaterialTheme.colorScheme.surfaceVariant + + Surface( + modifier = modifier, + shape = RoundedCornerShape(20.dp), + color = backgroundColor + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.Start + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + } + } +} + +/** + * Информационная карточка + */ +@Composable +fun InfoCard( + title: String, + content: String, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + Text( + text = content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Пилюля для отображения фазы цикла + */ +@Composable +fun PhasePill( + label: String, + color: Color, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(50), + color = color.copy(alpha = 0.2f) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = color.copy(alpha = 0.8f), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } +} + +/** + * Индикатор прогресса с процентами + */ +@Composable +fun ProgressWithLabel( + progress: Float, + label: String, + color: Color = MaterialTheme.colorScheme.primary, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + LinearProgressIndicator( + progress = progress, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = color, + trackColor = color.copy(alpha = 0.2f) + ) + + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + textAlign = TextAlign.Center + ) + } +} + +/** + * Ключевой показатель эффективности (KPI) + */ +@Composable +fun KPI( + title: String, + value: String, + tone: Color? = null, + modifier: Modifier = Modifier +) { + val backgroundColor = tone?.copy(alpha = 0.15f) ?: MaterialTheme.colorScheme.surfaceVariant + + Surface( + modifier = modifier, + shape = RoundedCornerShape(20.dp), + color = backgroundColor + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } +} + +/** + * Переключатель с подписью + */ +@Composable +fun ToggleRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + uncheckedThumbColor = MaterialTheme.colorScheme.outline, + uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/components/WaterIntakeDialog.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/components/WaterIntakeDialog.kt new file mode 100644 index 0000000..5631e9b --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/components/WaterIntakeDialog.kt @@ -0,0 +1,100 @@ +package kr.smartsoltech.wellshe.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocalDrink +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 androidx.compose.ui.window.Dialog + +@Composable +fun WaterIntakeDialog( + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit +) { + var waterAmount by remember { mutableStateOf("200") } + var isError by remember { mutableStateOf(false) } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.LocalDrink, + contentDescription = "Добавить воду", + tint = Color(0xFF3B82F6), + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Сколько воды вы выпили?", + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = waterAmount, + onValueChange = { value -> + waterAmount = value.filter { it.isDigit() } + isError = waterAmount.isEmpty() || waterAmount.toIntOrNull() == null + }, + label = { Text("Количество (мл)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = isError, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + if (isError) { + Text( + text = "Введите корректное количество", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 16.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = onDismiss) { + Text("Отмена") + } + + Button( + onClick = { + val amount = waterAmount.toIntOrNull() ?: return@Button + onConfirm(amount) + }, + enabled = !isError && waterAmount.isNotEmpty() + ) { + Text("Добавить") + } + } + } + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/components/WeightInputDialog.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/components/WeightInputDialog.kt new file mode 100644 index 0000000..85a15cf --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/components/WeightInputDialog.kt @@ -0,0 +1,122 @@ +package kr.smartsoltech.wellshe.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Scale +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 androidx.compose.ui.window.Dialog + +@Composable +fun WeightInputDialog( + onDismiss: () -> Unit, + onConfirm: (Float) -> Unit, + initialWeight: Float = 60f +) { + var weightInput by remember { mutableStateOf(initialWeight.toString()) } + var isError by remember { mutableStateOf(false) } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.Scale, + contentDescription = "Добавить вес", + tint = Color(0xFFEC4899), + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Запись веса", + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = weightInput, + onValueChange = { value -> + // Разрешаем только цифры и одну точку + val filtered = value.filter { it.isDigit() || it == '.' } + // Проверяем, что точка только одна + val dotCount = filtered.count { it == '.' } + weightInput = if (dotCount > 1) { + filtered.substring(0, filtered.lastIndexOf('.')) + } else { + filtered + } + + // Проверка валидности + isError = try { + val weight = weightInput.toFloat() + weight <= 0f || weight > 300f // Разумные ограничения на вес + } catch (e: NumberFormatException) { + true + } + }, + label = { Text("Вес (кг)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + isError = isError, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + if (isError) { + Text( + text = "Введите корректное значение веса (1-300 кг)", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 16.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = onDismiss) { + Text("Отмена") + } + + Button( + onClick = { + try { + val weight = weightInput.toFloat() + if (weight > 0f && weight <= 300f) { + onConfirm(weight) + } + } catch (e: NumberFormatException) { + // Ошибка уже отображается через isError + } + }, + enabled = !isError && weightInput.isNotEmpty() + ) { + Text("Сохранить") + } + } + } + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/components/WorkoutSelectionDialog.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/components/WorkoutSelectionDialog.kt new file mode 100644 index 0000000..6c88798 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/components/WorkoutSelectionDialog.kt @@ -0,0 +1,134 @@ +package kr.smartsoltech.wellshe.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FitnessCenter +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog + +@Composable +fun WorkoutSelectionDialog( + onDismiss: () -> Unit, + onConfirm: (Long) -> Unit +) { + // Тестовый список тренировок + val workouts = listOf( + Workout(1L, "Бег", "Кардио", 8.5), + Workout(2L, "Ходьба", "Кардио", 4.2), + Workout(3L, "Йога", "Растяжка", 3.0), + Workout(4L, "Силовая тренировка", "Силовая", 6.0), + Workout(5L, "Плавание", "Кардио", 7.0), + Workout(6L, "Велосипед", "Кардио", 7.5) + ) + + var selectedWorkout by remember { mutableStateOf(null) } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.FitnessCenter, + contentDescription = "Выбор тренировки", + tint = Color(0xFF10B981), // Зеленый цвет + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Выберите тренировку", + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + ) { + items(workouts) { workout -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = workout == selectedWorkout, + onClick = { selectedWorkout = workout } + ) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = workout == selectedWorkout, + onClick = { selectedWorkout = workout } + ) + + Column( + modifier = Modifier.padding(start = 16.dp) + ) { + Text( + text = workout.name, + fontWeight = FontWeight.Medium + ) + Text( + text = "${workout.type} • ~${workout.caloriesPerMinute} ккал/мин", + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray + ) + } + } + + Divider() + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = onDismiss) { + Text("Отмена") + } + + Button( + onClick = { + selectedWorkout?.let { onConfirm(it.id) } + }, + enabled = selectedWorkout != null + ) { + Text("Начать") + } + } + } + } + } +} + +data class Workout( + val id: Long, + val name: String, + val type: String, + val caloriesPerMinute: Double +) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleScreen.kt index 74d93e4..70cf52f 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleScreen.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleScreen.kt @@ -1,824 +1,192 @@ package kr.smartsoltech.wellshe.ui.cycle -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity -import kr.smartsoltech.wellshe.ui.theme.* +import kr.smartsoltech.wellshe.model.CycleForecast +import kr.smartsoltech.wellshe.model.CycleSettings +import kr.smartsoltech.wellshe.model.computeForecast +import kr.smartsoltech.wellshe.ui.components.InfoCard +import kr.smartsoltech.wellshe.ui.components.StatCard +import kr.smartsoltech.wellshe.ui.cycle.components.CycleCalendarCard +import kr.smartsoltech.wellshe.ui.cycle.components.QuickActionsCard +import kr.smartsoltech.wellshe.ui.theme.WaterColor +import kr.smartsoltech.wellshe.ui.theme.WellSheTheme import java.time.LocalDate +import java.time.YearMonth import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoUnit -import kotlin.math.cos -import kotlin.math.sin +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun CycleScreen( modifier: Modifier = Modifier, - viewModel: CycleViewModel = hiltViewModel(), - onNavigateBack: () -> Boolean + viewModel: CycleViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() + val scrollState = rememberScrollState() + // Загружаем данные при первом запуске LaunchedEffect(Unit) { viewModel.loadCycleData() } - LazyColumn( - modifier = modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - PrimaryPinkLight.copy(alpha = 0.3f), - NeutralWhite - ) - ) - ), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - CycleOverviewCard( - currentPhase = uiState.currentPhase, - daysUntilNext = uiState.daysUntilNextPeriod, - cycleDay = uiState.currentCycleDay, - cycleLength = uiState.cycleLength - ) - } - - item { - CycleTrackerCard( - isPeriodActive = uiState.isPeriodActive, - onStartPeriod = viewModel::startPeriod, - onEndPeriod = viewModel::endPeriod, - onLogSymptoms = { viewModel.toggleSymptomsEdit() } - ) - } - - item { - if (uiState.showSymptomsEdit) { - SymptomsTrackingCard( - selectedSymptoms = uiState.todaySymptoms, - selectedMood = uiState.todayMood, - onSymptomsUpdate = viewModel::updateSymptoms, - onMoodUpdate = viewModel::updateMood, - onSave = viewModel::saveTodayData - ) - } - } - - item { - CyclePredictionCard( - nextPeriodDate = uiState.nextPeriodDate, - ovulationDate = uiState.ovulationDate, - fertilityWindow = uiState.fertilityWindow - ) - } - - item { - CycleInsightsCard( - insights = uiState.insights, - averageCycleLength = uiState.averageCycleLength - ) - } - - item { - PeriodHistoryCard( - recentPeriods = uiState.recentPeriods, - onPeriodClick = { /* TODO: Navigate to period details */ } - ) - } - - item { - Spacer(modifier = Modifier.height(80.dp)) - } - } - - if (uiState.error != null) { - LaunchedEffect(uiState.error) { - viewModel.clearError() - } - } -} - -@Composable -private fun CycleOverviewCard( - currentPhase: String, - daysUntilNext: Int, - cycleDay: Int, - cycleLength: Int, - modifier: Modifier = Modifier -) { - val progress by animateFloatAsState( - targetValue = if (cycleLength > 0) (cycleDay.toFloat() / cycleLength).coerceIn(0f, 1f) else 0f, - animationSpec = tween(durationMillis = 1000) - ) - - Card( - modifier = modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - colors = CardDefaults.cardColors(containerColor = NeutralWhite), - shape = RoundedCornerShape(20.dp) - ) { + Scaffold { paddingValues -> Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text( - text = "Текущий цикл", - style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.Bold, - color = TextPrimary - ) - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center - ) { - CycleProgressIndicator( - progress = progress, - currentPhase = currentPhase, - modifier = Modifier.fillMaxSize() - ) - - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "День $cycleDay", - style = MaterialTheme.typography.headlineLarge.copy( - fontWeight = FontWeight.Bold, - color = PrimaryPink - ) - ) - Text( - text = "из $cycleLength дней", - style = MaterialTheme.typography.bodyMedium.copy( - color = TextSecondary - ) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = currentPhase, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - color = PrimaryPink - ) - ) - } - } - - Spacer(modifier = Modifier.height(20.dp)) - - Card( - colors = CardDefaults.cardColors( - containerColor = PrimaryPinkLight.copy(alpha = 0.1f) - ), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = if (daysUntilNext > 0) { - "До следующих месячных" - } else { - "Месячные уже начались" - }, - style = MaterialTheme.typography.bodyMedium.copy( - color = TextSecondary - ) - ) - Text( - text = if (daysUntilNext > 0) { - "$daysUntilNext дней" - } else { - "Отметьте начало" - }, - style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.Bold, - color = PrimaryPink - ) - ) - } - } - } - } -} - -@Composable -private fun CycleProgressIndicator( - progress: Float, - currentPhase: String, - modifier: Modifier = Modifier -) { - Canvas(modifier = modifier) { - val center = this.center - val radius = size.minDimension / 2 - 20.dp.toPx() - val strokeWidth = 12.dp.toPx() - - // Фон круга - drawCircle( - color = PrimaryPinkLight.copy(alpha = 0.3f), - radius = radius, - center = center, - style = androidx.compose.ui.graphics.drawscope.Stroke(width = strokeWidth) - ) - - // Прогресс-дуга - val sweepAngle = 360f * progress - drawArc( - brush = Brush.sweepGradient( - colors = listOf( - PrimaryPink, - PrimaryPinkDark - ) - ), - startAngle = -90f, - sweepAngle = sweepAngle, - useCenter = false, - style = androidx.compose.ui.graphics.drawscope.Stroke( - width = strokeWidth, - cap = androidx.compose.ui.graphics.StrokeCap.Round - ), - topLeft = Offset(center.x - radius, center.y - radius), - size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2) - ) - - // Индикаторы фаз цикла - drawPhaseIndicators(center, radius, strokeWidth) - } -} - -private fun DrawScope.drawPhaseIndicators(center: Offset, radius: Float, strokeWidth: Float) { - val phases = listOf( - Triple(0f, "М", Color(0xFFE91E63)), // Менструация - Triple(90f, "Ф", Color(0xFF9C27B0)), // Фолликулярная - Triple(180f, "О", Color(0xFF673AB7)), // Овуляция - Triple(270f, "Л", Color(0xFF3F51B5)) // Лютеиновая - ) - - phases.forEach { (angle, label, color) -> - val angleRad = Math.toRadians(angle.toDouble()) - val x = center.x + (radius + strokeWidth / 2) * cos(angleRad).toFloat() - val y = center.y + (radius + strokeWidth / 2) * sin(angleRad).toFloat() - - drawCircle( - color = color, - radius = 8.dp.toPx(), - center = Offset(x, y) - ) - } -} - -@Composable -private fun CycleTrackerCard( - isPeriodActive: Boolean, - onStartPeriod: () -> Unit, - onEndPeriod: () -> Unit, - onLogSymptoms: () -> Unit, - modifier: Modifier = Modifier -) { - Card( - modifier = modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - colors = CardDefaults.cardColors(containerColor = NeutralWhite), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - Text( - text = "Отслеживание", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - color = TextPrimary - ), - modifier = Modifier.padding(bottom = 16.dp) + // Календарь цикла + CycleCalendarCard( + month = uiState.month, + onPrev = { viewModel.prevMonth() }, + onNext = { viewModel.nextMonth() }, + forecast = uiState.forecast ) + // Карточки с прогнозом Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - if (isPeriodActive) { - Button( - onClick = onEndPeriod, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFFF5722) - ), - shape = RoundedCornerShape(12.dp) - ) { - Icon( - imageVector = Icons.Default.Stop, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Завершить") - } - } else { - Button( - onClick = onStartPeriod, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = PrimaryPink - ), - shape = RoundedCornerShape(12.dp) - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Начать месячные") - } - } + StatCard( + title = "След. менструация", + value = uiState.forecast?.nextPeriodStart?.format( + DateTimeFormatter.ofPattern("dd MMM", Locale("ru")) + ) ?: "—", + tone = Color(0xFF2196F3), + modifier = Modifier.weight(1f) + ) - OutlinedButton( - onClick = onLogSymptoms, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp) - ) { - Icon( - imageVector = Icons.Default.Assignment, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Симптомы") - } + StatCard( + title = "Овуляция", + value = uiState.forecast?.nextOvulation?.format( + DateTimeFormatter.ofPattern("dd MMM", Locale("ru")) + ) ?: "—", + tone = Color(0xFF4CAF50), + modifier = Modifier.weight(1f) + ) } + + // Быстрые действия + QuickActionsCard( + onMarkStart = { viewModel.markPeriodStart() }, + onMarkEnd = { viewModel.markPeriodEnd() }, + onAddSymptom = { viewModel.addSymptom() }, + onAddNote = { viewModel.addNote() } + ) + + // Информационные карточки + InfoCard( + title = "Симптомы сегодня", + content = uiState.todaySymptoms + ) + + InfoCard( + title = "Прогноз недели", + content = uiState.weekInsight + ) } } } +@Preview(showBackground = true) @Composable -private fun SymptomsTrackingCard( - selectedSymptoms: List, - selectedMood: String, - onSymptomsUpdate: (List) -> Unit, - onMoodUpdate: (String) -> Unit, - onSave: () -> Unit, - modifier: Modifier = Modifier -) { - Card( - modifier = modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - colors = CardDefaults.cardColors(containerColor = NeutralWhite), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - Text( - text = "Симптомы и настроение", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - color = TextPrimary - ), - modifier = Modifier.padding(bottom = 16.dp) - ) +fun CycleScreenPreview() { + val previewSettings = CycleSettings( + baselineLength = 28, + periodLength = 5, + lutealDays = 14, + lastPeriodStart = LocalDate.of(2025, 10, 1) + ) + val previewForecast = computeForecast(previewSettings) - // Симптомы - Text( - text = "Симптомы", - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Medium, - color = TextPrimary - ), - modifier = Modifier.padding(bottom = 8.dp) - ) - - val symptoms = listOf( - "Боли в животе", "Головная боль", "Тошнота", "Вздутие", - "Усталость", "Раздражительность", "Боли в спине", "Акне" - ) - - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(bottom = 16.dp) - ) { - items(symptoms) { symptom -> - FilterChip( - onClick = { - val newSymptoms = if (selectedSymptoms.contains(symptom)) { - selectedSymptoms - symptom - } else { - selectedSymptoms + symptom - } - onSymptomsUpdate(newSymptoms) - }, - label = { Text(symptom, style = MaterialTheme.typography.bodySmall) }, - selected = selectedSymptoms.contains(symptom), - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = PrimaryPink, - selectedLabelColor = NeutralWhite - ) - ) - } - } - - // Настроение - Text( - text = "Настроение", - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Medium, - color = TextPrimary - ), - modifier = Modifier.padding(bottom = 8.dp) - ) - - val moods = listOf("Отличное", "Хорошее", "Нейтральное", "Плохое", "Ужасное") - - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(bottom = 16.dp) - ) { - items(moods) { mood -> - FilterChip( - onClick = { onMoodUpdate(mood) }, - label = { Text(mood) }, - selected = selectedMood == mood, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = PrimaryPink, - selectedLabelColor = NeutralWhite - ) - ) - } - } - - Button( - onClick = onSave, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = PrimaryPink - ), - shape = RoundedCornerShape(12.dp) - ) { - Text("Сохранить") - } - } - } -} - -@Composable -private fun CyclePredictionCard( - nextPeriodDate: LocalDate?, - ovulationDate: LocalDate?, - fertilityWindow: Pair?, - modifier: Modifier = Modifier -) { - Card( - modifier = modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - colors = CardDefaults.cardColors(containerColor = NeutralWhite), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - Text( - text = "Прогнозы", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - color = TextPrimary - ), - modifier = Modifier.padding(bottom = 16.dp) - ) + val previewState = CycleViewModel.UiState( + month = YearMonth.now(), + forecast = previewForecast, + todaySymptoms = "Лёгкая усталость, аппетит выше обычного", + weekInsight = "ПМС с 13 окт; окно фертильности: 29 сен–04 окт", + cycleSettings = previewSettings + ) + WellSheTheme { + Surface { Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - PredictionItem( - icon = Icons.Default.CalendarMonth, - title = "Следующие месячные", - date = nextPeriodDate, - color = PrimaryPink + CycleCalendarCard( + month = previewState.month, + onPrev = { }, + onNext = { }, + forecast = previewState.forecast ) - PredictionItem( - icon = Icons.Default.Favorite, - title = "Овуляция", - date = ovulationDate, - color = Color(0xFF9C27B0) - ) - - if (fertilityWindow != null) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Spa, - contentDescription = null, - tint = Color(0xFF4CAF50), - modifier = Modifier.size(24.dp) - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Column { - Text( - text = "Период фертильности", - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = FontWeight.Medium, - color = TextPrimary - ) - ) - Text( - text = "${fertilityWindow.first.format(DateTimeFormatter.ofPattern("dd.MM"))} - ${fertilityWindow.second.format(DateTimeFormatter.ofPattern("dd.MM"))}", - style = MaterialTheme.typography.bodyMedium.copy( - color = Color(0xFF4CAF50), - fontWeight = FontWeight.Bold - ) - ) - } - } - } - } - } - } -} - -@Composable -private fun PredictionItem( - icon: ImageVector, - title: String, - date: LocalDate?, - color: Color, - modifier: Modifier = Modifier -) { - Row( - modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = color, - modifier = Modifier.size(24.dp) - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Column { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = FontWeight.Medium, - color = TextPrimary - ) - ) - Text( - text = date?.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) ?: "Недостаточно данных", - style = MaterialTheme.typography.bodyMedium.copy( - color = if (date != null) color else TextSecondary, - fontWeight = if (date != null) FontWeight.Bold else FontWeight.Normal - ) - ) - } - } -} - -@Composable -private fun CycleInsightsCard( - insights: List, - averageCycleLength: Float, - modifier: Modifier = Modifier -) { - Card( - modifier = modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - colors = CardDefaults.cardColors( - containerColor = PrimaryPinkLight.copy(alpha = 0.1f) - ), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 16.dp) - ) { - Icon( - imageVector = Icons.Default.Analytics, - contentDescription = null, - tint = PrimaryPink, - modifier = Modifier.size(24.dp) - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - text = "Анализ цикла", - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - color = TextPrimary + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatCard( + title = "След. менструация", + value = previewState.forecast?.nextPeriodStart?.format( + DateTimeFormatter.ofPattern("dd MMM", Locale("ru")) + ) ?: "—", + tone = Color(0xFF2196F3), + modifier = Modifier.weight(1f) ) - ) - } - if (averageCycleLength > 0) { - Text( - text = "Средняя длина цикла: %.1f дней".format(averageCycleLength), - style = MaterialTheme.typography.bodyMedium.copy( - color = TextPrimary, - fontWeight = FontWeight.Medium - ), - modifier = Modifier.padding(bottom = 12.dp) - ) - } - - if (insights.isEmpty()) { - Text( - text = "Отслеживайте цикл несколько месяцев для получения персональных рекомендаций.", - style = MaterialTheme.typography.bodyMedium.copy( - color = TextPrimary + StatCard( + title = "Овуляция", + value = previewState.forecast?.nextOvulation?.format( + DateTimeFormatter.ofPattern("dd MMM", Locale("ru")) + ) ?: "—", + tone = Color(0xFF4CAF50), + modifier = Modifier.weight(1f) ) - ) - } else { - insights.forEach { insight -> - Row( - modifier = Modifier.padding(vertical = 4.dp) - ) { - Icon( - imageVector = Icons.Default.Circle, - contentDescription = null, - tint = PrimaryPink, - modifier = Modifier.size(8.dp) - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - text = insight, - style = MaterialTheme.typography.bodyMedium.copy( - color = TextPrimary - ) - ) - } } + + QuickActionsCard( + onMarkStart = { }, + onMarkEnd = { }, + onAddSymptom = { }, + onAddNote = { } + ) + + InfoCard( + title = "Симптомы сегодня", + content = previewState.todaySymptoms + ) + + InfoCard( + title = "Прогноз недели", + content = previewState.weekInsight + ) } } } } +@Preview(showBackground = true, locale = "ru") @Composable -private fun PeriodHistoryCard( - recentPeriods: List, - onPeriodClick: (CyclePeriodEntity) -> Unit, - modifier: Modifier = Modifier -) { - Card( - modifier = modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - colors = CardDefaults.cardColors(containerColor = NeutralWhite), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - Text( - text = "История циклов", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - color = TextPrimary - ), - modifier = Modifier.padding(bottom = 16.dp) - ) - - if (recentPeriods.isEmpty()) { - Text( - text = "Пока нет записей о циклах", - style = MaterialTheme.typography.bodyMedium.copy( - color = TextSecondary - ), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - } else { - recentPeriods.take(3).forEach { period -> - PeriodHistoryItem( - period = period, - onClick = { onPeriodClick(period) } - ) - if (period != recentPeriods.last()) { - Spacer(modifier = Modifier.height(12.dp)) - } - } - } - } - } +fun CycleScreenPreviewRussian() { + CycleScreenPreview() } +@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) @Composable -private fun PeriodHistoryItem( - period: CyclePeriodEntity, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable { onClick() } - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.CalendarMonth, - contentDescription = null, - tint = PrimaryPink, - modifier = Modifier.size(32.dp) - ) - - Spacer(modifier = Modifier.width(16.dp)) - - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = period.startDate.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")), - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = FontWeight.Medium, - color = TextPrimary - ) - ) - val duration = if (period.endDate != null) { - ChronoUnit.DAYS.between(period.startDate, period.endDate) + 1 - } else { - null - } - Text( - text = if (duration != null) { - "Продолжительность: $duration дней" - } else { - "В процессе" - }, - style = MaterialTheme.typography.bodySmall.copy( - color = TextSecondary - ) - ) - } - - Text( - text = period.flow, - style = MaterialTheme.typography.bodySmall.copy( - color = TextSecondary - ) - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = "Просмотреть", - tint = TextSecondary, - modifier = Modifier.size(20.dp) - ) - } +fun CycleScreenPreviewDark() { + CycleScreenPreview() } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleSettingsDialog.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleSettingsDialog.kt new file mode 100644 index 0000000..f299500 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleSettingsDialog.kt @@ -0,0 +1,93 @@ +package kr.smartsoltech.wellshe.ui.cycle + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kr.smartsoltech.wellshe.model.CycleSettings +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +@Composable +fun CycleSettingsDialog( + initialSettings: CycleSettings, + onSave: (CycleSettings) -> Unit, + onDismiss: () -> Unit +) { + var baselineLength by remember { mutableStateOf(initialSettings.baselineLength.toString()) } + var periodLength by remember { mutableStateOf(initialSettings.periodLength.toString()) } + var lutealDays by remember { mutableStateOf(initialSettings.lutealDays.toString()) } + + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + var lastPeriodStartStr by remember { + mutableStateOf(initialSettings.lastPeriodStart.format(formatter)) + } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button(onClick = { + try { + val newSettings = CycleSettings( + baselineLength = baselineLength.toIntOrNull() ?: initialSettings.baselineLength, + periodLength = periodLength.toIntOrNull() ?: initialSettings.periodLength, + lutealDays = lutealDays.toIntOrNull() ?: initialSettings.lutealDays, + lastPeriodStart = try { + LocalDate.parse(lastPeriodStartStr, formatter) + } catch (e: DateTimeParseException) { + initialSettings.lastPeriodStart + } + ) + onSave(newSettings) + } catch (e: Exception) { + // В случае ошибки сохраняем исходные настройки + onSave(initialSettings) + } + }) { + Text("Сохранить") + } + }, + dismissButton = { + Button(onClick = onDismiss) { Text("Отмена") } + }, + title = { Text("Настройки цикла") }, + text = { + Column { + OutlinedTextField( + value = baselineLength, + onValueChange = { baselineLength = it }, + label = { Text("Длина цикла (дней)") }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + OutlinedTextField( + value = periodLength, + onValueChange = { periodLength = it }, + label = { Text("Длина месячных (дней)") }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + OutlinedTextField( + value = lutealDays, + onValueChange = { lutealDays = it }, + label = { Text("Лютеиновая фаза (дней)") }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + OutlinedTextField( + value = lastPeriodStartStr, + onValueChange = { lastPeriodStartStr = it }, + label = { Text("Первый день последних месячных (гггг-мм-дд)") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + ) +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleUiState.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleUiState.kt new file mode 100644 index 0000000..5641718 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleUiState.kt @@ -0,0 +1,41 @@ +package kr.smartsoltech.wellshe.ui.cycle + +import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity +import kr.smartsoltech.wellshe.model.CycleSettings +import java.time.LocalDate + +data class CycleUiState( + // Основные параметры цикла + val currentPhase: String = "Фолликулярная", + val currentCycleDay: Int = 1, + val cycleLength: Int = 28, + val daysUntilNextPeriod: Int = 0, + val isPeriodActive: Boolean = false, + + // Прогнозы + val nextPeriodDate: LocalDate? = null, + val ovulationDate: LocalDate? = null, + val fertilityWindow: Pair? = null, + + // Симптомы и настроение сегодня + val todaySymptoms: List = emptyList(), + val todayMood: String = "", + val showSymptomsEdit: Boolean = false, + + // Аналитика + val averageCycleLength: Float = 0f, + val insights: List = emptyList(), + val recentPeriods: List = emptyList(), + + // Параметры цикла пользователя + val cycleSettings: CycleSettings = CycleSettings( + baselineLength = 28, + periodLength = 5, + lutealDays = 14, + lastPeriodStart = LocalDate.now().minusDays(14) + ), + + // Состояние загрузки и ошибки + val isLoading: Boolean = false, + val error: String? = null +) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleViewModel.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleViewModel.kt index c24e40c..bbf639c 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleViewModel.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/CycleViewModel.kt @@ -3,301 +3,222 @@ package kr.smartsoltech.wellshe.ui.cycle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.update import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity -import kr.smartsoltech.wellshe.data.repository.WellSheRepository +import kr.smartsoltech.wellshe.data.repository.CycleRepository +import kr.smartsoltech.wellshe.model.CycleSettings +import kr.smartsoltech.wellshe.model.CycleForecast +import kr.smartsoltech.wellshe.model.computeForecast import java.time.LocalDate -import java.time.temporal.ChronoUnit -import javax.inject.Inject - -data class CycleUiState( - val currentPhase: String = "Фолликулярная", - val currentCycleDay: Int = 1, - val cycleLength: Int = 28, - val daysUntilNextPeriod: Int = 0, - val isPeriodActive: Boolean = false, - val nextPeriodDate: LocalDate? = null, - val ovulationDate: LocalDate? = null, - val fertilityWindow: Pair? = null, - val recentPeriods: List = emptyList(), - val averageCycleLength: Float = 0f, - val insights: List = emptyList(), - val showSymptomsEdit: Boolean = false, - val todaySymptoms: List = emptyList(), - val todayMood: String = "", - val isLoading: Boolean = false, - val error: String? = null -) +import java.time.YearMonth +import java.time.ZoneId +import java.time.format.DateTimeFormatter @HiltViewModel class CycleViewModel @Inject constructor( - private val repository: WellSheRepository + private val cycleRepository: CycleRepository ) : ViewModel() { + // События навигации + sealed class NavigationEvent { + object NavigateToCycleSettings : NavigationEvent() + } - private val _uiState = MutableStateFlow(CycleUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + data class UiState( + val month: YearMonth = YearMonth.now(), + val forecast: CycleForecast? = null, + val todaySymptoms: String = "Лёгкая усталость, аппетит выше обычного", + val weekInsight: String = "", + val cycleSettings: CycleSettings = CycleSettings( + baselineLength = 28, + periodLength = 5, + lutealDays = 14, + lastPeriodStart = LocalDate.of(2025, 9, 18) + ), + val recentPeriods: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null + ) + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + + // Состояние для отображения диалога настроек + private val _showSettingsDialog = MutableStateFlow(false) + val showSettingsDialog: StateFlow = _showSettingsDialog + + // События навигации + private val _navigationEvents = MutableStateFlow(null) + val navigationEvents: StateFlow = _navigationEvents + + // История веса + private val _weightHistory = MutableStateFlow>>(emptyMap()) + val weightHistory: StateFlow>> = _weightHistory + + // История спорта + private val _sportHistory = MutableStateFlow>>(emptyMap()) + val sportHistory: StateFlow>> = _sportHistory + + // История воды + private val _waterHistory = MutableStateFlow>>(emptyMap()) + val waterHistory: StateFlow>> = _waterHistory + + init { + loadCycleData() + updateForecast() + updateWeekInsight() + } + + fun openSettingsDialog() { + _showSettingsDialog.value = true + } + + fun closeSettingsDialog() { + _showSettingsDialog.value = false + } + + fun clearNavigationEvent() { + _navigationEvents.value = null + } + + fun saveCycleSettings(newSettings: CycleSettings) { + _uiState.update { it.copy(cycleSettings = newSettings) } + closeSettingsDialog() + updateForecast() + updateWeekInsight() + } + + fun updateCycleSettings(newSettings: CycleSettings) { + _uiState.update { it.copy(cycleSettings = newSettings) } + } + + private fun updateForecast() { + val forecast = computeForecast(_uiState.value.cycleSettings) + _uiState.update { it.copy(forecast = forecast) } + } + + private fun updateWeekInsight() { + val forecast = _uiState.value.forecast ?: return + val now = LocalDate.now() + val insight = "ПМС с ${fmt(forecast.pmsStart)}; окно фертильности: ${fmt(forecast.fertileStart)}–${fmt(forecast.fertileEnd)}" + _uiState.update { it.copy(weekInsight = insight) } + } fun loadCycleData() { viewModelScope.launch { - _uiState.value = _uiState.value.copy(isLoading = true) - + _uiState.update { it.copy(isLoading = true) } try { - // Загружаем текущий период - repository.getCurrentCyclePeriod().collect { currentPeriod -> - val isPeriodActive = currentPeriod != null && currentPeriod.endDate == null - - _uiState.value = _uiState.value.copy( - isPeriodActive = isPeriodActive, - isLoading = false - ) - - // Вычисляем текущий день цикла и фазу - calculateCycleInfo(currentPeriod) - } - - // Загружаем историю периодов - repository.getRecentPeriods().collect { periods -> - val averageLength = calculateAverageCycleLength(periods) - val insights = generateCycleInsights(periods) - - _uiState.value = _uiState.value.copy( - recentPeriods = periods, - averageCycleLength = averageLength, - insights = insights - ) - } - - // Загружаем настройки цикла пользователя - repository.getUserProfile().collect { user -> - _uiState.value = _uiState.value.copy( - cycleLength = user.cycleLength - ) - } - + val periods = cycleRepository.getAllPeriods() + _uiState.update { it.copy(recentPeriods = periods, isLoading = false) } } catch (e: Exception) { - _uiState.value = _uiState.value.copy( - isLoading = false, - error = e.message - ) + _uiState.update { it.copy(isLoading = false, error = e.message) } } } } - private fun calculateCycleInfo(currentPeriod: CyclePeriodEntity?) { - val today = LocalDate.now() - val cycleLength = _uiState.value.cycleLength + fun prevMonth() { + _uiState.update { it.copy(month = it.month.minusMonths(1)) } + } - if (currentPeriod != null) { - val daysSinceStart = ChronoUnit.DAYS.between(currentPeriod.startDate, today).toInt() + 1 - val currentCycleDay = if (daysSinceStart > cycleLength) { - // Если прошло больше дней чем длина цикла, начинаем новый цикл - (daysSinceStart - 1) % cycleLength + 1 + fun nextMonth() { + _uiState.update { it.copy(month = it.month.plusMonths(1)) } + } + + fun markPeriodStart() { + viewModelScope.launch { + val today = LocalDate.now() + val newPeriod = CyclePeriodEntity(startDate = today, endDate = null) + cycleRepository.insertPeriod(newPeriod) + loadCycleData() + + // Обновляем настройки цикла с новой датой начала + val newSettings = _uiState.value.cycleSettings.copy(lastPeriodStart = today) + _uiState.update { it.copy(cycleSettings = newSettings) } + updateForecast() + updateWeekInsight() + } + } + + fun markPeriodEnd() { + viewModelScope.launch { + val today = LocalDate.now() + val latestPeriod = _uiState.value.recentPeriods.firstOrNull { it.endDate == null } + latestPeriod?.let { + val updatedPeriod = it.copy(endDate = today) + cycleRepository.updatePeriod(updatedPeriod) + loadCycleData() + } + } + } + + fun addSymptom() { + // Заглушка для демонстрации + } + + fun addNote() { + // Заглушка для демонстрации + } + + fun addSportActivity(date: LocalDate, activity: String) { + if (activity.isNotBlank()) { + _sportHistory.update { currentHistory -> + val updatedActivities = (currentHistory[date] ?: emptyList()) + activity + currentHistory.toMutableMap().apply { put(date, updatedActivities) } + } + } + } + + fun removeSportActivity(date: LocalDate, activity: String) { + _sportHistory.update { currentHistory -> + val updatedActivities = (currentHistory[date] ?: emptyList()).filter { it != activity } + val updatedMap = currentHistory.toMutableMap() + if (updatedActivities.isEmpty()) { + updatedMap.remove(date) } else { - daysSinceStart + updatedMap[date] = updatedActivities } - - val phase = calculatePhase(currentCycleDay, cycleLength) - val daysUntilNext = cycleLength - currentCycleDay - - // Прогнозы - val nextPeriodDate = currentPeriod.startDate.plusDays(cycleLength.toLong()) - val ovulationDay = cycleLength / 2 // Примерно в середине цикла - val ovulationDate = currentPeriod.startDate.plusDays(ovulationDay.toLong()) - val fertilityStart = ovulationDate.minusDays(5) - val fertilityEnd = ovulationDate.plusDays(1) - - _uiState.value = _uiState.value.copy( - currentCycleDay = currentCycleDay, - currentPhase = phase, - daysUntilNextPeriod = daysUntilNext.coerceAtLeast(0), - nextPeriodDate = nextPeriodDate, - ovulationDate = ovulationDate, - fertilityWindow = Pair(fertilityStart, fertilityEnd) - ) - } else { - // Нет данных о текущем цикле - _uiState.value = _uiState.value.copy( - currentCycleDay = 1, - currentPhase = "Нет данных", - daysUntilNextPeriod = 0, - nextPeriodDate = null, - ovulationDate = null, - fertilityWindow = null - ) + updatedMap } } - private fun calculatePhase(cycleDay: Int, cycleLength: Int): String { - return when { - cycleDay <= 5 -> "Менструация" - cycleDay <= cycleLength / 2 - 2 -> "Фолликулярная" - cycleDay <= cycleLength / 2 + 2 -> "Овуляция" - else -> "Лютеиновая" - } - } - - private fun calculateAverageCycleLength(periods: List): Float { - if (periods.size < 2) return 0f - - val cycleLengths = mutableListOf() - for (i in 0 until periods.size - 1) { - val currentPeriod = periods[i] - val nextPeriod = periods[i + 1] - val length = ChronoUnit.DAYS.between(nextPeriod.startDate, currentPeriod.startDate).toInt() - if (length > 0) { - cycleLengths.add(length) - } - } - - return if (cycleLengths.isNotEmpty()) { - cycleLengths.average().toFloat() - } else { - 0f - } - } - - private fun generateCycleInsights(periods: List): List { - val insights = mutableListOf() - - if (periods.size >= 3) { - val averageLength = calculateAverageCycleLength(periods) - - when { - averageLength < 21 -> { - insights.add("Ваши циклы короче обычного. Рекомендуем консультацию с врачом.") - } - averageLength > 35 -> { - insights.add("Ваши циклы длиннее обычного. Стоит обратиться к специалисту.") - } - else -> { - insights.add("Длина ваших циклов в пределах нормы.") - } - } - - // Анализ регулярности - val cycleLengths = mutableListOf() - for (i in 0 until periods.size - 1) { - val length = ChronoUnit.DAYS.between(periods[i + 1].startDate, periods[i].startDate).toInt() - if (length > 0) cycleLengths.add(length) - } - - if (cycleLengths.size >= 2) { - val deviation = cycleLengths.map { kotlin.math.abs(it - averageLength) }.average() - - if (deviation <= 3) { - insights.add("У вас очень регулярный цикл.") - } else if (deviation <= 7) { - insights.add("Ваш цикл достаточно регулярный.") - } else { - insights.add("Циклы нерегулярные. Рекомендуем отслеживать факторы, влияющие на цикл.") - } - } - - // Анализ симптомов - val symptomsData = periods.mapNotNull { period -> - period.symptoms.split(",").filter { it.isNotBlank() } - }.flatten() - - if (symptomsData.isNotEmpty()) { - val commonSymptoms = symptomsData.groupBy { it }.maxByOrNull { it.value.size }?.key - if (commonSymptoms != null) { - insights.add("Наиболее частый симптом: $commonSymptoms") - } - } - } - - return insights - } - - fun startPeriod() { - viewModelScope.launch { - try { - val today = LocalDate.now() - - repository.addPeriod( - startDate = today, - endDate = null, - flow = "Средний", - symptoms = emptyList(), - mood = "" - ) - - _uiState.value = _uiState.value.copy(isPeriodActive = true) - loadCycleData() // Перезагружаем данные - - } catch (e: Exception) { - _uiState.value = _uiState.value.copy(error = e.message) + fun addWaterRecord(date: LocalDate, amount: Int) { + if (amount > 0) { + _waterHistory.update { currentHistory -> + val updatedAmounts = (currentHistory[date] ?: emptyList()) + amount + currentHistory.toMutableMap().apply { put(date, updatedAmounts) } } } } - fun endPeriod() { - viewModelScope.launch { - try { - val today = LocalDate.now() - val currentPeriod = _uiState.value.recentPeriods.firstOrNull { it.endDate == null } + fun removeWaterRecord(date: LocalDate, index: Int) { + _waterHistory.update { currentHistory -> + val current = currentHistory[date] ?: return@update currentHistory + if (index < 0 || index >= current.size) return@update currentHistory - if (currentPeriod != null) { - repository.addPeriod( - startDate = currentPeriod.startDate, - endDate = today, - flow = currentPeriod.flow, - symptoms = currentPeriod.symptoms.split(","), - mood = currentPeriod.mood - ) + val updated = current.toMutableList().apply { removeAt(index) } + val updatedMap = currentHistory.toMutableMap() + if (updated.isEmpty()) { + updatedMap.remove(date) + } else { + updatedMap[date] = updated + } + updatedMap + } + } - _uiState.value = _uiState.value.copy(isPeriodActive = false) - loadCycleData() // Перезагружаем данные - } - - } catch (e: Exception) { - _uiState.value = _uiState.value.copy(error = e.message) + fun addOrUpdateWeight(date: LocalDate, weight: Float) { + if (weight > 0) { + _weightHistory.update { currentHistory -> + val updatedWeights = (currentHistory[date] ?: emptyList()) + weight + currentHistory.toMutableMap().apply { put(date, updatedWeights) } } } } - fun toggleSymptomsEdit() { - _uiState.value = _uiState.value.copy( - showSymptomsEdit = !_uiState.value.showSymptomsEdit - ) - } - - fun updateSymptoms(symptoms: List) { - _uiState.value = _uiState.value.copy(todaySymptoms = symptoms) - } - - fun updateMood(mood: String) { - _uiState.value = _uiState.value.copy(todayMood = mood) - } - - fun saveTodayData() { - viewModelScope.launch { - try { - val today = LocalDate.now() - val symptoms = _uiState.value.todaySymptoms - val mood = _uiState.value.todayMood - - // TODO: Сохранить симптомы и настроение за сегодня - // Это может быть отдельная таблица или обновление текущего периода - - _uiState.value = _uiState.value.copy( - showSymptomsEdit = false, - todaySymptoms = emptyList(), - todayMood = "" - ) - - loadCycleData() // Перезагружаем данные - - } catch (e: Exception) { - _uiState.value = _uiState.value.copy(error = e.message) - } - } - } - - fun clearError() { - _uiState.value = _uiState.value.copy(error = null) + private fun fmt(date: LocalDate): String { + return date.format(DateTimeFormatter.ofPattern("dd MMM")) } } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/JournalEditorDialog.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/JournalEditorDialog.kt new file mode 100644 index 0000000..46bbd38 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/JournalEditorDialog.kt @@ -0,0 +1,128 @@ +package kr.smartsoltech.wellshe.ui.cycle + +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kr.smartsoltech.wellshe.model.JournalEntry +import kr.smartsoltech.wellshe.model.JournalMedia +import kr.smartsoltech.wellshe.model.MediaType +import androidx.compose.foundation.background + +@Composable +fun JournalEditorDialog( + initialEntry: JournalEntry?, + onSave: (JournalEntry) -> Unit, + onDelete: () -> Unit, + onDismiss: () -> Unit +) { + var text by remember { mutableStateOf(initialEntry?.text ?: "") } + var media by remember { mutableStateOf(initialEntry?.media ?: emptyList()) } + var showMediaPicker by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button(onClick = { + onSave( + initialEntry?.copy(text = text, media = media) ?: JournalEntry( + id = 0L, + date = initialEntry?.date ?: java.time.LocalDate.now(), + text = text, + media = media + ) + ) + }) { + Text("Сохранить") + } + }, + dismissButton = { + if (initialEntry != null) { + Button(onClick = onDelete, colors = ButtonDefaults.buttonColors(containerColor = Color.Red)) { + Text("Удалить", color = Color.White) + } + } + Button(onClick = onDismiss) { + Text("Отмена") + } + }, + title = { Text(if (initialEntry == null) "Новая запись" else "Редактировать запись") }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + // Форматирование текста (минимум: жирный, курсив) + Row(modifier = Modifier.padding(bottom = 8.dp)) { + IconButton(onClick = { text += "**жирный**" }) { + Icon(Icons.Default.FormatBold, contentDescription = "Жирный") + } + IconButton(onClick = { text += "*курсив*" }) { + Icon(Icons.Default.FormatItalic, contentDescription = "Курсив") + } + IconButton(onClick = { text += "- элемент списка\n" }) { + Icon(Icons.Default.FormatListBulleted, contentDescription = "Список") + } + } + BasicTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background(Color(0xFFF8F9FA), shape = MaterialTheme.shapes.small) + .padding(8.dp) + ) + Spacer(Modifier.height(8.dp)) + // Медиа + Row { + Button(onClick = { showMediaPicker = true }) { + Icon(Icons.Default.AddPhotoAlternate, contentDescription = "Добавить медиа") + Text("Медиа") + } + } + if (media.isNotEmpty()) { + Column { + media.forEach { m -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + when (m.type) { + MediaType.IMAGE -> Icons.Default.Image + MediaType.VIDEO -> Icons.Default.Videocam + MediaType.AUDIO -> Icons.Default.MusicNote + }, + contentDescription = null + ) + Text(m.uri) + IconButton(onClick = { media = media - m }) { + Icon(Icons.Default.Delete, contentDescription = "Удалить медиа") + } + } + } + } + } + // Примитивный медиа-пикер (заглушка) + if (showMediaPicker) { + AlertDialog( + onDismissRequest = { showMediaPicker = false }, + confirmButton = { + Button(onClick = { + // Пример добавления картинки (заглушка) + media = media + JournalMedia(uri = "media_uri_example", type = MediaType.IMAGE) + showMediaPicker = false + }) { + Text("Добавить картинку") + } + }, + title = { Text("Добавить медиа") }, + text = { Text("Здесь будет выбор медиафайла") } + ) + } + } + } + ) +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/ModernDatePickerDialog.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/ModernDatePickerDialog.kt new file mode 100644 index 0000000..eba9c10 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/ModernDatePickerDialog.kt @@ -0,0 +1,59 @@ +package kr.smartsoltech.wellshe.ui.cycle + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import java.time.LocalDate + +@Composable +fun ModernDatePickerDialog( + onDateSelected: (LocalDate) -> Unit, + onDismiss: () -> Unit +) { + var selectedDate by remember { mutableStateOf(LocalDate.now()) } + val daysInMonth = selectedDate.lengthOfMonth() + val monthDates = (1..daysInMonth).map { selectedDate.withDayOfMonth(it) } + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button(onClick = { onDateSelected(selectedDate) }) { Text("Выбрать") } + }, + dismissButton = { + Button(onClick = onDismiss) { Text("Отмена") } + }, + title = { Text("Выбор даты") }, + text = { + Column { + // Горизонтальный календарь + LazyRow(Modifier.fillMaxWidth()) { + items(monthDates) { date -> + val isSelected = date == selectedDate + Button( + onClick = { selectedDate = date }, + colors = ButtonDefaults.buttonColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface + ), + modifier = Modifier.padding(2.dp) + ) { + Text("${date.dayOfMonth}") + } + } + } + Spacer(Modifier.height(8.dp)) + // Ручной ввод + OutlinedTextField( + value = selectedDate.toString(), + onValueChange = { + runCatching { selectedDate = LocalDate.parse(it) } + }, + label = { Text("Дата (ГГГГ-ММ-ДД)") }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) + } + } + ) +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/SportContent.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/SportContent.kt new file mode 100644 index 0000000..d027815 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/SportContent.kt @@ -0,0 +1,49 @@ +package kr.smartsoltech.wellshe.ui.cycle + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import java.time.LocalDate +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.foundation.background + +@Composable +fun SportContent(viewModel: CycleViewModel) { + val sportHistory = viewModel.sportHistory.collectAsState() + var selectedDate by remember { mutableStateOf(LocalDate.now()) } + var activityInput by remember { mutableStateOf("") } + val activities = sportHistory.value[selectedDate] ?: emptyList() + + Column(Modifier.fillMaxSize().padding(16.dp)) { + Text("Спорт", style = MaterialTheme.typography.titleLarge) + OutlinedTextField( + value = activityInput, + onValueChange = { activityInput = it }, + label = { Text("Активность") }, + modifier = Modifier.fillMaxWidth() + ) + Button(onClick = { + viewModel.addSportActivity(selectedDate, activityInput) + activityInput = "" + }, modifier = Modifier.fillMaxWidth()) { + Text("Добавить") + } + Spacer(Modifier.height(8.dp)) + Text("Активности за выбранный день:", style = MaterialTheme.typography.titleMedium) + LazyColumn { + items(activities) { act -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(act, style = MaterialTheme.typography.bodyLarge) + IconButton(onClick = { viewModel.removeSportActivity(selectedDate, act) }) { + Icon(Icons.Default.Delete, contentDescription = "Удалить") + } + } + } + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/WaterContent.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/WaterContent.kt new file mode 100644 index 0000000..926fc3e --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/WaterContent.kt @@ -0,0 +1,57 @@ +package kr.smartsoltech.wellshe.ui.cycle + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import java.time.LocalDate +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import kotlinx.coroutines.launch +import androidx.compose.foundation.background + +@Composable +fun WaterContent(viewModel: CycleViewModel) { + val waterHistory by viewModel.waterHistory.collectAsState() + var selectedDate by remember { mutableStateOf(LocalDate.now()) } + var waterInput by remember { mutableStateOf("") } + val records = waterHistory[selectedDate] ?: emptyList() + val scope = rememberCoroutineScope() + + Column(Modifier.fillMaxSize().padding(16.dp)) { + Text("Вода", style = MaterialTheme.typography.titleLarge) + OutlinedTextField( + value = waterInput, + onValueChange = { waterInput = it }, + label = { Text("Выпито (мл)") }, + modifier = Modifier.fillMaxWidth() + ) + Button(onClick = { + scope.launch { + viewModel.addWaterRecord(selectedDate, waterInput.toIntOrNull() ?: 0) + } + waterInput = "" + }, modifier = Modifier.fillMaxWidth()) { + Text("Добавить") + } + Spacer(Modifier.height(8.dp)) + Text("Записи за выбранный день:", style = MaterialTheme.typography.titleMedium) + LazyColumn { + items(records) { rec -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("${rec} мл", style = MaterialTheme.typography.bodyLarge) + IconButton(onClick = { + scope.launch { + viewModel.removeWaterRecord(selectedDate, rec) + } + }) { + Icon(Icons.Default.Delete, contentDescription = "Удалить") + } + } + } + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/WeightContent.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/WeightContent.kt new file mode 100644 index 0000000..2904992 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/WeightContent.kt @@ -0,0 +1,74 @@ +package kr.smartsoltech.wellshe.ui.cycle + +import java.time.LocalDate +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +@Composable +fun WeightContent(viewModel: CycleViewModel) { + val weightHistory by viewModel.weightHistory.collectAsState() + var selectedDate by remember { mutableStateOf(LocalDate.now()) } + var weightInput by remember { mutableStateOf("") } + val daysInMonth = selectedDate.lengthOfMonth() + val monthDates = (1..daysInMonth).map { selectedDate.withDayOfMonth(it) } + val weights = weightHistory[selectedDate] ?: emptyList() + val scope = rememberCoroutineScope() + + Column(Modifier.fillMaxSize().padding(16.dp)) { + Text("Вес", style = MaterialTheme.typography.titleLarge) + LazyRow(Modifier.fillMaxWidth()) { + items(monthDates) { date -> + val isSelected = date == selectedDate + Button( + onClick = { selectedDate = date }, + colors = ButtonDefaults.buttonColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface + ), + modifier = Modifier.padding(2.dp) + ) { + Text("${date.dayOfMonth}") + } + } + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = weightInput, + onValueChange = { weightInput = it }, + label = { Text("Вес (кг)") }, + modifier = Modifier.fillMaxWidth() + ) + Button(onClick = { + scope.launch { + viewModel.addOrUpdateWeight(selectedDate, weightInput.toFloatOrNull() ?: 0f) + } + weightInput = "" + }, modifier = Modifier.fillMaxWidth()) { + Text("Сохранить") + } + Spacer(Modifier.height(8.dp)) + Text("История за выбранный день:", style = MaterialTheme.typography.titleMedium) + weights.forEach { w: Float -> + Text("${w} кг", style = MaterialTheme.typography.bodyLarge) + } + Spacer(Modifier.height(16.dp)) + Text("График веса за месяц:", style = MaterialTheme.typography.titleMedium) + // Примитивный график + Row(Modifier.fillMaxWidth()) { + monthDates.forEach { date -> + val ws = weightHistory[date] + val weight = ws?.lastOrNull() ?: 0f + Box(Modifier.height((weight * 2).dp).width(8.dp).background(MaterialTheme.colorScheme.primary)) + } + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/components/CycleCalendar.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/components/CycleCalendar.kt new file mode 100644 index 0000000..98fdc84 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/components/CycleCalendar.kt @@ -0,0 +1,236 @@ +package kr.smartsoltech.wellshe.ui.cycle.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +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.style.TextAlign +import androidx.compose.ui.unit.dp +import kr.smartsoltech.wellshe.model.CycleForecast +import kr.smartsoltech.wellshe.ui.components.PhasePill +import kr.smartsoltech.wellshe.ui.theme.* +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.util.* + +/** + * Карточка календаря цикла + */ +@Composable +fun CycleCalendarCard( + month: YearMonth, + onPrev: () -> Unit, + onNext: () -> Unit, + forecast: CycleForecast?, + modifier: Modifier = Modifier +) { + val daysOfWeek = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс") + val monthFormat = DateTimeFormatter.ofPattern("LLLL yyyy", Locale("ru")) + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = CycleTabColor.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Заголовок карточки с иконкой + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + imageVector = Icons.Default.WbSunny, + contentDescription = null, + tint = Color(0xFFF9A825) + ) + Text( + text = "Календарь цикла", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + + // Заголовок месяца с кнопками навигации + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onPrev) { + Icon( + imageVector = Icons.Default.KeyboardArrowLeft, + contentDescription = "Предыдущий месяц" + ) + } + + Text( + text = month.format(monthFormat).replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + + IconButton(onClick = onNext) { + Icon( + imageVector = Icons.Default.KeyboardArrowRight, + contentDescription = "Следующий месяц" + ) + } + } + + // Дни недели (заголовки) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + daysOfWeek.forEach { day -> + Text( + text = day, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Сетка дней календаря + CalendarGrid(month, forecast) + + Divider(modifier = Modifier.padding(vertical = 16.dp)) + + // Легенда фаз + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + PhasePill(label = "Менструация", color = Color(0xFFE57373)) + PhasePill(label = "Фертильное окно", color = Color(0xFF4CAF50)) + PhasePill(label = "Овуляция", color = Color(0xFF3F51B5)) + PhasePill(label = "ПМС", color = Color(0xFFFFA726)) + } + } + } +} + +/** + * Сетка календаря с днями месяца + */ +@Composable +fun CalendarGrid( + month: YearMonth, + forecast: CycleForecast? +) { + // Получаем текущую дату + val today = LocalDate.now() + + // Получаем первый день месяца и последний день месяца + val firstDay = month.atDay(1) + val lastDay = month.atEndOfMonth() + + // Вычисляем смещение первого дня месяца + val dayOfWeekValue = firstDay.dayOfWeek.value // 1 для понедельника, 7 для воскресенья + val firstDayOffset = if (dayOfWeekValue == 7) 0 else dayOfWeekValue + + // Создаем полную сетку календаря (6 недель по 7 дней) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + for (week in 0 until 6) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + for (dayOfWeek in 0 until 7) { + val dayNumber = week * 7 + dayOfWeek - firstDayOffset + 1 + val isValidDay = dayNumber in 1..lastDay.dayOfMonth + + if (isValidDay) { + val date = month.atDay(dayNumber) + CalendarDay(date = date, today = today, forecast = forecast) + } else { + // Пустая ячейка для выравнивания + Box( + modifier = Modifier + .size(36.dp) + .weight(1f) + ) + } + } + } + } + } +} + +/** + * Отображение одного дня календаря + */ +@Composable +fun CalendarDay( + date: LocalDate, + today: LocalDate, + forecast: CycleForecast? +) { + // Определяем, в какой фазе цикла находится день + val isPeriod = forecast?.let { date in it.nextPeriodStart..it.periodEnd } ?: false + val isFertile = forecast?.let { date in it.fertileStart..it.fertileEnd } ?: false + val isPms = forecast?.let { date in it.pmsStart..it.nextPeriodStart.minusDays(1) } ?: false + val isOvulation = forecast?.let { date == it.nextOvulation } ?: false + val isToday = date == today + + // Определяем цвет фона + val backgroundColor = when { + isPeriod -> PeriodColor + isFertile -> FertileColor + isPms -> PmsColor + else -> Color.Transparent + } + + // Добавляем модификаторы в зависимости от фазы + val baseModifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(backgroundColor) + + // Особое выделение для овуляции + val finalModifier = if (isOvulation) { + baseModifier.border(BorderStroke(2.dp, OvulationBorder), CircleShape) + } else { + baseModifier + } + + Box( + modifier = finalModifier, + contentAlignment = Alignment.Center + ) { + Text( + text = date.dayOfMonth.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/components/CycleComponents.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/components/CycleComponents.kt new file mode 100644 index 0000000..cfd8e0f --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/components/CycleComponents.kt @@ -0,0 +1,17 @@ +package kr.smartsoltech.wellshe.ui.cycle.components + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kr.smartsoltech.wellshe.model.CycleForecast +import java.time.YearMonth + +/** + * Этот файл содержал дубликаты функций, определённых в CycleCalendar.kt + * Для устранения конфликтов при компиляции, функции CycleCalendarCard и CalendarGrid были удалены + * Используйте функции из CycleCalendar.kt вместо них + */ +@Deprecated("Используйте CycleCalendar.kt вместо этого файла") +object CycleComponentsDeprecated { + // Пустой объект для предотвращения ошибок при компиляции +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/components/QuickActions.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/components/QuickActions.kt new file mode 100644 index 0000000..417fcb8 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/components/QuickActions.kt @@ -0,0 +1,149 @@ +package kr.smartsoltech.wellshe.ui.cycle.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Healing +import androidx.compose.material.icons.filled.Note +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * Карточка с быстрыми действиями + */ +@Composable +fun QuickActionsCard( + onMarkStart: () -> Unit, + onMarkEnd: () -> Unit, + onAddSymptom: () -> Unit, + onAddNote: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Быстрые действия", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + QuickActionButton( + text = "Отметить начало", + onClick = onMarkStart, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + + QuickActionButton( + text = "Отметить окончание", + onClick = onMarkEnd, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedQuickActionButton( + text = "Симптом", + onClick = onAddSymptom, + modifier = Modifier.weight(1f), + icon = Icons.Default.Healing + ) + + OutlinedQuickActionButton( + text = "Заметка", + onClick = onAddNote, + modifier = Modifier.weight(1f), + icon = Icons.Default.Note + ) + } + } + } +} + +/** + * Кнопка для быстрых действий (заполненная) + */ +@Composable +fun QuickActionButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + colors: ButtonColors = ButtonDefaults.buttonColors() +) { + Button( + onClick = onClick, + shape = RoundedCornerShape(12.dp), + colors = colors, + modifier = modifier, + contentPadding = PaddingValues(vertical = 12.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium + ) + } +} + +/** + * Кнопка для быстрых действий (контурная) + */ +@Composable +fun OutlinedQuickActionButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: androidx.compose.ui.graphics.vector.ImageVector? = null +) { + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(12.dp), + modifier = modifier, + contentPadding = PaddingValues(vertical = 12.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + Text( + text = text, + style = MaterialTheme.typography.labelMedium + ) + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsContentPart1.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsContentPart1.kt new file mode 100644 index 0000000..d09a3cb --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsContentPart1.kt @@ -0,0 +1,539 @@ +package kr.smartsoltech.wellshe.ui.cycle.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity +import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity +import kr.smartsoltech.wellshe.domain.models.* +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +/** + * Основное содержимое экрана настроек цикла + */ +@Composable +fun CycleSettingsContent( + settings: CycleSettingsEntity, + validationState: CycleSettingsViewModel.ValidationState, + cycleHistory: List, + exportImportState: CycleSettingsViewModel.ExportImportState, + onBasicSettingChanged: (BasicSettingChange) -> Unit, + onOvulationMethodChanged: (OvulationMethod) -> Unit, + onAllowManualOvulationChanged: (Boolean) -> Unit, + onStatusChanged: (StatusChange) -> Unit, + onHistorySettingChanged: (HistorySetting) -> Unit, + onSensorSettingChanged: (SensorSetting) -> Unit, + onNotificationSettingChanged: (NotificationSetting) -> Unit, + onCycleAtypicalToggled: (Long, Boolean) -> Unit, + onExportSettings: () -> Unit, + onImportSettings: (String) -> Unit, + onResetExportImportState: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Секция основных настроек + BasicSettingsSection( + settings = settings, + validationState = validationState, + onSettingChanged = onBasicSettingChanged + ) + + Divider() + + // Секция метода определения овуляции + OvulationMethodSection( + currentMethod = OvulationMethod.fromString(settings.ovulationMethod), + allowManualOvulation = settings.allowManualOvulation, + onMethodChanged = onOvulationMethodChanged, + onAllowManualChanged = onAllowManualOvulationChanged + ) + + Divider() + + // Секция статусов + StatusSection( + hormonalContraception = HormonalContraceptionType.fromString(settings.hormonalContraception), + isPregnant = settings.isPregnant, + isPostpartum = settings.isPostpartum, + isLactating = settings.isLactating, + perimenopause = settings.perimenopause, + onStatusChanged = onStatusChanged + ) + + Divider() + + // Секция настроек сенсоров и единиц измерения + SensorSettingsSection( + tempUnit = TemperatureUnit.fromString(settings.tempUnit), + bbtTimeWindow = settings.bbtTimeWindow, + timezone = settings.timezone, + validationState = validationState, + onSettingChanged = onSensorSettingChanged + ) + + Divider() + + // Секция настроек уведомлений + NotificationSettingsSection( + periodReminderDays = settings.periodReminderDaysBefore, + ovulationReminderDays = settings.ovulationReminderDaysBefore, + pmsWindowDays = settings.pmsWindowDays, + deviationAlertDays = settings.deviationAlertDays, + fertileWindowMode = FertileWindowMode.fromString(settings.fertileWindowMode), + onSettingChanged = onNotificationSettingChanged + ) + + Divider() + + // Секция настроек истории + HistorySettingsSection( + historyWindowCycles = settings.historyWindowCycles, + excludeOutliers = settings.excludeOutliers, + onSettingChanged = onHistorySettingChanged + ) + + // История циклов + CycleHistorySection( + cycleHistory = cycleHistory, + onCycleAtypicalToggled = onCycleAtypicalToggled + ) + + Divider() + + // Секция экспорта/импорта + ExportImportSection( + exportImportState = exportImportState, + onExportSettings = onExportSettings, + onImportSettings = onImportSettings, + onResetExportImportState = onResetExportImportState + ) + + // Отступ внизу для скролла + Spacer(modifier = Modifier.height(32.dp)) + } +} + +/** + * Секция основных настроек цикла + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BasicSettingsSection( + settings: CycleSettingsEntity, + validationState: CycleSettingsViewModel.ValidationState, + onSettingChanged: (BasicSettingChange) -> Unit +) { + var showDatePicker by remember { mutableStateOf(false) } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Основные параметры цикла", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + // Базовая длина цикла + OutlinedTextField( + value = settings.baselineCycleLength.toString(), + onValueChange = { + try { + onSettingChanged(BasicSettingChange.BaselineCycleLength(it.toInt())) + } catch (e: NumberFormatException) { + // Игнорируем некорректный ввод + } + }, + label = { Text("Базовая длина цикла (дни)") }, + isError = validationState.baselineCycleLengthError != null, + supportingText = { + validationState.baselineCycleLengthError?.let { Text(it) } + }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Вариабельность цикла + OutlinedTextField( + value = settings.cycleVariabilityDays.toString(), + onValueChange = { + try { + onSettingChanged(BasicSettingChange.CycleVariability(it.toInt())) + } catch (e: NumberFormatException) { + // Игнорируем некорректный ввод + } + }, + label = { Text("Вариабельность (±дни)") }, + isError = validationState.cycleVariabilityError != null, + supportingText = { + validationState.cycleVariabilityError?.let { Text(it) } + }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Длительность менструации + OutlinedTextField( + value = settings.periodLengthDays.toString(), + onValueChange = { + try { + onSettingChanged(BasicSettingChange.PeriodLength(it.toInt())) + } catch (e: NumberFormatException) { + // Игнорируем некорректный ввод + } + }, + label = { Text("Длительность менструации (дни)") }, + isError = validationState.periodLengthError != null, + supportingText = { + validationState.periodLengthError?.let { Text(it) } + }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Лютеиновая фаза + OutlinedTextField( + value = settings.lutealPhaseDays, + onValueChange = { + onSettingChanged(BasicSettingChange.LutealPhase(it)) + }, + label = { Text("Лютеиновая фаза (дни или 'auto')") }, + isError = validationState.lutealPhaseError != null, + supportingText = { + validationState.lutealPhaseError?.let { + Text(it) + } ?: Text("Оставьте 'auto' или укажите значение 8-17 дней") + }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Дата последней менструации + OutlinedButton( + onClick = { showDatePicker = true }, + modifier = Modifier.fillMaxWidth() + ) { + val dateText = if (settings.lastPeriodStart != null) { + "Последняя менструация: ${ + settings.lastPeriodStart.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) + }" + } else { + "Выбрать дату последней менструации" + } + Text(dateText) + } + } + + // Показываем диалог выбора даты + if (showDatePicker) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = settings.lastPeriodStart?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + ) + + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { millis -> + val date = java.time.Instant.ofEpochMilli(millis) + .atZone(java.time.ZoneId.systemDefault()) + .toLocalDate() + onSettingChanged(BasicSettingChange.LastPeriodStart(date)) + } + showDatePicker = false + } + ) { + Text("Подтвердить") + } + }, + dismissButton = { + TextButton( + onClick = { showDatePicker = false } + ) { + Text("Отмена") + } + } + ) { + DatePicker(state = datePickerState) + } + } +} + +/** + * Секция метода определения овуляции + */ +@Composable +fun OvulationMethodSection( + currentMethod: OvulationMethod, + allowManualOvulation: Boolean, + onMethodChanged: (OvulationMethod) -> Unit, + onAllowManualChanged: (Boolean) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Метод определения овуляции", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + // Список методов овуляции + val methods = OvulationMethod.values() + methods.forEach { method -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = currentMethod == method, + onClick = { onMethodChanged(method) } + ) + + Text( + text = when (method) { + OvulationMethod.AUTO -> "Автоматически (календарный метод)" + OvulationMethod.BBT -> "Базальная температура тела" + OvulationMethod.LH_TEST -> "Тест на ЛГ" + OvulationMethod.CERVICAL_MUCUS -> "Цервикальная слизь" + OvulationMethod.MEDICAL -> "Медицинское подтверждение" + }, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + + // Переключатель для ручной фиксации + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Switch( + checked = allowManualOvulation, + onCheckedChange = onAllowManualChanged + ) + + Text( + text = "Разрешить ручную фиксацию овуляции", + modifier = Modifier.padding(start = 8.dp) + ) + } + } +} + +/** + * Секция статусов, влияющих на точность прогнозов + */ +@Composable +fun StatusSection( + hormonalContraception: HormonalContraceptionType, + isPregnant: Boolean, + isPostpartum: Boolean, + isLactating: Boolean, + perimenopause: Boolean, + onStatusChanged: (StatusChange) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Статусы (влияют на точность)", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + // Гормональная контрацепция + Text("Гормональная контрацепция:") + + val contraceptionTypes = HormonalContraceptionType.values() + contraceptionTypes.forEach { type -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = hormonalContraception == type, + onClick = { onStatusChanged(StatusChange.HormonalContraception(type)) } + ) + + Text( + text = when (type) { + HormonalContraceptionType.NONE -> "Нет" + HormonalContraceptionType.COC -> "Комбинированные оральные контрацептивы (КОК)" + HormonalContraceptionType.IUD -> "Гормональная внутриматочная спираль" + HormonalContraceptionType.IMPLANT -> "Имплант" + HormonalContraceptionType.OTHER -> "Другое" + }, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + + // Другие статусы (чекбоксы) + val statusItems = listOf( + Triple("Беременность", isPregnant) { value: Boolean -> + onStatusChanged(StatusChange.Pregnant(value)) + }, + Triple("Послеродовой период", isPostpartum) { value: Boolean -> + onStatusChanged(StatusChange.Postpartum(value)) + }, + Triple("Грудное вскармливание", isLactating) { value: Boolean -> + onStatusChanged(StatusChange.Lactating(value)) + }, + Triple("Перименопауза", perimenopause) { value: Boolean -> + onStatusChanged(StatusChange.Perimenopause(value)) + } + ) + + statusItems.forEach { (label, isChecked, onCheckedChange) -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = isChecked, + onCheckedChange = onCheckedChange + ) + + Text( + text = label, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } +} + +/** + * Секция истории циклов + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CycleHistorySection( + cycleHistory: List, + onCycleAtypicalToggled: (Long, Boolean) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "История циклов", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + if (cycleHistory.isEmpty()) { + Text( + text = "История циклов пуста", + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + // Показываем историю циклов + val sortedHistory = cycleHistory.sortedByDescending { it.periodStart } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + sortedHistory.forEach { cycle -> + ElevatedCard( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Начало: ${cycle.periodStart.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))}", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold + ) + + if (cycle.atypical) { + Badge( + contentColor = MaterialTheme.colorScheme.onError, + containerColor = MaterialTheme.colorScheme.error + ) { + Text("Атипичный") + } + } + } + + cycle.periodEnd?.let { + Text( + text = "Конец: ${it.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))}", + style = MaterialTheme.typography.bodyMedium + ) + } + + cycle.ovulationDate?.let { + Text( + text = "Овуляция: ${it.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))}", + style = MaterialTheme.typography.bodyMedium + ) + } + + if (cycle.notes.isNotEmpty()) { + Text( + text = "Примечания: ${cycle.notes}", + style = MaterialTheme.typography.bodySmall + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + OutlinedButton( + onClick = { onCycleAtypicalToggled(cycle.id, !cycle.atypical) } + ) { + Text(if (cycle.atypical) "Пометить как типичный" else "Пометить как атипичный") + } + } + } + } + } + } + } + } +} + +// Для полной реализации всех секций потребуется продолжить в новом файле +// Из-за ограничений размера файла diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsContentPart2.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsContentPart2.kt new file mode 100644 index 0000000..755be1f --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsContentPart2.kt @@ -0,0 +1,475 @@ +package kr.smartsoltech.wellshe.ui.cycle.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import kr.smartsoltech.wellshe.domain.models.FertileWindowMode +import kr.smartsoltech.wellshe.domain.models.TemperatureUnit + +/** + * Секция настроек сенсоров и единиц измерения + */ +@Composable +fun SensorSettingsSection( + tempUnit: TemperatureUnit, + bbtTimeWindow: String, + timezone: String, + validationState: CycleSettingsViewModel.ValidationState, + onSettingChanged: (SensorSetting) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Сенсоры и единицы измерения", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + // Единицы измерения температуры + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Единица измерения температуры:", + modifier = Modifier.weight(1f) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = tempUnit == TemperatureUnit.CELSIUS, + onClick = { onSettingChanged(SensorSetting.TempUnit(TemperatureUnit.CELSIUS)) } + ) + Text("°C") + } + + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = tempUnit == TemperatureUnit.FAHRENHEIT, + onClick = { onSettingChanged(SensorSetting.TempUnit(TemperatureUnit.FAHRENHEIT)) } + ) + Text("°F") + } + } + } + + // Временное окно для БТТ + OutlinedTextField( + value = bbtTimeWindow, + onValueChange = { onSettingChanged(SensorSetting.BbtTimeWindow(it)) }, + label = { Text("Временное окно для измерения БТТ") }, + placeholder = { Text("06:00-10:00") }, + isError = validationState.bbtTimeWindowError != null, + supportingText = { + validationState.bbtTimeWindowError?.let { Text(it) } + ?: Text("Формат: ЧЧ:ММ-ЧЧ:ММ") + }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Временная зона + OutlinedTextField( + value = timezone, + onValueChange = { onSettingChanged(SensorSetting.Timezone(it)) }, + label = { Text("Временная зона") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } +} + +/** + * Секция настроек уведомлений + */ +@Composable +fun NotificationSettingsSection( + periodReminderDays: Int, + ovulationReminderDays: Int, + pmsWindowDays: Int, + deviationAlertDays: Int, + fertileWindowMode: FertileWindowMode, + onSettingChanged: (NotificationSetting) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Уведомления", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + // Напоминание о менструации + OutlinedTextField( + value = periodReminderDays.toString(), + onValueChange = { + try { + onSettingChanged(NotificationSetting.PeriodReminder(it.toInt())) + } catch (e: NumberFormatException) { + // Игнорируем некорректный ввод + } + }, + label = { Text("Напоминание о менструации (дней до)") }, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Напоминание об овуляции + OutlinedTextField( + value = ovulationReminderDays.toString(), + onValueChange = { + try { + onSettingChanged(NotificationSetting.OvulationReminder(it.toInt())) + } catch (e: NumberFormatException) { + // Игнорируем некорректный ввод + } + }, + label = { Text("Напоминание об овуляции (дней до)") }, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Окно ПМС + OutlinedTextField( + value = pmsWindowDays.toString(), + onValueChange = { + try { + onSettingChanged(NotificationSetting.PmsWindow(it.toInt())) + } catch (e: NumberFormatException) { + // Игнорируем некорректный ввод + } + }, + label = { Text("Окно ПМС (дней)") }, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Оповещение об отклонении + OutlinedTextField( + value = deviationAlertDays.toString(), + onValueChange = { + try { + onSettingChanged(NotificationSetting.DeviationAlert(it.toInt())) + } catch (e: NumberFormatException) { + // Игнорируем некорректный ввод + } + }, + label = { Text("Оповещение об отклонении (дней после ожидаемой даты)") }, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Режим фертильного окна + Text( + text = "Режим определения фертильного окна:", + modifier = Modifier.padding(top = 8.dp) + ) + + val fertileWindowModes = FertileWindowMode.values() + fertileWindowModes.forEach { mode -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = fertileWindowMode == mode, + onClick = { onSettingChanged(NotificationSetting.FertileWindowMode(mode)) } + ) + + Column( + modifier = Modifier.padding(start = 8.dp) + ) { + Text( + text = when (mode) { + FertileWindowMode.CONSERVATIVE -> "Консервативный" + FertileWindowMode.BALANCED -> "Сбалансированный" + FertileWindowMode.BROAD -> "Широкий" + } + ) + + Text( + text = when (mode) { + FertileWindowMode.CONSERVATIVE -> "3 дня до овуляции + день овуляции" + FertileWindowMode.BALANCED -> "5 дней до овуляции + день овуляции" + FertileWindowMode.BROAD -> "7 дней до овуляции + день овуляции" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +/** + * Секция настроек истории + */ +@Composable +fun HistorySettingsSection( + historyWindowCycles: Int, + excludeOutliers: Boolean, + onSettingChanged: (HistorySetting) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Настройки истории", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + // Окно истории для расчётов + OutlinedTextField( + value = historyWindowCycles.toString(), + onValueChange = { + try { + onSettingChanged(HistorySetting.WindowCycles(it.toInt())) + } catch (e: NumberFormatException) { + // Игнорируем некорректный ввод + } + }, + label = { Text("Количество циклов для расчётов") }, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Исключать выбросы + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Switch( + checked = excludeOutliers, + onCheckedChange = { onSettingChanged(HistorySetting.ExcludeOutliers(it)) } + ) + + Text( + text = "Исключать выбросы (циклы < 18 или > 60 дней)", + modifier = Modifier.padding(start = 8.dp) + ) + } + } +} + +/** + * Секция экспорта/импорта настроек + */ +@Composable +fun ExportImportSection( + exportImportState: CycleSettingsViewModel.ExportImportState, + onExportSettings: () -> Unit, + onImportSettings: (String) -> Unit, + onResetExportImportState: () -> Unit +) { + var showImportDialog by remember { mutableStateOf(false) } + var importJson by remember { mutableStateOf("") } + + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Экспорт/Импорт настроек", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onExportSettings, + modifier = Modifier.weight(1f) + ) { + Text("Экспортировать") + } + + Button( + onClick = { showImportDialog = true }, + modifier = Modifier.weight(1f) + ) { + Text("Импортировать") + } + } + + when (val state = exportImportState) { + is CycleSettingsViewModel.ExportImportState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + is CycleSettingsViewModel.ExportImportState.ExportSuccess -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Text( + text = "Настройки экспортированы:", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + + OutlinedTextField( + value = state.json, + onValueChange = { }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .height(120.dp), + readOnly = true + ) + + Button( + onClick = { + clipboardManager.setText(AnnotatedString(state.json)) + onResetExportImportState() + }, + modifier = Modifier.align(Alignment.End) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Копировать в буфер обмена" + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Копировать") + } + } + } + is CycleSettingsViewModel.ExportImportState.ImportSuccess -> { + Text( + text = "Настройки успешно импортированы!", + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth() + ) + + // Автоматически сбрасываем состояние через небольшую задержку + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(3000) + onResetExportImportState() + } + } + is CycleSettingsViewModel.ExportImportState.Error -> { + Text( + text = state.message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth() + ) + + TextButton( + onClick = onResetExportImportState, + modifier = Modifier.align(Alignment.End) + ) { + Text("Закрыть") + } + } + else -> { /* Ничего не показываем в состоянии Idle */ } + } + } + + // Диалог импорта настроек + if (showImportDialog) { + Dialog( + onDismissRequest = { + showImportDialog = false + importJson = "" + } + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Text( + text = "Импорт настроек из JSON", + style = MaterialTheme.typography.titleLarge + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = importJson, + onValueChange = { importJson = it }, + modifier = Modifier + .fillMaxWidth() + .height(150.dp), + label = { Text("Вставьте JSON настроек") } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = { + showImportDialog = false + importJson = "" + } + ) { + Text("Отмена") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + if (importJson.isNotBlank()) { + onImportSettings(importJson) + showImportDialog = false + importJson = "" + } + }, + enabled = importJson.isNotBlank() + ) { + Text("Импортировать") + } + } + } + } + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsScreen.kt new file mode 100644 index 0000000..7fb8c5e --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsScreen.kt @@ -0,0 +1,231 @@ +package kr.smartsoltech.wellshe.ui.cycle.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import kr.smartsoltech.wellshe.R +import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity +import java.time.format.DateTimeFormatter + +/** + * Главный экран настроек цикла + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CycleSettingsScreen( + viewModel: CycleSettingsViewModel = hiltViewModel(), + onNavigateBack: () -> Unit +) { + val settings by viewModel.settingsState.collectAsStateWithLifecycle() + val validationState by viewModel.validationState.collectAsStateWithLifecycle() + val exportImportState by viewModel.exportImportState.collectAsStateWithLifecycle() + val cycleHistory by viewModel.cycleHistory.collectAsStateWithLifecycle() + + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + + // Отслеживаем события UI из ViewModel + LaunchedEffect(Unit) { + viewModel.uiEvents.observeForever { event -> + when (event) { + is CycleSettingsViewModel.UiEvent.ShowSnackbar -> { + coroutineScope.launch { + val result = if (event.actionLabel != null && event.action != null) { + snackbarHostState.showSnackbar( + message = event.message, + actionLabel = event.actionLabel, + duration = SnackbarDuration.Long + ) + } else { + snackbarHostState.showSnackbar( + message = event.message, + duration = SnackbarDuration.Short + ) + } + + // Вызываем действие отмены, если пользователь нажал на кнопку действия + if (result == SnackbarResult.ActionPerformed && event.action != null) { + event.action.invoke() + } + } + } + } + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text("Настройки цикла") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Назад" + ) + } + }, + actions = { + IconButton(onClick = { viewModel.resetToDefaults() }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Сбросить к рекомендуемым" + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + if (settings == null) { + // Показываем загрузку, если настройки еще не получены + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + // Основное содержимое экрана настроек + CycleSettingsContent( + settings = settings!!, + validationState = validationState, + cycleHistory = cycleHistory, + exportImportState = exportImportState, + onBasicSettingChanged = { + when (it) { + is BasicSettingChange.BaselineCycleLength -> + viewModel.updateBaselineCycleLength(it.value) + is BasicSettingChange.CycleVariability -> + viewModel.updateCycleVariability(it.value) + is BasicSettingChange.PeriodLength -> + viewModel.updatePeriodLength(it.value) + is BasicSettingChange.LutealPhase -> + viewModel.updateLutealPhase(it.value) + is BasicSettingChange.LastPeriodStart -> + viewModel.updateLastPeriodStart(it.value) + } + }, + onOvulationMethodChanged = { + viewModel.updateOvulationMethod(it) + }, + onAllowManualOvulationChanged = { + viewModel.updateAllowManualOvulation(it) + }, + onStatusChanged = { + when (it) { + is StatusChange.HormonalContraception -> + viewModel.updateHormonalContraception(it.value) + is StatusChange.Pregnant -> + viewModel.updatePregnancyStatus(it.value) + is StatusChange.Postpartum -> + viewModel.updatePostpartumStatus(it.value) + is StatusChange.Lactating -> + viewModel.updateLactatingStatus(it.value) + is StatusChange.Perimenopause -> + viewModel.updatePerimenopauseStatus(it.value) + } + }, + onHistorySettingChanged = { + when (it) { + is HistorySetting.WindowCycles -> + viewModel.updateHistoryWindow(it.value) + is HistorySetting.ExcludeOutliers -> + viewModel.updateExcludeOutliers(it.value) + } + }, + onSensorSettingChanged = { + when (it) { + is SensorSetting.TempUnit -> + viewModel.updateTemperatureUnit(it.value) + is SensorSetting.BbtTimeWindow -> + viewModel.updateBbtTimeWindow(it.value) + is SensorSetting.Timezone -> + viewModel.updateTimezone(it.value) + } + }, + onNotificationSettingChanged = { + when (it) { + is NotificationSetting.PeriodReminder -> + viewModel.updatePeriodReminderDays(it.value) + is NotificationSetting.OvulationReminder -> + viewModel.updateOvulationReminderDays(it.value) + is NotificationSetting.PmsWindow -> + viewModel.updatePmsWindowDays(it.value) + is NotificationSetting.DeviationAlert -> + viewModel.updateDeviationAlertDays(it.value) + is NotificationSetting.FertileWindowMode -> + viewModel.updateFertileWindowMode(it.value) + } + }, + onCycleAtypicalToggled = { cycleId, atypical -> + viewModel.toggleCycleAtypical(cycleId, atypical) + }, + onExportSettings = { + viewModel.exportSettingsToJson() + }, + onImportSettings = { json -> + viewModel.importSettingsFromJson(json) + }, + onResetExportImportState = { + viewModel.resetExportImportState() + } + ) + } + } + } +} + +/** + * Классы для передачи событий изменения настроек из UI в ViewModel + */ +sealed class BasicSettingChange { + data class BaselineCycleLength(val value: Int) : BasicSettingChange() + data class CycleVariability(val value: Int) : BasicSettingChange() + data class PeriodLength(val value: Int) : BasicSettingChange() + data class LutealPhase(val value: String) : BasicSettingChange() + data class LastPeriodStart(val value: java.time.LocalDate) : BasicSettingChange() +} + +sealed class StatusChange { + data class HormonalContraception(val value: kr.smartsoltech.wellshe.domain.models.HormonalContraceptionType) : StatusChange() + data class Pregnant(val value: Boolean) : StatusChange() + data class Postpartum(val value: Boolean) : StatusChange() + data class Lactating(val value: Boolean) : StatusChange() + data class Perimenopause(val value: Boolean) : StatusChange() +} + +sealed class HistorySetting { + data class WindowCycles(val value: Int) : HistorySetting() + data class ExcludeOutliers(val value: Boolean) : HistorySetting() +} + +sealed class SensorSetting { + data class TempUnit(val value: kr.smartsoltech.wellshe.domain.models.TemperatureUnit) : SensorSetting() + data class BbtTimeWindow(val value: String) : SensorSetting() + data class Timezone(val value: String) : SensorSetting() +} + +sealed class NotificationSetting { + data class PeriodReminder(val value: Int) : NotificationSetting() + data class OvulationReminder(val value: Int) : NotificationSetting() + data class PmsWindow(val value: Int) : NotificationSetting() + data class DeviationAlert(val value: Int) : NotificationSetting() + data class FertileWindowMode(val value: kr.smartsoltech.wellshe.domain.models.FertileWindowMode) : NotificationSetting() +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsViewModel.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsViewModel.kt new file mode 100644 index 0000000..908cc10 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/cycle/settings/CycleSettingsViewModel.kt @@ -0,0 +1,571 @@ +package kr.smartsoltech.wellshe.ui.cycle.settings + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity +import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity +import kr.smartsoltech.wellshe.data.repository.CycleRepository +import kr.smartsoltech.wellshe.domain.models.CycleSettings +import kr.smartsoltech.wellshe.domain.models.FertileWindowMode +import kr.smartsoltech.wellshe.domain.models.HormonalContraceptionType +import kr.smartsoltech.wellshe.domain.models.OvulationMethod +import kr.smartsoltech.wellshe.domain.models.TemperatureUnit +import kr.smartsoltech.wellshe.domain.services.CycleSettingsExportService +import kr.smartsoltech.wellshe.workers.CycleNotificationManager +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +/** + * ViewModel для экрана настроек цикла + */ +@HiltViewModel +class CycleSettingsViewModel @Inject constructor( + private val cycleRepository: CycleRepository, + private val exportService: CycleSettingsExportService, + private val notificationManager: CycleNotificationManager +) : ViewModel() { + + // Текущее состояние настроек + private val _settingsState = MutableStateFlow(null) + val settingsState: StateFlow = _settingsState.asStateFlow() + + // История циклов + private val _cycleHistory = MutableStateFlow>(emptyList()) + val cycleHistory: StateFlow> = _cycleHistory.asStateFlow() + + // Состояние операций экспорта/импорта + private val _exportImportState = MutableStateFlow(ExportImportState.Idle) + val exportImportState: StateFlow = _exportImportState.asStateFlow() + + // Состояние валидации полей + private val _validationState = MutableStateFlow(ValidationState()) + val validationState: StateFlow = _validationState.asStateFlow() + + // Последнее действие для отмены (Undo) + private var lastAction: UndoAction? = null + + // События для UI + private val _uiEvents = MutableLiveData() + val uiEvents: LiveData = _uiEvents + + init { + loadSettings() + loadCycleHistory() + } + + /** + * Загружает настройки цикла из репозитория + */ + private fun loadSettings() { + viewModelScope.launch { + cycleRepository.getSettingsFlow().collect { settings -> + _settingsState.value = settings ?: CycleSettingsEntity() + } + } + } + + /** + * Загружает историю циклов из репозитория + */ + private fun loadCycleHistory() { + viewModelScope.launch { + cycleRepository.getAllHistoryFlow().collect { history -> + _cycleHistory.value = history + } + } + } + + /** + * Обновляет базовую длину цикла + */ + fun updateBaselineCycleLength(length: Int) { + val validatedLength = length.coerceIn(18, 60) + if (validatedLength != length) { + _validationState.value = _validationState.value.copy( + baselineCycleLengthError = "Длина цикла должна быть от 18 до 60 дней" + ) + } else { + _validationState.value = _validationState.value.copy(baselineCycleLengthError = null) + } + + updateSetting { + it.copy(baselineCycleLength = validatedLength) + } + } + + /** + * Обновляет вариабельность цикла + */ + fun updateCycleVariability(days: Int) { + val validatedDays = days.coerceIn(0, 10) + if (validatedDays != days) { + _validationState.value = _validationState.value.copy( + cycleVariabilityError = "Вариабельность должна быть от 0 до 10 дней" + ) + } else { + _validationState.value = _validationState.value.copy(cycleVariabilityError = null) + } + + updateSetting { + it.copy(cycleVariabilityDays = validatedDays) + } + } + + /** + * Обновляет длительность периода + */ + fun updatePeriodLength(days: Int) { + val validatedDays = days.coerceIn(1, 10) + if (validatedDays != days) { + _validationState.value = _validationState.value.copy( + periodLengthError = "Длительность периода должна быть от 1 до 10 дней" + ) + } else { + _validationState.value = _validationState.value.copy(periodLengthError = null) + } + + updateSetting { + it.copy(periodLengthDays = validatedDays) + } + } + + /** + * Обновляет лютеиновую фазу + */ + fun updateLutealPhase(value: String) { + val validatedValue = if (value != "auto") { + try { + val days = value.toInt() + if (days in 8..17) { + _validationState.value = _validationState.value.copy(lutealPhaseError = null) + days.toString() + } else { + _validationState.value = _validationState.value.copy( + lutealPhaseError = "Лютеиновая фаза должна быть от 8 до 17 дней" + ) + value + } + } catch (e: NumberFormatException) { + _validationState.value = _validationState.value.copy( + lutealPhaseError = "Введите число или 'auto'" + ) + value + } + } else { + _validationState.value = _validationState.value.copy(lutealPhaseError = null) + "auto" + } + + updateSetting { + it.copy(lutealPhaseDays = validatedValue) + } + } + + /** + * Обновляет дату последней менструации + */ + fun updateLastPeriodStart(date: LocalDate) { + updateSetting(shouldSaveLastAction = true) { + it.copy(lastPeriodStart = date) + } + } + + /** + * Обновляет метод определения овуляции + */ + fun updateOvulationMethod(method: OvulationMethod) { + updateSetting { + it.copy(ovulationMethod = method.toStorageString()) + } + } + + /** + * Обновляет разрешение на ручное указание овуляции + */ + fun updateAllowManualOvulation(allow: Boolean) { + updateSetting { + it.copy(allowManualOvulation = allow) + } + } + + /** + * Обновляет тип гормональной контрацепции + */ + fun updateHormonalContraception(type: HormonalContraceptionType) { + updateSetting(shouldSaveLastAction = true) { + it.copy(hormonalContraception = type.toStorageString()) + } + } + + /** + * Обновляет статус беременности + */ + fun updatePregnancyStatus(isPregnant: Boolean) { + updateSetting(shouldSaveLastAction = true) { + it.copy(isPregnant = isPregnant) + } + } + + /** + * Обновляет статус послеродового периода + */ + fun updatePostpartumStatus(isPostpartum: Boolean) { + updateSetting(shouldSaveLastAction = true) { + it.copy(isPostpartum = isPostpartum) + } + } + + /** + * Обновляет статус грудного вскармливания + */ + fun updateLactatingStatus(isLactating: Boolean) { + updateSetting(shouldSaveLastAction = true) { + it.copy(isLactating = isLactating) + } + } + + /** + * Обновляет статус перименопаузы + */ + fun updatePerimenopauseStatus(perimenopause: Boolean) { + updateSetting(shouldSaveLastAction = true) { + it.copy(perimenopause = perimenopause) + } + } + + /** + * Обновляет окно истории для расчетов + */ + fun updateHistoryWindow(cycles: Int) { + updateSetting { + it.copy(historyWindowCycles = cycles.coerceIn(2, 12)) + } + } + + /** + * Обновляет исключение выбросов при расчетах + */ + fun updateExcludeOutliers(exclude: Boolean) { + updateSetting { + it.copy(excludeOutliers = exclude) + } + } + + /** + * Обновляет единицы измерения температуры + */ + fun updateTemperatureUnit(unit: TemperatureUnit) { + updateSetting { + it.copy(tempUnit = unit.toStorageString()) + } + } + + /** + * Обновляет временное окно для измерения базальной температуры + */ + fun updateBbtTimeWindow(timeWindow: String) { + // Валидация формата "HH:mm-HH:mm" + val isValid = try { + val parts = timeWindow.split("-") + val formatter = DateTimeFormatter.ofPattern("HH:mm") + formatter.parse(parts[0]) + formatter.parse(parts[1]) + true + } catch (e: Exception) { + false + } + + if (!isValid) { + _validationState.value = _validationState.value.copy( + bbtTimeWindowError = "Формат должен быть ЧЧ:ММ-ЧЧ:ММ" + ) + } else { + _validationState.value = _validationState.value.copy(bbtTimeWindowError = null) + } + + updateSetting { + it.copy(bbtTimeWindow = timeWindow) + } + } + + /** + * Обновляет timezone + */ + fun updateTimezone(timezone: String) { + updateSetting { + it.copy(timezone = timezone) + } + } + + /** + * Обновляет количество дней для напоминания о менструации + */ + fun updatePeriodReminderDays(days: Int) { + updateSetting { + it.copy(periodReminderDaysBefore = days.coerceIn(0, 7)) + } + } + + /** + * Обновляет количество дней для напоминания об овуляции + */ + fun updateOvulationReminderDays(days: Int) { + updateSetting { + it.copy(ovulationReminderDaysBefore = days.coerceIn(0, 7)) + } + } + + /** + * Обновляет окно ПМС + */ + fun updatePmsWindowDays(days: Int) { + updateSetting { + it.copy(pmsWindowDays = days.coerceIn(1, 7)) + } + } + + /** + * Обновляет количество дней для оповещения об отклонении + */ + fun updateDeviationAlertDays(days: Int) { + updateSetting { + it.copy(deviationAlertDays = days.coerceIn(1, 14)) + } + } + + /** + * Обновляет режим фертильного окна + */ + fun updateFertileWindowMode(mode: FertileWindowMode) { + updateSetting { + it.copy(fertileWindowMode = mode.toStorageString()) + } + } + + /** + * Сбрасывает настройки к рекомендуемым значениям по умолчанию + */ + fun resetToDefaults() { + viewModelScope.launch { + // Сохраняем текущие настройки для возможности отмены + val currentSettings = _settingsState.value + lastAction = if (currentSettings != null) { + UndoAction.ResetSettings(currentSettings) + } else { + null + } + + // Сбрасываем все ошибки валидации + _validationState.value = ValidationState() + + // Сбрасываем настройки к рекомендуемым + cycleRepository.resetToDefaults() + + // Отправляем событие для показа снекбара + _uiEvents.value = UiEvent.ShowSnackbar( + message = "Настройки сброшены к рекомендуемым", + actionLabel = "Отменить", + action = { undoLastAction() } + ) + } + } + + /** + * Помечает цикл как атипичный или типичный + */ + fun toggleCycleAtypical(cycleId: Long, atypical: Boolean) { + viewModelScope.launch { + cycleRepository.markCycleAsAtypical(cycleId, atypical) + + // Оповещаем пользователя о действии + val message = if (atypical) { + "Цикл помечен как атипичный" + } else { + "Цикл помечен как типичный" + } + + _uiEvents.value = UiEvent.ShowSnackbar(message = message) + } + } + + /** + * Экспортирует настройки в JSON + */ + fun exportSettingsToJson() { + viewModelScope.launch { + _exportImportState.value = ExportImportState.Loading + + try { + val settings = _settingsState.value ?: CycleSettingsEntity() + val json = exportService.exportSettingsToJson(settings) + _exportImportState.value = ExportImportState.ExportSuccess(json) + } catch (e: Exception) { + _exportImportState.value = ExportImportState.Error("Ошибка экспорта: ${e.message}") + } + } + } + + /** + * Импортирует настройки из JSON + */ + fun importSettingsFromJson(json: String) { + viewModelScope.launch { + _exportImportState.value = ExportImportState.Loading + + try { + val importedSettings = exportService.importSettingsFromJson(json) + if (importedSettings != null) { + // Сохраняем текущие настройки для возможности отмены + val currentSettings = _settingsState.value + if (currentSettings != null) { + lastAction = UndoAction.ImportSettings(currentSettings) + } + + // Сохраняем импортированные настройки + cycleRepository.saveSettings(importedSettings) + + _exportImportState.value = ExportImportState.ImportSuccess + + // Отправляем событие для показа снекбара + _uiEvents.value = UiEvent.ShowSnackbar( + message = "Настройки успешно импортированы", + actionLabel = "Отменить", + action = { undoLastAction() } + ) + } else { + _exportImportState.value = ExportImportState.Error("Некорректный формат JSON") + } + } catch (e: Exception) { + _exportImportState.value = ExportImportState.Error("Ошибка импорта: ${e.message}") + } + } + } + + /** + * Отменяет последнее критичное действие (Undo) + */ + fun undoLastAction() { + viewModelScope.launch { + val action = lastAction + if (action != null) { + when (action) { + is UndoAction.ResetSettings -> { + cycleRepository.saveSettings(action.previousSettings) + _uiEvents.value = UiEvent.ShowSnackbar("Изменения отменены") + } + is UndoAction.ImportSettings -> { + cycleRepository.saveSettings(action.previousSettings) + _uiEvents.value = UiEvent.ShowSnackbar("Импорт отменен") + } + is UndoAction.UpdateCriticalSetting -> { + cycleRepository.saveSettings(action.previousSettings) + _uiEvents.value = UiEvent.ShowSnackbar("Изменение отменено") + } + } + + // Очищаем последнее действие после отмены + lastAction = null + } + } + } + + /** + * Сбрасывает состояние экспорта/импорта + */ + fun resetExportImportState() { + _exportImportState.value = ExportImportState.Idle + } + + /** + * Обновляет настройку цикла и сохраняет её в репозиторий + */ + private fun updateSetting( + shouldSaveLastAction: Boolean = false, + update: (CycleSettingsEntity) -> CycleSettingsEntity + ) { + viewModelScope.launch { + val currentSettings = _settingsState.value ?: CycleSettingsEntity() + + // Сохраняем текущие настройки для критичных изменений + if (shouldSaveLastAction) { + lastAction = UndoAction.UpdateCriticalSetting(currentSettings) + } + + // Обновляем и сохраняем настройки + val updatedSettings = update(currentSettings) + cycleRepository.saveSettings(updatedSettings) + + // Если это критичное изменение, показываем снекбар с возможностью отмены + if (shouldSaveLastAction) { + _uiEvents.value = UiEvent.ShowSnackbar( + message = "Настройки обновлены", + actionLabel = "Отменить", + action = { undoLastAction() } + ) + } + } + } + + /** + * Модель состояния валидации полей + */ + data class ValidationState( + val baselineCycleLengthError: String? = null, + val cycleVariabilityError: String? = null, + val periodLengthError: String? = null, + val lutealPhaseError: String? = null, + val bbtTimeWindowError: String? = null + ) { + /** + * Проверяет, есть ли ошибки валидации + */ + fun hasErrors(): Boolean { + return baselineCycleLengthError != null || + cycleVariabilityError != null || + periodLengthError != null || + lutealPhaseError != null || + bbtTimeWindowError != null + } + } + + /** + * Модель состояния экспорта/импорта + */ + sealed class ExportImportState { + object Idle : ExportImportState() + object Loading : ExportImportState() + data class ExportSuccess(val json: String) : ExportImportState() + object ImportSuccess : ExportImportState() + data class Error(val message: String) : ExportImportState() + } + + /** + * Модель для отмены последнего действия + */ + sealed class UndoAction { + data class ResetSettings(val previousSettings: CycleSettingsEntity) : UndoAction() + data class ImportSettings(val previousSettings: CycleSettingsEntity) : UndoAction() + data class UpdateCriticalSetting(val previousSettings: CycleSettingsEntity) : UndoAction() + } + + /** + * События для UI + */ + sealed class UiEvent { + data class ShowSnackbar( + val message: String, + val actionLabel: String? = null, + val action: (() -> Unit)? = null + ) : UiEvent() + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/dashboard/DashboardScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/dashboard/DashboardScreen.kt index c157ebf..09b840e 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/dashboard/DashboardScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.* import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -23,7 +24,6 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import kr.smartsoltech.wellshe.domain.model.* import kr.smartsoltech.wellshe.ui.theme.* @@ -300,14 +300,11 @@ private fun QuickActionsRow( fontWeight = FontWeight.SemiBold, color = TextPrimary ), - modifier = Modifier.padding(horizontal = 4.dp) + modifier = Modifier.padding(bottom = 12.dp) ) - Spacer(modifier = Modifier.height(12.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 4.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { items(quickActions) { action -> QuickActionCard( @@ -327,7 +324,7 @@ private fun QuickActionCard( ) { Card( modifier = modifier - .width(120.dp) + .width(140.dp) .clickable { onClick() }, elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), colors = CardDefaults.cardColors( @@ -345,14 +342,14 @@ private fun QuickActionCard( imageVector = action.icon, contentDescription = null, tint = action.iconColor, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(32.dp) ) Spacer(modifier = Modifier.height(8.dp)) Text( text = action.title, - style = MaterialTheme.typography.bodySmall.copy( + style = MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.Medium, color = action.textColor ), @@ -695,13 +692,13 @@ private fun getSleepQualityText(quality: SleepQuality): String { private fun getWorkoutIcon(type: WorkoutType): ImageVector { return when (type) { - WorkoutType.CARDIO -> Icons.Default.DirectionsRun + WorkoutType.CARDIO -> Icons.AutoMirrored.Filled.DirectionsRun WorkoutType.STRENGTH -> Icons.Default.FitnessCenter WorkoutType.YOGA -> Icons.Default.SelfImprovement WorkoutType.PILATES -> Icons.Default.SelfImprovement - WorkoutType.RUNNING -> Icons.Default.DirectionsRun - WorkoutType.WALKING -> Icons.Default.DirectionsWalk - WorkoutType.CYCLING -> Icons.Default.DirectionsBike + WorkoutType.RUNNING -> Icons.AutoMirrored.Filled.DirectionsRun + WorkoutType.WALKING -> Icons.AutoMirrored.Filled.DirectionsWalk + WorkoutType.CYCLING -> Icons.AutoMirrored.Filled.DirectionsBike WorkoutType.SWIMMING -> Icons.Default.Pool } } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/dashboard/DashboardViewModel.kt index b7f83f3..78974e0 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/dashboard/DashboardViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity import kr.smartsoltech.wellshe.data.entity.SleepLogEntity @@ -14,6 +15,7 @@ import kr.smartsoltech.wellshe.data.repository.WellSheRepository import kr.smartsoltech.wellshe.domain.model.* import javax.inject.Inject import java.time.LocalDate +import java.time.temporal.ChronoUnit data class DashboardUiState( val user: User = User(), @@ -45,12 +47,16 @@ class DashboardViewModel @Inject constructor( try { // Загружаем данные пользователя - repository.getUserProfile().collect { user -> + repository.getUserProfile().catch { + // Игнорируем ошибки, используем дефолтные данные + }.collect { user: User -> _uiState.value = _uiState.value.copy(user = user) } // Загружаем данные о здоровье - repository.getTodayHealthData().collect { healthEntity -> + repository.getTodayHealthData().catch { + // Игнорируем ошибки, используем дефолтные данные + }.collect { healthEntity: HealthRecordEntity? -> val healthData = healthEntity?.let { convertHealthEntityToModel(it) } ?: HealthData() _uiState.value = _uiState.value.copy(todayHealth = healthData) } @@ -59,13 +65,16 @@ class DashboardViewModel @Inject constructor( loadSleepData() // Загружаем данные о цикле - repository.getCurrentCyclePeriod().collect { cycleEntity -> + repository.getRecentPeriods().let { periods -> + val cycleEntity = periods.firstOrNull() val cycleData = cycleEntity?.let { convertCycleEntityToModel(it) } ?: CycleData() _uiState.value = _uiState.value.copy(cycleData = cycleData) } // Загружаем тренировки - repository.getRecentWorkouts().collect { workoutEntities -> + repository.getRecentWorkouts().catch { + // Игнорируем ошибки + }.collect { workoutEntities: List -> val workouts = workoutEntities.map { convertWorkoutEntityToModel(it) } _uiState.value = _uiState.value.copy(recentWorkouts = workouts) } @@ -93,7 +102,7 @@ class DashboardViewModel @Inject constructor( val sleepEntity = repository.getSleepForDate(yesterday) val sleepData = sleepEntity?.let { convertSleepEntityToModel(it) } ?: SleepData() _uiState.value = _uiState.value.copy(sleepData = sleepData) - } catch (e: Exception) { + } catch (_: Exception) { // Игнорируем ошибки загрузки сна } } @@ -101,10 +110,12 @@ class DashboardViewModel @Inject constructor( private suspend fun loadTodayFitnessData() { try { val today = LocalDate.now() - repository.getFitnessDataForDate(today).collect { fitnessData -> + repository.getFitnessDataForDate(today).catch { + // Игнорируем ошибки + }.collect { fitnessData: FitnessData -> _uiState.value = _uiState.value.copy(todaySteps = fitnessData.steps) } - } catch (e: Exception) { + } catch (_: Exception) { // Игнорируем ошибки загрузки фитнеса } } @@ -112,11 +123,13 @@ class DashboardViewModel @Inject constructor( private suspend fun loadTodayWaterData() { try { val today = LocalDate.now() - repository.getWaterIntakeForDate(today).collect { waterIntakes -> + repository.getWaterIntakeForDate(today).catch { + // Игнорируем ошибки + }.collect { waterIntakes: List -> val totalAmount = waterIntakes.sumOf { it.amount.toDouble() }.toFloat() _uiState.value = _uiState.value.copy(todayWater = totalAmount) } - } catch (e: Exception) { + } catch (_: Exception) { // Игнорируем ошибки загрузки воды } } @@ -135,10 +148,10 @@ class DashboardViewModel @Inject constructor( heartRate = entity.heartRate ?: 70, bloodPressureSystolic = entity.bloodPressureS ?: 120, bloodPressureDiastolic = entity.bloodPressureD ?: 80, - mood = convertMoodStringToEnum(entity.mood), - energyLevel = entity.energyLevel, - stressLevel = entity.stressLevel, - symptoms = entity.symptoms.split(",").filter { it.isNotBlank() } + mood = convertMoodStringToEnum(entity.mood ?: "neutral"), + energyLevel = entity.energyLevel ?: 5, + stressLevel = entity.stressLevel ?: 5, + symptoms = entity.symptoms ?: emptyList() ) } @@ -158,13 +171,13 @@ class DashboardViewModel @Inject constructor( return CycleData( id = entity.id.toString(), userId = "current_user", - cycleLength = entity.cycleLength, + cycleLength = entity.cycleLength ?: 28, periodLength = entity.endDate?.let { - java.time.temporal.ChronoUnit.DAYS.between(entity.startDate, it).toInt() + 1 + ChronoUnit.DAYS.between(entity.startDate, it).toInt() + 1 } ?: 5, lastPeriodDate = entity.startDate, - nextPeriodDate = entity.startDate.plusDays(entity.cycleLength.toLong()), - ovulationDate = entity.startDate.plusDays((entity.cycleLength / 2).toLong()) + nextPeriodDate = entity.startDate.plusDays((entity.cycleLength ?: 28).toLong()), + ovulationDate = entity.startDate.plusDays(((entity.cycleLength ?: 28) / 2).toLong()) ) } @@ -215,14 +228,4 @@ class DashboardViewModel @Inject constructor( else -> WorkoutType.CARDIO } } - - private fun convertWorkoutIntensityStringToEnum(intensity: String): WorkoutIntensity { - return when (intensity.lowercase()) { - "low" -> WorkoutIntensity.LOW - "moderate" -> WorkoutIntensity.MODERATE - "high" -> WorkoutIntensity.HIGH - "intense" -> WorkoutIntensity.INTENSE - else -> WorkoutIntensity.MODERATE - } - } } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/fitness/FitnessViewModel.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/fitness/FitnessViewModel.kt index 7956787..8b653cd 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/fitness/FitnessViewModel.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/fitness/FitnessViewModel.kt @@ -44,7 +44,7 @@ class FitnessViewModel @Inject constructor( val today = LocalDate.now() // Загружаем данные фитнеса за сегодня - repository.getFitnessDataForDate(today).collect { fitnessData -> + repository.getFitnessDataForDate(today).collect { fitnessData: FitnessData -> val calories = calculateCaloriesFromSteps(fitnessData.steps) val distance = calculateDistanceFromSteps(fitnessData.steps) @@ -101,92 +101,64 @@ class FitnessViewModel @Inject constructor( } } + fun toggleStepTracking() { + viewModelScope.launch { + val isTracking = _uiState.value.isTrackingSteps + + if (isTracking) { + repository.stopStepTracking() + } else { + repository.startStepTracking() + } + + _uiState.value = _uiState.value.copy(isTrackingSteps = !isTracking) + } + } + + fun startWorkout(type: String, notes: String = "") { + viewModelScope.launch { + val workout = WorkoutSession( + type = type, + date = LocalDate.now(), + startTime = LocalDateTime.now(), + notes = notes + ) + + repository.startWorkout(workout) + loadFitnessData() // Перезагружаем данные + } + } + + fun endWorkout(workoutId: Long, duration: Int, caloriesBurned: Int, distance: Float = 0f) { + viewModelScope.launch { + repository.endWorkout(workoutId, duration, caloriesBurned, distance) + loadFitnessData() // Перезагружаем данные + } + } + fun startStepTracking() { viewModelScope.launch { try { - _uiState.value = _uiState.value.copy(isTrackingSteps = true) repository.startStepTracking() + _uiState.value = _uiState.value.copy(isTrackingSteps = true) } catch (e: Exception) { _uiState.value = _uiState.value.copy(error = e.message) } } } - fun stopStepTracking() { - viewModelScope.launch { - try { - repository.stopStepTracking() - _uiState.value = _uiState.value.copy(isTrackingSteps = false) - } catch (e: Exception) { - _uiState.value = _uiState.value.copy(error = e.message) - } - } - } - - fun startWorkout(workoutType: String) { - viewModelScope.launch { - try { - val workout = WorkoutSession( - id = 0, - type = workoutType, - date = LocalDate.now(), - startTime = LocalDateTime.now(), - duration = 0, - caloriesBurned = 0, - distance = 0f - ) - - repository.startWorkout(workout) - loadFitnessData() // Перезагружаем данные - - } catch (e: Exception) { - _uiState.value = _uiState.value.copy(error = e.message) - } - } - } - - fun endWorkout(workoutId: Long, duration: Int, caloriesBurned: Int, distance: Float) { - viewModelScope.launch { - try { - repository.endWorkout(workoutId, duration, caloriesBurned, distance) - loadFitnessData() // Перезагружаем данные - - } catch (e: Exception) { - _uiState.value = _uiState.value.copy(error = e.message) - } - } - } - - fun updateSteps(steps: Int) { - viewModelScope.launch { - try { - repository.updateTodaySteps(steps) - val calories = calculateCaloriesFromSteps(steps) - val distance = calculateDistanceFromSteps(steps) - - _uiState.value = _uiState.value.copy( - todaySteps = steps, - caloriesBurned = calories, - distance = distance - ) - - } catch (e: Exception) { - _uiState.value = _uiState.value.copy(error = e.message) - } - } - } - - private fun calculateCaloriesFromSteps(steps: Int): Int { - // Приблизительная формула: 1 шаг = 0.04 калории - return (steps * 0.04).toInt() - } - - private fun calculateDistanceFromSteps(steps: Int): Float { - // Приблизительная формула: 1 шаг = 0.8 метра - return (steps * 0.8f) / 1000f // конвертируем в километры - } - fun clearError() { _uiState.value = _uiState.value.copy(error = null) } + + // Вспомогательные функции для расчетов + private fun calculateCaloriesFromSteps(steps: Int): Int { + // Средний расход калорий: около 0.04 ккал на шаг + return (steps * 0.04).toInt() + } + + private fun calculateDistanceFromSteps(steps: Int): Float { + // Средняя длина шага: около 0.7 метра + return steps * 0.7f / 1000 // Переводим в км + } } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/health/HealthOverviewScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/HealthOverviewScreen.kt index eb08c06..761c5c0 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/health/HealthOverviewScreen.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/HealthOverviewScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush +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 @@ -28,6 +29,9 @@ import java.time.format.DateTimeFormatter @Composable fun HealthOverviewScreen( onNavigateBack: () -> Unit, + onWater: () -> Unit, + onWeight: () -> Unit, + onSport: () -> Unit, modifier: Modifier = Modifier, viewModel: HealthViewModel = hiltViewModel() ) { @@ -43,44 +47,41 @@ fun HealthOverviewScreen( .background( Brush.verticalGradient( colors = listOf( - SuccessGreenLight.copy(alpha = 0.3f), + Color(0xFFFFF0F5), + Color(0xFFFAF0E6), NeutralWhite ) ) ) ) { - TopAppBar( - title = { - Text( - text = "Здоровье", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - color = TextPrimary - ) - ) - }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = "Назад", - tint = TextPrimary - ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color.White, + shadowElevation = 4.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Button(onClick = onWater) { + Icon(Icons.Default.LocalDrink, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Вода") } - }, - actions = { - IconButton(onClick = { viewModel.toggleEditMode() }) { - Icon( - imageVector = if (uiState.isEditMode) Icons.Default.Save else Icons.Default.Edit, - contentDescription = if (uiState.isEditMode) "Сохранить" else "Редактировать", - tint = SuccessGreen - ) + Button(onClick = onWeight) { + Icon(Icons.Default.MonitorWeight, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Вес") } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = NeutralWhite.copy(alpha = 0.95f) - ) - ) + Button(onClick = onSport) { + Icon(Icons.Default.FitnessCenter, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Спорт") + } + } + } LazyColumn( modifier = Modifier diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/health/HealthScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/HealthScreen.kt index 758e871..603677d 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/health/HealthScreen.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/HealthScreen.kt @@ -241,7 +241,19 @@ private fun VitalSignsCard( onValueChange = { systolic = it it.toIntOrNull()?.let { sys -> - val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now()) + val currentRecord = healthRecord ?: HealthRecordEntity( + date = LocalDate.now(), + weight = 0f, + heartRate = 0, + bloodPressureS = 0, + bloodPressureD = 0, + temperature = 36.6f, + mood = "", + energyLevel = 5, + stressLevel = 5, + symptoms = emptyList(), + notes = "" + ) onRecordUpdate(currentRecord.copy(bloodPressureS = sys)) } }, @@ -256,7 +268,19 @@ private fun VitalSignsCard( onValueChange = { diastolic = it it.toIntOrNull()?.let { dia -> - val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now()) + val currentRecord = healthRecord ?: HealthRecordEntity( + date = LocalDate.now(), + weight = 0f, + heartRate = 0, + bloodPressureS = 0, + bloodPressureD = 0, + temperature = 36.6f, + mood = "", + energyLevel = 5, + stressLevel = 5, + symptoms = emptyList(), + notes = "" + ) onRecordUpdate(currentRecord.copy(bloodPressureD = dia)) } }, @@ -274,7 +298,19 @@ private fun VitalSignsCard( onValueChange = { heartRate = it it.toIntOrNull()?.let { hr -> - val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now()) + val currentRecord = healthRecord ?: HealthRecordEntity( + date = LocalDate.now(), + weight = 0f, + heartRate = 0, + bloodPressureS = 0, + bloodPressureD = 0, + temperature = 36.6f, + mood = "", + energyLevel = 5, + stressLevel = 5, + symptoms = emptyList(), + notes = "" + ) onRecordUpdate(currentRecord.copy(heartRate = hr)) } }, @@ -290,7 +326,19 @@ private fun VitalSignsCard( value = notes, onValueChange = { notes = it - val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now()) + val currentRecord = healthRecord ?: HealthRecordEntity( + date = LocalDate.now(), + weight = 0f, + heartRate = 0, + bloodPressureS = 0, + bloodPressureD = 0, + temperature = 36.6f, + mood = "", + energyLevel = 5, + stressLevel = 5, + symptoms = emptyList(), + notes = "" + ) onRecordUpdate(currentRecord.copy(notes = it)) }, label = { Text("Заметки") }, @@ -329,7 +377,7 @@ private fun VitalSignsCard( ) } - if (healthRecord.notes.isNotEmpty()) { + if ((healthRecord.notes ?: "").isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) Card( modifier = Modifier.fillMaxWidth(), @@ -339,7 +387,7 @@ private fun VitalSignsCard( shape = RoundedCornerShape(8.dp) ) { Text( - text = healthRecord.notes, + text = healthRecord.notes ?: "", style = MaterialTheme.typography.bodyMedium.copy( color = TextPrimary ), diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/health/HealthViewModel.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/HealthViewModel.kt index d0568f1..debc9a0 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/health/HealthViewModel.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/HealthViewModel.kt @@ -38,21 +38,29 @@ class HealthViewModel @Inject constructor( try { // Загружаем данные о здоровье за сегодня - repository.getTodayHealthData().collect { todayRecord -> + repository.getTodayHealthData().collect { todayRecord: HealthRecordEntity? -> _uiState.value = _uiState.value.copy( todayRecord = todayRecord, lastUpdateDate = todayRecord?.date, - todaySymptoms = todayRecord?.symptoms?.split(",")?.filter { it.isNotBlank() } ?: emptyList(), + todaySymptoms = todayRecord?.symptoms ?: emptyList(), todayNotes = todayRecord?.notes ?: "", isLoading = false ) } // Загружаем недельные данные веса - loadWeeklyWeights() + repository.getAllHealthRecords().collect { records: List -> + val weightsMap = records + .filter { it.weight != null && it.weight > 0f } + .groupBy { it.date } + .mapValues { entry -> entry.value.last().weight ?: 0f } + _uiState.value = _uiState.value.copy(weeklyWeights = weightsMap) + } // Загружаем последние записи - loadRecentRecords() + repository.getRecentHealthRecords().collect { records: List -> + _uiState.value = _uiState.value.copy(recentRecords = records) + } } catch (e: Exception) { _uiState.value = _uiState.value.copy( @@ -63,26 +71,6 @@ class HealthViewModel @Inject constructor( } } - private suspend fun loadWeeklyWeights() { - try { - // Временная заглушка - методы репозитория пока не реализованы - val weightsMap = emptyMap() - _uiState.value = _uiState.value.copy(weeklyWeights = weightsMap) - } catch (e: Exception) { - // Игнорируем ошибки загрузки весов - } - } - - private suspend fun loadRecentRecords() { - try { - // Временная заглушка - методы репозитория пока не реализованы - val records = emptyList() - _uiState.value = _uiState.value.copy(recentRecords = records) - } catch (e: Exception) { - // Игнорируем ошибки загрузки записей - } - } - fun updateVitals(weight: Float?, heartRate: Int?, bpSystolic: Int?, bpDiastolic: Int?, temperature: Float?) { viewModelScope.launch { try { @@ -102,13 +90,15 @@ class HealthViewModel @Inject constructor( heartRate = heartRate, bloodPressureS = bpSystolic, bloodPressureD = bpDiastolic, - temperature = temperature + temperature = temperature, + mood = "", + energyLevel = 5, + stressLevel = 5, + symptoms = emptyList(), + notes = "" ) } - - // Временная заглушка - метод saveHealthRecord пока не реализован - // repository.saveHealthRecord(updatedRecord) - + repository.saveHealthRecord(updatedRecord) } catch (e: Exception) { _uiState.value = _uiState.value.copy(error = e.message) } @@ -124,13 +114,19 @@ class HealthViewModel @Inject constructor( } else { HealthRecordEntity( date = LocalDate.now(), - mood = mood + weight = 0f, + heartRate = 0, + bloodPressureS = 0, + bloodPressureD = 0, + temperature = 36.6f, + mood = mood, + energyLevel = 5, + stressLevel = 5, + symptoms = emptyList(), + notes = "" ) } - - // Временная заглушка - метод saveHealthRecord пока не реализован - // repository.saveHealthRecord(updatedRecord) - + repository.saveHealthRecord(updatedRecord) } catch (e: Exception) { _uiState.value = _uiState.value.copy(error = e.message) } @@ -146,13 +142,19 @@ class HealthViewModel @Inject constructor( } else { HealthRecordEntity( date = LocalDate.now(), - energyLevel = energy + weight = 0f, + heartRate = 0, + bloodPressureS = 0, + bloodPressureD = 0, + temperature = 36.6f, + mood = "", + energyLevel = energy, + stressLevel = 5, + symptoms = emptyList(), + notes = "" ) } - - // Временная заглушка - метод saveHealthRecord пока не реализован - // repository.saveHealthRecord(updatedRecord) - + repository.saveHealthRecord(updatedRecord) } catch (e: Exception) { _uiState.value = _uiState.value.copy(error = e.message) } @@ -168,13 +170,19 @@ class HealthViewModel @Inject constructor( } else { HealthRecordEntity( date = LocalDate.now(), - stressLevel = stress + weight = 0f, + heartRate = 0, + bloodPressureS = 0, + bloodPressureD = 0, + temperature = 36.6f, + mood = "", + energyLevel = 5, + stressLevel = stress, + symptoms = emptyList(), + notes = "" ) } - - // Временная заглушка - метод saveHealthRecord пока не реализован - // repository.saveHealthRecord(updatedRecord) - + repository.saveHealthRecord(updatedRecord) } catch (e: Exception) { _uiState.value = _uiState.value.copy(error = e.message) } @@ -183,23 +191,27 @@ class HealthViewModel @Inject constructor( fun updateSymptoms(symptoms: List) { _uiState.value = _uiState.value.copy(todaySymptoms = symptoms) - viewModelScope.launch { try { val currentRecord = _uiState.value.todayRecord - val symptomsString = symptoms.joinToString(",") val updatedRecord = if (currentRecord != null) { - currentRecord.copy(symptoms = symptomsString) + currentRecord.copy(symptoms = symptoms) } else { HealthRecordEntity( date = LocalDate.now(), - symptoms = symptomsString + weight = 0f, + heartRate = 0, + bloodPressureS = 0, + bloodPressureD = 0, + temperature = 36.6f, + mood = "", + energyLevel = 5, + stressLevel = 5, + symptoms = symptoms, + notes = "" ) } - - // Временная заглушка - метод saveHealthRecord пока не реализован - // repository.saveHealthRecord(updatedRecord) - + repository.saveHealthRecord(updatedRecord) } catch (e: Exception) { _uiState.value = _uiState.value.copy(error = e.message) } @@ -208,7 +220,6 @@ class HealthViewModel @Inject constructor( fun updateNotes(notes: String) { _uiState.value = _uiState.value.copy(todayNotes = notes) - viewModelScope.launch { try { val currentRecord = _uiState.value.todayRecord @@ -217,13 +228,19 @@ class HealthViewModel @Inject constructor( } else { HealthRecordEntity( date = LocalDate.now(), + weight = 0f, + heartRate = 0, + bloodPressureS = 0, + bloodPressureD = 0, + temperature = 36.6f, + mood = "", + energyLevel = 5, + stressLevel = 5, + symptoms = emptyList(), notes = notes ) } - - // Временная заглушка - метод saveHealthRecord пока не реализован - // repository.saveHealthRecord(updatedRecord) - + repository.saveHealthRecord(updatedRecord) } catch (e: Exception) { _uiState.value = _uiState.value.copy(error = e.message) } @@ -233,8 +250,7 @@ class HealthViewModel @Inject constructor( fun deleteHealthRecord(record: HealthRecordEntity) { viewModelScope.launch { try { - // Временная заглушка - метод deleteHealthRecord пока не реализован - // repository.deleteHealthRecord(record.id) + repository.deleteHealthRecord(record.id) } catch (e: Exception) { _uiState.value = _uiState.value.copy(error = e.message) } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/health/SportScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/SportScreen.kt new file mode 100644 index 0000000..06af223 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/SportScreen.kt @@ -0,0 +1,77 @@ +package kr.smartsoltech.wellshe.ui.health + +import androidx.compose.runtime.Composable +import androidx.compose.material3.* +import androidx.compose.foundation.layout.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.LaunchedEffect + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SportScreen(onBack: () -> Unit, viewModel: SportViewModel = hiltViewModel()) { + val exercises by viewModel.exercises.collectAsState() + val sessions by viewModel.sessions.collectAsState() + val activeSessionId by viewModel.activeSessionId.collectAsState() + val (search, setSearch) = remember { mutableStateOf("") } + val (selectedExercise, setSelectedExercise) = remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { viewModel.loadExercises() ; viewModel.loadSessions() } + + Scaffold( + topBar = { + TopAppBar(title = { Text("Тренировки и спорт") }, navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = null) } + }) + } + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + OutlinedTextField( + value = search, + onValueChange = { + setSearch(it) + viewModel.loadExercises(it) + }, + label = { Text("Поиск упражнения") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(8.dp)) + Text("Выберите упражнение:", style = MaterialTheme.typography.titleSmall) + exercises.forEach { ex -> + Row(Modifier.fillMaxWidth().clickable { setSelectedExercise(ex.id) }) { + RadioButton(selected = selectedExercise == ex.id, onClick = { setSelectedExercise(ex.id) }) + Text(ex.name, Modifier.padding(start = 8.dp)) + } + } + Spacer(Modifier.height(16.dp)) + if (activeSessionId == null && selectedExercise != null) { + Button(onClick = { viewModel.startSession(selectedExercise!!) }, modifier = Modifier.fillMaxWidth()) { + Text("Старт тренировки") + } + } + if (activeSessionId != null) { + Button(onClick = { viewModel.stopSession() }, modifier = Modifier.fillMaxWidth()) { + Text("Стоп тренировки") + } + // TODO: таймер, параметры, онлайн-расчёт калорий + } + Spacer(Modifier.height(24.dp)) + Text("История тренировок за неделю", style = MaterialTheme.typography.titleSmall) + sessions.forEach { session -> + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(session.startedAt.toString(), Modifier.weight(1f)) + Text("${session.kcalTotal?.let { String.format("%.0f ккал", it) } ?: "—"}", Modifier.weight(1f)) + Text("${session.distanceKm?.let { String.format("%.2f км", it) } ?: "—"}", Modifier.weight(1f)) + } + } + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/health/SportViewModel.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/SportViewModel.kt new file mode 100644 index 0000000..4b1eb5e --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/SportViewModel.kt @@ -0,0 +1,57 @@ +package kr.smartsoltech.wellshe.ui.health + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kr.smartsoltech.wellshe.data.repo.WorkoutService +import kr.smartsoltech.wellshe.data.entity.Exercise +import kr.smartsoltech.wellshe.data.entity.WorkoutSession +import javax.inject.Inject + +@HiltViewModel +class SportViewModel @Inject constructor( + private val workoutService: WorkoutService +) : ViewModel() { + private val _exercises = MutableStateFlow>(emptyList()) + val exercises: StateFlow> = _exercises + + private val _sessions = MutableStateFlow>(emptyList()) + val sessions: StateFlow> = _sessions + + private val _activeSessionId = MutableStateFlow(null) + val activeSessionId: StateFlow = _activeSessionId + + fun loadExercises(query: String = "") { + viewModelScope.launch { + _exercises.value = workoutService.searchExercises(query) + } + } + + fun startSession(exerciseId: Long) { + viewModelScope.launch { + val sessionId = workoutService.startSession(exerciseId) + _activeSessionId.value = sessionId + loadSessions() + } + } + + fun stopSession() { + viewModelScope.launch { + val sessionId = _activeSessionId.value + if (sessionId != null) { + workoutService.stopSession(sessionId) + } + _activeSessionId.value = null + loadSessions() + } + } + + fun loadSessions() { + viewModelScope.launch { + _sessions.value = workoutService.getSessions(days = 7) + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/health/WaterScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/WaterScreen.kt new file mode 100644 index 0000000..81f6b99 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/WaterScreen.kt @@ -0,0 +1,64 @@ +package kr.smartsoltech.wellshe.ui.health + +import androidx.compose.runtime.Composable +import androidx.compose.material3.* +import androidx.compose.foundation.layout.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.foundation.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WaterScreen(onBack: () -> Unit, viewModel: WaterViewModel = hiltViewModel()) { + val waterToday by viewModel.waterToday.collectAsState() + val dailyGoal by viewModel.dailyGoal.collectAsState() + val waterHistory by viewModel.waterHistory.collectAsState() + + LaunchedEffect(Unit) { viewModel.loadHistory() } + + Scaffold( + topBar = { + TopAppBar(title = { Text("Вода и напитки") }, navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = null) } + }) + } + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + Text("Быстрые кнопки объёмов воды и напитков", style = MaterialTheme.typography.titleMedium) + Row(Modifier.padding(vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + listOf(200, 300, 500).forEach { ml -> + Button(onClick = { viewModel.logWater(ml) }) { + Text("+${ml} мл") + } + } + } + Spacer(Modifier.height(16.dp)) + Text("Сегодня: $waterToday мл / $dailyGoal мл", style = MaterialTheme.typography.bodyLarge) + LinearProgressIndicator( + progress = (waterToday / dailyGoal.toFloat()).coerceIn(0f, 1f), + modifier = Modifier.fillMaxWidth().height(12.dp), + color = Color(0xFF42A5F5) + ) + Spacer(Modifier.height(24.dp)) + Text("График за неделю", style = MaterialTheme.typography.titleSmall) + Canvas(modifier = Modifier.fillMaxWidth().height(120.dp)) { + val max = (waterHistory.maxOrNull() ?: 1).toFloat() + val barWidth = size.width / (waterHistory.size.coerceAtLeast(1)) + waterHistory.forEachIndexed { i, v -> + drawRect( + color = Color(0xFF42A5F5), + topLeft = androidx.compose.ui.geometry.Offset(i * barWidth, size.height * (1 - v / max)), + size = androidx.compose.ui.geometry.Size(barWidth * 0.7f, size.height * (v / max)) + ) + } + } + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/health/WaterViewModel.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/WaterViewModel.kt new file mode 100644 index 0000000..b242502 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/WaterViewModel.kt @@ -0,0 +1,45 @@ +package kr.smartsoltech.wellshe.ui.health + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kr.smartsoltech.wellshe.data.repo.DrinkLogger +import kotlinx.coroutines.flow.update +import java.time.Instant +import javax.inject.Inject + +@HiltViewModel +class WaterViewModel @Inject constructor( + private val drinkLogger: DrinkLogger +) : ViewModel() { + private val _waterToday = MutableStateFlow(0) + val waterToday: StateFlow = _waterToday + + private val _waterHistory = MutableStateFlow>(emptyList()) + val waterHistory: StateFlow> = _waterHistory + + private val _dailyGoal = MutableStateFlow(2000) // по умолчанию 2 литра + val dailyGoal: StateFlow = _dailyGoal + + fun setDailyGoal(goal: Int) { _dailyGoal.value = goal } + + fun logWater(volumeMl: Int) { + viewModelScope.launch { + drinkLogger.logWater(ts = Instant.now(), volumeMl = volumeMl) + loadHistory() + } + } + + fun loadHistory() { + viewModelScope.launch { + val history = drinkLogger.getWaterHistory(days = 7) + _waterHistory.value = history + _waterToday.value = history.lastOrNull() ?: 0 + } + } + + // TODO: прогресс-бар +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/health/WeightScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/WeightScreen.kt new file mode 100644 index 0000000..8b4d4f1 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/WeightScreen.kt @@ -0,0 +1,71 @@ +package kr.smartsoltech.wellshe.ui.health + +import androidx.compose.runtime.Composable +import androidx.compose.material3.* +import androidx.compose.foundation.layout.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.foundation.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WeightScreen(onBack: () -> Unit, viewModel: WeightViewModel = hiltViewModel()) { + val weightToday by viewModel.weightToday.collectAsState() + val weightHistory by viewModel.weightHistory.collectAsState() + val (inputWeight, setInputWeight) = remember { mutableStateOf("") } + + LaunchedEffect(Unit) { viewModel.loadHistory() } + + Scaffold( + topBar = { + TopAppBar(title = { Text("Контроль веса") }, navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = null) } + }) + } + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + Text("Последний вес: ${weightToday?.let { String.format("%.1f", it) } ?: "—"} кг", style = MaterialTheme.typography.titleMedium) + Row(Modifier.padding(vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = inputWeight, + onValueChange = setInputWeight, + label = { Text("Новый вес (кг)") }, + modifier = Modifier.weight(1f) + ) + Button(onClick = { + inputWeight.toFloatOrNull()?.let { + viewModel.addWeight(it) + setInputWeight("") + } + }) { + Text("Сохранить") + } + } + Spacer(Modifier.height(16.dp)) + Text("График веса за неделю", style = MaterialTheme.typography.titleSmall) + Canvas(modifier = Modifier.fillMaxWidth().height(120.dp)) { + val max = (weightHistory.maxOfOrNull { it.second } ?: 1f) + val min = (weightHistory.minOfOrNull { it.second } ?: 0f) + val barWidth = size.width / (weightHistory.size.coerceAtLeast(1)) + weightHistory.forEachIndexed { i, pair -> + val v = pair.second + val norm = if (max > min) (v - min) / (max - min) else 0.5f + drawRect( + color = Color(0xFFAB47BC), + topLeft = androidx.compose.ui.geometry.Offset(i * barWidth, size.height * (1 - norm)), + size = androidx.compose.ui.geometry.Size(barWidth * 0.7f, size.height * norm) + ) + } + } + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/health/WeightViewModel.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/WeightViewModel.kt new file mode 100644 index 0000000..729fc7f --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/health/WeightViewModel.kt @@ -0,0 +1,37 @@ +package kr.smartsoltech.wellshe.ui.health + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kr.smartsoltech.wellshe.data.repo.WeightRepository +import java.time.Instant +import javax.inject.Inject + +@HiltViewModel +class WeightViewModel @Inject constructor( + private val weightRepository: WeightRepository +) : ViewModel() { + private val _weightToday = MutableStateFlow(null) + val weightToday: StateFlow = _weightToday + + private val _weightHistory = MutableStateFlow>>(emptyList()) + val weightHistory: StateFlow>> = _weightHistory + + fun addWeight(kg: Float) { + viewModelScope.launch { + weightRepository.addWeight(ts = Instant.now(), kg = kg) + loadHistory() + } + } + + fun loadHistory() { + viewModelScope.launch { + val history = weightRepository.getWeightHistory(days = 7) + _weightHistory.value = history + _weightToday.value = history.lastOrNull()?.second + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/mood/MoodScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/mood/MoodScreen.kt new file mode 100644 index 0000000..0bf2401 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/mood/MoodScreen.kt @@ -0,0 +1,258 @@ +package kr.smartsoltech.wellshe.ui.mood + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.ModeNight +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kr.smartsoltech.wellshe.ui.components.InfoCard +import kr.smartsoltech.wellshe.ui.components.StatCard +import kr.smartsoltech.wellshe.ui.theme.MoodTabColor +import kr.smartsoltech.wellshe.ui.theme.WellSheTheme + +/** + * Экран "Настроение" для отслеживания сна и эмоционального состояния + */ +@Composable +fun MoodScreen( + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Статистические карточки + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + StatCard( + title = "Сон", + value = "7.2 ч", + tone = Color(0xFF673AB7), // Фиолетовый для сна + modifier = Modifier.weight(1f) + ) + + StatCard( + title = "Стресс", + value = "3/10", + tone = Color(0xFFE91E63), // Розовый для стресса + modifier = Modifier.weight(1f) + ) + } + + // Карточка дневника + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = CardDefaults.cardColors( + containerColor = MoodTabColor.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Заголовок + Text( + text = "Дневник", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + + // Содержимое дневника + Text( + text = "Сегодня было продуктивно, немного тревоги перед встречей. Выполнила все запланированные задачи, чувствую удовлетворение от проделанной работы.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Кнопки действий + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = { /* TODO */ }) { + Text("Редактировать") + } + + TextButton(onClick = { /* TODO */ }) { + Text("Добавить запись") + } + } + } + } + + // Карточка сна + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Заголовок с иконкой + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.ModeNight, + contentDescription = null, + tint = Color(0xFF673AB7) + ) + Text( + text = "Качество сна", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + + // Оценка сна + Column( + modifier = Modifier.padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Продолжительность") + Text("7.2 часа", fontWeight = FontWeight.SemiBold) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Качество") + Text("Хорошее", fontWeight = FontWeight.SemiBold) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Пробуждения") + Text("1 раз", fontWeight = FontWeight.SemiBold) + } + } + + // Кнопка добавления записи + OutlinedButton( + onClick = { /* TODO */ }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Записать сон") + } + } + } + + // Карточка эмоций + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Заголовок с иконкой + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = null, + tint = Color(0xFFE91E63) + ) + Text( + text = "Эмоциональное состояние", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + + // Текущее настроение + Text( + text = "Текущее настроение: Спокойствие, удовлетворение", + style = MaterialTheme.typography.bodyMedium + ) + + // Кнопки эмоций + EmojiButtonsRow() + } + } + + // Карточка рекомендаций + InfoCard( + title = "Рекомендации", + content = "Стабильный сон и низкий уровень стресса положительно влияют на ваш цикл. Рекомендуется поддерживать текущий режим для гормонального баланса." + ) + } +} + +/** + * Строка кнопок с эмодзи для выбора эмоций + */ +@Composable +fun EmojiButtonsRow() { + val emojis = listOf("😊", "😌", "🙂", "😐", "😔", "😢", "😡") + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + emojis.forEach { emoji -> + OutlinedButton( + onClick = { /* TODO */ }, + contentPadding = PaddingValues(12.dp), + modifier = Modifier.size(44.dp), + shape = MaterialTheme.shapes.medium, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Text( + text = emoji, + style = MaterialTheme.typography.titleMedium + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MoodScreenPreview() { + WellSheTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + MoodScreen() + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/mood/MoodViewModel.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/mood/MoodViewModel.kt new file mode 100644 index 0000000..ef4255d --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/mood/MoodViewModel.kt @@ -0,0 +1,33 @@ +package kr.smartsoltech.wellshe.ui.mood + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@HiltViewModel +class MoodViewModel @Inject constructor() : ViewModel() { + // Данные для экрана настроения + private val _sleepHours = MutableStateFlow(7.2f) + val sleepHours: StateFlow = _sleepHours.asStateFlow() + + private val _stressLevel = MutableStateFlow(3) + val stressLevel: StateFlow = _stressLevel.asStateFlow() + + private val _journalEntry = MutableStateFlow("Сегодня было продуктивно, немного тревоги перед встречей.") + val journalEntry: StateFlow = _journalEntry.asStateFlow() + + fun updateSleepHours(hours: Float) { + _sleepHours.value = hours + } + + fun updateStressLevel(level: Int) { + _stressLevel.value = level + } + + fun updateJournalEntry(entry: String) { + _journalEntry.value = entry + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/AppNavGraph.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/AppNavGraph.kt new file mode 100644 index 0000000..220c695 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/AppNavGraph.kt @@ -0,0 +1,42 @@ +package kr.smartsoltech.wellshe.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import kr.smartsoltech.wellshe.ui.analytics.AnalyticsScreen +import kr.smartsoltech.wellshe.ui.body.BodyScreen +import kr.smartsoltech.wellshe.ui.cycle.CycleScreen +import kr.smartsoltech.wellshe.ui.mood.MoodScreen +import kr.smartsoltech.wellshe.ui.profile.ProfileScreen + +@Composable +fun AppNavGraph( + navController: NavHostController, + startDestination: String = BottomNavItem.Cycle.route +) { + NavHost( + navController = navController, + startDestination = startDestination + ) { + composable(BottomNavItem.Cycle.route) { + CycleScreen() + } + + composable(BottomNavItem.Body.route) { + BodyScreen() + } + + composable(BottomNavItem.Mood.route) { + MoodScreen() + } + + composable(BottomNavItem.Analytics.route) { + AnalyticsScreen() + } + + composable(BottomNavItem.Profile.route) { + ProfileScreen() + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/BottomNavItem.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/BottomNavItem.kt new file mode 100644 index 0000000..516f330 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/BottomNavItem.kt @@ -0,0 +1,52 @@ +package kr.smartsoltech.wellshe.ui.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BarChart +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.WaterDrop +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Модель навигационного элемента для нижней панели навигац��и + */ +sealed class BottomNavItem( + val route: String, + val title: String, + val icon: ImageVector +) { + data object Cycle : BottomNavItem( + route = "cycle", + title = "Цикл", + icon = Icons.Default.WbSunny + ) + + data object Body : BottomNavItem( + route = "body", + title = "Тело", + icon = Icons.Default.WaterDrop + ) + + data object Mood : BottomNavItem( + route = "mood", + title = "Настроение", + icon = Icons.Default.Favorite + ) + + data object Analytics : BottomNavItem( + route = "analytics", + title = "Аналитика", + icon = Icons.Default.BarChart + ) + + data object Profile : BottomNavItem( + route = "profile", + title = "Профиль", + icon = Icons.Default.Person + ) + + companion object { + val items = listOf(Cycle, Body, Mood, Analytics, Profile) + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/BottomNavigation.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/BottomNavigation.kt new file mode 100644 index 0000000..e4794ca --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/BottomNavigation.kt @@ -0,0 +1,117 @@ +package kr.smartsoltech.wellshe.ui.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Analytics +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.currentBackStackEntryAsState +import kr.smartsoltech.wellshe.ui.theme.* + +@Composable +fun BottomNavigation( + navController: NavController, + modifier: Modifier = Modifier +) { + NavigationBar( + modifier = modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.background, + tonalElevation = 8.dp + ) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + val items = listOf( + BottomNavItem.Cycle, + BottomNavItem.Body, + BottomNavItem.Mood, + BottomNavItem.Analytics, + BottomNavItem.Profile + ) + + items.forEach { item -> + val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true + + // Определяем цвет фона для выбранного элемента + val backgroundColor = when (item) { + BottomNavItem.Cycle -> CycleTabColor + BottomNavItem.Body -> BodyTabColor + BottomNavItem.Mood -> MoodTabColor + BottomNavItem.Analytics -> AnalyticsTabColor + BottomNavItem.Profile -> ProfileTabColor + } + + NavigationBarItem( + icon = { + if (selected) { + Icon( + imageVector = item.icon, + contentDescription = item.title, + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(backgroundColor) + .padding(2.dp) + ) + } else { + Icon( + imageVector = item.icon, + contentDescription = item.title, + modifier = Modifier.size(24.dp) + ) + } + }, + label = { + Text( + text = item.title, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center + ) + }, + selected = selected, + onClick = { + navController.navigate(item.route) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSurface, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/Navigation.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/Navigation.kt deleted file mode 100644 index 9ee6c21..0000000 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/Navigation.kt +++ /dev/null @@ -1,176 +0,0 @@ -package kr.smartsoltech.wellshe.ui.navigation - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp -import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import kr.smartsoltech.wellshe.ui.dashboard.DashboardScreen -import kr.smartsoltech.wellshe.ui.cycle.CycleScreen -import kr.smartsoltech.wellshe.ui.workouts.WorkoutsScreen -import kr.smartsoltech.wellshe.ui.profile.ProfileScreen -import kr.smartsoltech.wellshe.ui.settings.SettingsScreen -import kr.smartsoltech.wellshe.ui.health.HealthOverviewScreen -import kr.smartsoltech.wellshe.ui.sleep.SleepTrackingScreen -import kr.smartsoltech.wellshe.ui.theme.* - -sealed class Screen(val route: String, val title: String, val icon: ImageVector) { - object Dashboard : Screen("dashboard", "Главная", Icons.Default.Home) - object Cycle : Screen("cycle", "Цикл", Icons.Default.Favorite) - object Workouts : Screen("workouts", "Тренировки", Icons.Default.FitnessCenter) - object Health : Screen("health", "Здоровье", Icons.Default.HealthAndSafety) - object Profile : Screen("profile", "Профиль", Icons.Default.Person) - - // Дополнительные экраны без навигации в нижнем меню - object Settings : Screen("settings", "Настройки", Icons.Default.Settings) - object Sleep : Screen("sleep", "Сон", Icons.Default.Bedtime) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun WellSheNavigation() { - val navController = rememberNavController() - - Scaffold( - bottomBar = { - BottomNavigationBar( - navController = navController, - onNavigate = { route -> - navController.navigate(route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } - ) - } - ) { innerPadding -> - NavHost( - navController = navController, - startDestination = Screen.Dashboard.route, - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) { - composable(Screen.Dashboard.route) { - DashboardScreen( - onNavigate = { route -> navController.navigate(route) } - ) - } - composable(Screen.Cycle.route) { - CycleScreen( - onNavigateBack = { navController.popBackStack() } - ) - } - composable(Screen.Workouts.route) { - WorkoutsScreen( - onNavigateBack = { navController.popBackStack() } - ) - } - composable(Screen.Health.route) { - HealthOverviewScreen( - onNavigateBack = { navController.popBackStack() } - ) - } - composable(Screen.Profile.route) { - ProfileScreen( - onNavigateBack = { navController.popBackStack() } - ) - } - composable(Screen.Settings.route) { - SettingsScreen( - onNavigateBack = { navController.popBackStack() } - ) - } - composable(Screen.Sleep.route) { - SleepTrackingScreen( - onBackClick = { navController.popBackStack() } - ) - } - } - } -} - -@Composable -private fun BottomNavigationBar( - navController: androidx.navigation.NavController, - onNavigate: (String) -> Unit -) { - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - - NavigationBar( - containerColor = NeutralWhite, - tonalElevation = 8.dp - ) { - bottomNavItems.forEach { item -> - NavigationBarItem( - icon = { - Icon( - imageVector = item.icon, - contentDescription = item.title, - tint = if (currentDestination?.hierarchy?.any { it.route == item.route } == true) - PrimaryPink else NeutralGray - ) - }, - label = { - Text( - item.title, - color = if (currentDestination?.hierarchy?.any { it.route == item.route } == true) - PrimaryPink else NeutralGray - ) - }, - selected = currentDestination?.hierarchy?.any { it.route == item.route } == true, - onClick = { - onNavigate(item.route) - }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = PrimaryPink, - selectedTextColor = PrimaryPink, - indicatorColor = PrimaryPinkLight - ) - ) - } - } -} - -private data class BottomNavItem( - val title: String, - val icon: ImageVector, - val route: String -) - -private val bottomNavItems = listOf( - BottomNavItem( - title = "Главная", - icon = Icons.Default.Home, - route = Screen.Dashboard.route - ), - BottomNavItem( - title = "Цикл", - icon = Icons.Default.CalendarMonth, - route = Screen.Cycle.route - ), - BottomNavItem( - title = "Здоровье", - icon = Icons.Default.Favorite, - route = Screen.Health.route - ), - BottomNavItem( - title = "Профиль", - icon = Icons.Default.Person, - route = Screen.Profile.route - ) -) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/WellSheNavigation.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/WellSheNavigation.kt new file mode 100644 index 0000000..639abc8 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/navigation/WellSheNavigation.kt @@ -0,0 +1,43 @@ +package kr.smartsoltech.wellshe.ui.navigation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.compose.rememberNavController +import androidx.compose.ui.tooling.preview.Preview +import kr.smartsoltech.wellshe.ui.theme.WellSheTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WellSheNavigation() { + val navController = rememberNavController() + + Scaffold( + bottomBar = { + BottomNavigation(navController = navController) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + AppNavGraph(navController = navController) + } + } +} + +@Preview(showBackground = true) +@Composable +fun WellSheNavigationPreview() { + WellSheTheme { + Surface { + WellSheNavigation() + } + } +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/profile/ProfileScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/profile/ProfileScreen.kt index fb4b107..acb141f 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/profile/ProfileScreen.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/profile/ProfileScreen.kt @@ -4,164 +4,355 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import kr.smartsoltech.wellshe.ui.components.InfoCard +import kr.smartsoltech.wellshe.ui.components.ToggleRow +import kr.smartsoltech.wellshe.ui.theme.ProfileTabColor +import kr.smartsoltech.wellshe.ui.theme.WellSheTheme +/** + * Экран "Профиль" с настройками пользователя + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ProfileScreen( - onNavigateBack: () -> Unit, - viewModel: ProfileViewModel = hiltViewModel() + modifier: Modifier = Modifier ) { - val uiState by viewModel.uiState.collectAsState() + val scrollState = rememberScrollState() - Scaffold( - topBar = { - TopAppBar( - title = { Text("Профиль") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.Filled.ArrowBack, contentDescription = "Назад") - } - } + // Состояния для различных настроек + var units by remember { + mutableStateOf( + mapOf( + "weight" to "кг", + "speed" to "км/ч", + "temp" to "°C" + ) + ) + } + + var goals by remember { + mutableStateOf( + mapOf( + "water" to 2000 + ) + ) + } + + var privacy by remember { + mutableStateOf( + mapOf( + "biometrics" to true, + "analytics" to true + ) + ) + } + + var integrations by remember { + mutableStateOf( + listOf( + Integration("Google Fit", false), + Integration("FatSecret Proxy", true), + Integration("Wear OS", false) + ) + ) + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Заголовок + Text( + text = "Профиль", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + + // Карточка целей и единиц измерения + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = CardDefaults.cardColors( + containerColor = ProfileTabColor.copy(alpha = 0.3f) ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) ) { - if (uiState.isLoading) { - Box( + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Цели и единицы измерения", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + // Цель воды + Row( modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - CircularProgressIndicator() + Text( + text = "Цель воды (мл)", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + var waterGoal by remember { mutableStateOf(goals["water"].toString()) } + + OutlinedTextField( + value = waterGoal, + onValueChange = { + waterGoal = it + goals = goals.toMutableMap().apply { + this["water"] = waterGoal.toIntOrNull() ?: 2000 + } + }, + modifier = Modifier.width(100.dp), + textStyle = MaterialTheme.typography.bodyMedium, + singleLine = true, + colors = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) } - } else { - ProfileContent( - user = uiState.user, - onUpdateProfile = { user -> - viewModel.updateProfile(user) + + // Единицы веса + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Вес", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + UnitSelector( + options = listOf("кг", "lb"), + selectedOption = units["weight"] ?: "кг", + onOptionSelected = { units = units.toMutableMap().apply { this["weight"] = it } } + ) + } + + // Единицы скорости + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Скорость", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + UnitSelector( + options = listOf("км/ч", "mph"), + selectedOption = units["speed"] ?: "км/ч", + onOptionSelected = { units = units.toMutableMap().apply { this["speed"] = it } } + ) + } + + // Единицы температуры + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Температура", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + UnitSelector( + options = listOf("°C", "°F"), + selectedOption = units["temp"] ?: "°C", + onOptionSelected = { units = units.toMutableMap().apply { this["temp"] = it } } + ) + } + } + } + + // Карточка уведомлений цикла + InfoCard( + title = "Уведомления цикла", + content = "Управляются в разделе Настройки → Цикл." + ) + + // Карточка приватности и безопасности + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Приватность и безопасность", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + ToggleRow( + label = "Блокировка по биометрии", + checked = privacy["biometrics"] ?: false, + onCheckedChange = { privacy = privacy.toMutableMap().apply { this["biometrics"] = it } } + ) + + ToggleRow( + label = "Анонимная аналитика", + checked = privacy["analytics"] ?: false, + onCheckedChange = { privacy = privacy.toMutableMap().apply { this["analytics"] = it } } + ) + } + } + + // Карточка интеграций + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Интеграции", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + integrations.forEachIndexed { index, integration -> + ToggleRow( + label = integration.title, + checked = integration.enabled, + onCheckedChange = { isEnabled -> + integrations = integrations.toMutableList().apply { + this[index] = Integration(integration.title, isEnabled) + } + } + ) + } + } + } + + // Кнопки действий + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { /* TODO */ }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Сохранить") + } + + OutlinedButton( + onClick = { /* TODO */ }, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Экспорт настроек") + } + } + } +} + +/** + * Селектор единиц измерения + */ +@Composable +fun UnitSelector( + options: List, + selectedOption: String, + onOptionSelected: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Box { + OutlinedButton( + onClick = { expanded = true }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Text(selectedOption) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onOptionSelected(option) + expanded = false } ) } - - uiState.error?.let { error -> - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) - ) { - Text( - text = error, - modifier = Modifier.padding(16.dp), - color = MaterialTheme.colorScheme.onErrorContainer - ) - } - } } } } +/** + * Данные для интеграций + */ +data class Integration( + val title: String, + val enabled: Boolean +) + +@Preview(showBackground = true) @Composable -private fun ProfileContent( - user: kr.smartsoltech.wellshe.domain.model.User, - onUpdateProfile: (kr.smartsoltech.wellshe.domain.model.User) -> Unit -) { - var name by remember { mutableStateOf(user.name) } - var email by remember { mutableStateOf(user.email) } - var age by remember { mutableStateOf(user.age.toString()) } - var height by remember { mutableStateOf(user.height.toString()) } - var weight by remember { mutableStateOf(user.weight.toString()) } - - Card { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) +fun ProfileScreenPreview() { + WellSheTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background ) { - Text( - text = "Основная информация", - style = MaterialTheme.typography.headlineSmall - ) - - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Имя") }, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = email, - onValueChange = { email = it }, - label = { Text("Email") }, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = age, - onValueChange = { age = it }, - label = { Text("Возраст") }, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = height, - onValueChange = { height = it }, - label = { Text("Рост (см)") }, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = weight, - onValueChange = { weight = it }, - label = { Text("Вес (кг)") }, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - onUpdateProfile( - user.copy( - name = name, - email = email, - age = age.toIntOrNull() ?: user.age, - height = height.toFloatOrNull() ?: user.height, - weight = weight.toFloatOrNull() ?: user.weight - ) - ) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Сохранить") - } - } - } - - Card { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "Цели", - style = MaterialTheme.typography.headlineSmall - ) - - Text("Вода: ${user.dailyWaterGoal} л/день") - Text("Шаги: ${user.dailyStepsGoal} шагов/день") - Text("Калории: ${user.dailyCaloriesGoal} ккал/день") - Text("Сон: ${user.dailySleepGoal} часов/день") + ProfileScreen() } } } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/settings/SettingsScreen.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/settings/SettingsScreen.kt index adf4488..d6282d5 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/settings/SettingsScreen.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -14,7 +16,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import kr.smartsoltech.wellshe.ui.theme.* @@ -197,30 +202,30 @@ private fun CycleSettingsCard( modifier: Modifier = Modifier ) { SettingsCard( - title = "Настройки цикла", + title = "Настройки менструального цикла", icon = Icons.Default.CalendarMonth, modifier = modifier ) { - SettingsSliderItem( + SettingsNumberField( title = "Длина цикла", - subtitle = "Количество дней в цикле", - value = cycleLength.toFloat(), - valueRange = 21f..35f, - steps = 13, - onValueChange = { onCycleLengthChange(it.toInt()) }, - valueFormatter = { "${it.toInt()} дней" } + subtitle = "Количество дней в цикле (21-35)", + value = cycleLength, + onValueChange = { value -> + if (value in 21..35) onCycleLengthChange(value) + }, + suffix = "дней" ) Spacer(modifier = Modifier.height(20.dp)) - SettingsSliderItem( + SettingsNumberField( title = "Длина менструации", - subtitle = "Количество дней менструации", - value = periodLength.toFloat(), - valueRange = 3f..8f, - steps = 4, - onValueChange = { onPeriodLengthChange(it.toInt()) }, - valueFormatter = { "${it.toInt()} дней" } + subtitle = "Количество дней менструации (3-8)", + value = periodLength, + onValueChange = { value -> + if (value in 3..8) onPeriodLengthChange(value) + }, + suffix = "дней" ) } } @@ -240,38 +245,38 @@ private fun GoalsSettingsCard( icon = Icons.Default.TrackChanges, modifier = modifier ) { - SettingsSliderItem( + SettingsDecimalField( title = "Цель по воде", - subtitle = "Количество воды в день", + subtitle = "Количество воды в день (1.5-4.0 л)", value = waterGoal, - valueRange = 1.5f..4.0f, - steps = 24, - onValueChange = onWaterGoalChange, - valueFormatter = { "%.1f л".format(it) } + onValueChange = { value -> + if (value in 1.5f..4.0f) onWaterGoalChange(value) + }, + suffix = "л" ) Spacer(modifier = Modifier.height(20.dp)) - SettingsSliderItem( + SettingsNumberField( title = "Цель по шагам", - subtitle = "Количество шагов в день", - value = stepsGoal.toFloat(), - valueRange = 5000f..20000f, - steps = 29, - onValueChange = { onStepsGoalChange(it.toInt()) }, - valueFormatter = { "${(it/1000).toInt()}k шагов" } + subtitle = "Количество шагов в день (5000-20000)", + value = stepsGoal, + onValueChange = { value -> + if (value in 5000..20000) onStepsGoalChange(value) + }, + suffix = "шагов" ) Spacer(modifier = Modifier.height(20.dp)) - SettingsSliderItem( + SettingsDecimalField( title = "Цель по сну", - subtitle = "Количество часов сна", + subtitle = "Количество часов сна (6-10 часов)", value = sleepGoal, - valueRange = 6.0f..10.0f, - steps = 7, - onValueChange = onSleepGoalChange, - valueFormatter = { "%.1f часов".format(it) } + onValueChange = { value -> + if (value in 6.0f..10.0f) onSleepGoalChange(value) + }, + suffix = "часов" ) } } @@ -327,15 +332,17 @@ private fun DataManagementCard( Spacer(modifier = Modifier.height(16.dp)) SettingsActionItem( - title = "Очистить все данные", - subtitle = "Удалить все сохраненные данные", + title = "Очистить данные", + subtitle = "Удалить все данные приложения", icon = Icons.Default.DeleteForever, onClick = onClearData, - isDestructive = true + textColor = Color(0xFFFF5722) ) } } +// Компоненты настроек + @Composable private fun SettingsCard( title: String, @@ -356,7 +363,7 @@ private fun SettingsCard( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.padding(bottom = 20.dp) ) { Icon( imageVector = icon, @@ -369,7 +376,7 @@ private fun SettingsCard( Text( text = title, - style = MaterialTheme.typography.titleLarge.copy( + style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold, color = TextPrimary ) @@ -405,7 +412,7 @@ private fun SettingsSwitchItem( ) Text( text = subtitle, - style = MaterialTheme.typography.bodySmall.copy( + style = MaterialTheme.typography.bodyMedium.copy( color = TextSecondary ) ) @@ -415,70 +422,108 @@ private fun SettingsSwitchItem( checked = isChecked, onCheckedChange = onCheckedChange, colors = SwitchDefaults.colors( - checkedThumbColor = NeutralWhite, - checkedTrackColor = PrimaryPink, - uncheckedThumbColor = NeutralWhite, - uncheckedTrackColor = Color.Gray.copy(alpha = 0.3f) + checkedThumbColor = PrimaryPink, + checkedTrackColor = PrimaryPinkLight ) ) } } @Composable -private fun SettingsSliderItem( +private fun SettingsNumberField( + title: String, + subtitle: String, + value: Int, + onValueChange: (Int) -> Unit, + suffix: String, + modifier: Modifier = Modifier +) { + val keyboardController = LocalSoftwareKeyboardController.current + var textValue by remember(value) { mutableStateOf(value.toString()) } + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Medium, + color = TextPrimary + ) + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium.copy( + color = TextSecondary + ), + modifier = Modifier.padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = textValue, + onValueChange = { newValue -> + textValue = newValue + newValue.toIntOrNull()?.let { intValue -> + onValueChange(intValue) + } + }, + suffix = { Text(suffix) }, + singleLine = true, + modifier = Modifier.width(120.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { keyboardController?.hide() } + ) + ) + } +} + +@Composable +private fun SettingsDecimalField( title: String, subtitle: String, value: Float, - valueRange: ClosedFloatingPointRange, - steps: Int, onValueChange: (Float) -> Unit, - valueFormatter: (Float) -> String, + suffix: String, modifier: Modifier = Modifier ) { - Column( - modifier = modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = FontWeight.Medium, - color = TextPrimary - ) - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall.copy( - color = TextSecondary - ) - ) - } + val keyboardController = LocalSoftwareKeyboardController.current + var textValue by remember(value) { mutableStateOf("%.1f".format(value)) } - Text( - text = valueFormatter(value), - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Bold, - color = PrimaryPink - ) + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Medium, + color = TextPrimary ) - } + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium.copy( + color = TextSecondary + ), + modifier = Modifier.padding(bottom = 8.dp) + ) - Spacer(modifier = Modifier.height(8.dp)) - - Slider( - value = value, - onValueChange = onValueChange, - valueRange = valueRange, - steps = steps, - colors = SliderDefaults.colors( - thumbColor = PrimaryPink, - activeTrackColor = PrimaryPink, - inactiveTrackColor = Color.Gray.copy(alpha = 0.3f) + OutlinedTextField( + value = textValue, + onValueChange = { newValue -> + textValue = newValue + newValue.toFloatOrNull()?.let { floatValue -> + onValueChange(floatValue) + } + }, + suffix = { Text(suffix) }, + singleLine = true, + modifier = Modifier.width(120.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { keyboardController?.hide() } ) ) } @@ -490,7 +535,7 @@ private fun SettingsActionItem( subtitle: String, icon: ImageVector, onClick: () -> Unit, - isDestructive: Boolean = false, + textColor: Color = TextPrimary, modifier: Modifier = Modifier ) { Row( @@ -503,7 +548,7 @@ private fun SettingsActionItem( Icon( imageVector = icon, contentDescription = null, - tint = if (isDestructive) Color(0xFFE53E3E) else PrimaryPink, + tint = textColor, modifier = Modifier.size(24.dp) ) @@ -516,22 +561,21 @@ private fun SettingsActionItem( text = title, style = MaterialTheme.typography.bodyLarge.copy( fontWeight = FontWeight.Medium, - color = if (isDestructive) Color(0xFFE53E3E) else TextPrimary + color = textColor ) ) Text( text = subtitle, - style = MaterialTheme.typography.bodySmall.copy( - color = if (isDestructive) Color(0xFFE53E3E).copy(alpha = 0.7f) else TextSecondary + style = MaterialTheme.typography.bodyMedium.copy( + color = if (textColor == TextPrimary) TextSecondary else textColor.copy(alpha = 0.7f) ) ) } Icon( imageVector = Icons.Default.ChevronRight, - contentDescription = "Выполнить", - tint = TextSecondary, - modifier = Modifier.size(20.dp) + contentDescription = null, + tint = NeutralGray ) } } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/settings/SettingsViewModel.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/settings/SettingsViewModel.kt index fb19666..43f4402 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/settings/SettingsViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch import kr.smartsoltech.wellshe.data.repository.WellSheRepository import javax.inject.Inject @@ -37,7 +38,12 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy(isLoading = true) try { - repository.getSettings().collect { settings -> + repository.getSettings().catch { e -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = e.message + ) + }.collect { settings -> _uiState.value = _uiState.value.copy( isWaterReminderEnabled = settings.isWaterReminderEnabled, isCycleReminderEnabled = settings.isCycleReminderEnabled, @@ -60,6 +66,7 @@ class SettingsViewModel @Inject constructor( } } + // Уведомления fun toggleWaterReminder(enabled: Boolean) { viewModelScope.launch { try { @@ -93,61 +100,74 @@ class SettingsViewModel @Inject constructor( } } + // Настройки цикла fun updateCycleLength(length: Int) { - viewModelScope.launch { - try { - repository.updateCycleLength(length) - _uiState.value = _uiState.value.copy(cycleLength = length) - } catch (e: Exception) { - _uiState.value = _uiState.value.copy(error = e.message) + if (length in 21..35) { + viewModelScope.launch { + try { + repository.updateCycleLength(length) + _uiState.value = _uiState.value.copy(cycleLength = length) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = e.message) + } } } } fun updatePeriodLength(length: Int) { - viewModelScope.launch { - try { - repository.updatePeriodLength(length) - _uiState.value = _uiState.value.copy(periodLength = length) - } catch (e: Exception) { - _uiState.value = _uiState.value.copy(error = e.message) + if (length in 3..8) { + viewModelScope.launch { + try { + repository.updatePeriodLength(length) + _uiState.value = _uiState.value.copy(periodLength = length) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = e.message) + } } } } + // Цели fun updateWaterGoal(goal: Float) { - viewModelScope.launch { - try { - repository.updateWaterGoal(goal) - _uiState.value = _uiState.value.copy(waterGoal = goal) - } catch (e: Exception) { - _uiState.value = _uiState.value.copy(error = e.message) + if (goal in 1.5f..4.0f) { + viewModelScope.launch { + try { + repository.updateWaterGoal(goal) + _uiState.value = _uiState.value.copy(waterGoal = goal) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = e.message) + } } } } fun updateStepsGoal(goal: Int) { - viewModelScope.launch { - try { - repository.updateStepsGoal(goal) - _uiState.value = _uiState.value.copy(stepsGoal = goal) - } catch (e: Exception) { - _uiState.value = _uiState.value.copy(error = e.message) + if (goal in 5000..20000) { + viewModelScope.launch { + try { + repository.updateStepsGoal(goal) + _uiState.value = _uiState.value.copy(stepsGoal = goal) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = e.message) + } } } } fun updateSleepGoal(goal: Float) { - viewModelScope.launch { - try { - repository.updateSleepGoal(goal) - _uiState.value = _uiState.value.copy(sleepGoal = goal) - } catch (e: Exception) { - _uiState.value = _uiState.value.copy(error = e.message) + if (goal in 6.0f..10.0f) { + viewModelScope.launch { + try { + repository.updateSleepGoal(goal) + _uiState.value = _uiState.value.copy(sleepGoal = goal) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = e.message) + } } } } + // Внешний вид fun toggleTheme(isDark: Boolean) { viewModelScope.launch { try { @@ -159,11 +179,12 @@ class SettingsViewModel @Inject constructor( } } + // Управление данными fun exportData() { viewModelScope.launch { try { repository.exportUserData() - // TODO: Показать уведомление об успешном экспорте + // Показать сообщение об успехе } catch (e: Exception) { _uiState.value = _uiState.value.copy(error = e.message) } @@ -174,7 +195,7 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { try { repository.importUserData() - loadSettings() // Перезагружаем настройки + loadSettings() // Перезагрузить настройки } catch (e: Exception) { _uiState.value = _uiState.value.copy(error = e.message) } @@ -185,7 +206,8 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { try { repository.clearAllUserData() - loadSettings() // Перезагружаем настройки + // Сбросить на дефолтные значения + _uiState.value = SettingsUiState() } catch (e: Exception) { _uiState.value = _uiState.value.copy(error = e.message) } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Color.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Color.kt index 4c3d800..ec26047 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Color.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Color.kt @@ -49,3 +49,21 @@ val ChartPurple = Color(0xFF9C27B0) val ChartGreen = Color(0xFF4CAF50) val ChartOrange = Color(0xFFFF9800) val ChartRed = Color(0xFFF44336) + +// Цвета для фаз цикла +val PeriodColor = Color(0xFFFFD6E0) // Розовый для менструации +val FertileColor = Color(0xFFD6F5E3) // Зелёный для фертильного окна +val PmsColor = Color(0xFFFFF2CC) // Янтарный для ПМС +val OvulationBorder = Color(0xFF6366F1) // Индиго для обводки дня овуляции + +// Цвета для вкладок +val CycleTabColor = Color(0xFFFFF8E1) // Янтарный для вкладки Цикл +val BodyTabColor = Color(0xFFE3F2FD) // Синий для вкладки Тело +val MoodTabColor = Color(0xFFFCE4EC) // Розовый для вкладки Настроение +val AnalyticsTabColor = Color(0xFFE0F2F1) // Изумрудный для вкладки Аналитика +val ProfileTabColor = Color(0xFFF5F5F5) // Серый для вкладки Профиль + +// Акцентные цвета для разделов +val WaterColor = Color(0xFF2196F3) // Синий для воды +val WeightColor = Color(0xFFEC407A) // Розовый для веса +val ActivityColor = Color(0xFF4CAF50) // Зелёный для активности diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/CustomColors.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/CustomColors.kt new file mode 100644 index 0000000..7c4dc3d --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/CustomColors.kt @@ -0,0 +1,10 @@ +package kr.smartsoltech.wellshe.ui.theme + +import androidx.compose.ui.graphics.Color + +// Этот файл больше не используется - все цвета перенесены в Color.kt +// Файл оставлен только для обратной совместимости и будет удален в будущих версиях +@Deprecated("Используйте цвета из Color.kt вместо этого файла") +object CustomColorsDeprecated { + // Пустой объект для предотвращения ошибок при компиляции +} diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Shape.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Shape.kt new file mode 100644 index 0000000..8d37a64 --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Shape.kt @@ -0,0 +1,13 @@ +package kr.smartsoltech.wellshe.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +// Формы для Material3 компонентов +val Shapes = Shapes( + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(20.dp), + extraLarge = RoundedCornerShape(24.dp) +) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Theme.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Theme.kt index 5a723e1..e8203f8 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Theme.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Theme.kt @@ -10,42 +10,70 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat -private val DarkColorScheme = darkColorScheme( - primary = PrimaryPink, - secondary = AccentPurple, - tertiary = SecondaryBlue, - background = NeutralBlack, - surface = NeutralDarkGray, - onPrimary = NeutralWhite, - onSecondary = NeutralWhite, - onTertiary = NeutralWhite, - onBackground = NeutralWhite, - onSurface = NeutralWhite, +// Основная светлая цветовая схема +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF2196F3), // Основной синий цвет приложения + onPrimary = Color.White, + primaryContainer = Color(0xFFE3F2FD), // Светло-синий для карточек + onPrimaryContainer = Color(0xFF0D47A1), + + secondary = Color(0xFF4CAF50), // Зеленый для акцентов + onSecondary = Color.White, + secondaryContainer = Color(0xFFE8F5E9), + onSecondaryContainer = Color(0xFF1B5E20), + + tertiary = Color(0xFFE91E63), // Розовый для женских элементов + onTertiary = Color.White, + tertiaryContainer = Color(0xFFFCE4EC), + onTertiaryContainer = Color(0xFF880E4F), + + background = Color.White, + onBackground = Color(0xFF121212), + surface = Color(0xFFF5F5F5), + onSurface = Color(0xFF121212), + + surfaceVariant = Color(0xFFEEEEEE), // Светло-серый для карточек + onSurfaceVariant = Color(0xFF616161), + outline = Color(0xFFBDBDBD) ) -private val LightColorScheme = lightColorScheme( - primary = PrimaryPink, - secondary = AccentPurple, - tertiary = SecondaryBlue, - background = NeutralWhite, - surface = NeutralLightGray, - onPrimary = NeutralWhite, - onSecondary = NeutralWhite, - onTertiary = NeutralWhite, - onBackground = NeutralDarkGray, - onSurface = NeutralDarkGray, +// Темная цветовая схема +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF90CAF9), + onPrimary = Color(0xFF0D47A1), + primaryContainer = Color(0xFF1565C0), + onPrimaryContainer = Color(0xFFE3F2FD), + + secondary = Color(0xFFA5D6A7), + onSecondary = Color(0xFF1B5E20), + secondaryContainer = Color(0xFF2E7D32), + onSecondaryContainer = Color(0xFFE8F5E9), + + tertiary = Color(0xFFF48FB1), + onTertiary = Color(0xFF880E4F), + tertiaryContainer = Color(0xFFC2185B), + onTertiaryContainer = Color(0xFFFCE4EC), + + background = Color(0xFF121212), + onBackground = Color.White, + surface = Color(0xFF212121), + onSurface = Color.White, + + surfaceVariant = Color(0xFF303030), + onSurfaceVariant = Color(0xFFEEEEEE), + outline = Color(0xFF757575) ) @Composable fun WellSheTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, + dynamicColor: Boolean = false, content: @Composable () -> Unit ) { val colorScheme = when { @@ -53,22 +81,23 @@ fun WellSheTheme( val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - darkTheme -> DarkColorScheme else -> LightColorScheme } + val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + window.statusBarColor = colorScheme.background.toArgb() // Прозрачный статусбар + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme } } MaterialTheme( colorScheme = colorScheme, typography = Typography, + shapes = Shapes, content = content ) } diff --git a/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Type.kt b/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Type.kt index c08a69f..1ac2900 100644 --- a/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Type.kt +++ b/app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Type.kt @@ -6,48 +6,85 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -// Set of Material typography styles to start with +// Типографика в соответствии с Material 3 и дизайном веб-прототипа val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ), + // Заголовки titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp ), + titleMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = 0.1.sp + ), + + // Основной текст + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + + // Метки + labelLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), labelSmall = TextStyle( - fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp ), + + // Заголовки дисплейные headlineLarge = TextStyle( - fontFamily = FontFamily.Default, fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.sp ), headlineMedium = TextStyle( - fontFamily = FontFamily.Default, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.sp ), - bodyMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.25.sp + headlineSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp ) ) diff --git a/app/src/main/java/kr/smartsoltech/wellshe/workers/CycleNotificationManager.kt b/app/src/main/java/kr/smartsoltech/wellshe/workers/CycleNotificationManager.kt new file mode 100644 index 0000000..db02dfe --- /dev/null +++ b/app/src/main/java/kr/smartsoltech/wellshe/workers/CycleNotificationManager.kt @@ -0,0 +1,247 @@ +package kr.smartsoltech.wellshe.workers + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.* +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kr.smartsoltech.wellshe.MainActivity +import kr.smartsoltech.wellshe.R +import kr.smartsoltech.wellshe.data.entity.CycleForecastEntity +import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Сервис для управления уведомлениями, связанными с менструальным циклом + */ +@Singleton +class CycleNotificationManager @Inject constructor( + @ApplicationContext private val context: Context, + private val workManager: WorkManager +) { + companion object { + // Изменено с private на internal для доступа из внутреннего класса + internal const val CHANNEL_ID_CYCLE = "cycle_notifications" + private const val CHANNEL_NAME_CYCLE = "Уведомления цикла" + + const val WORK_TAG_PERIOD = "period_notification" + const val WORK_TAG_OVULATION = "ovulation_notification" + const val WORK_TAG_PMS = "pms_notification" + const val WORK_TAG_DEVIATION = "deviation_notification" + + const val EXTRA_NOTIFICATION_TYPE = "notification_type" + const val TYPE_PERIOD = "period" + const val TYPE_OVULATION = "ovulation" + const val TYPE_PMS = "pms" + const val TYPE_DEVIATION = "deviation" + } + + init { + createNotificationChannel() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID_CYCLE, + CHANNEL_NAME_CYCLE, + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Уведомления о менструальном цикле" + enableLights(true) + enableVibration(true) + } + + val notificationManager = context.getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + } + + /** + * Планирует все уведомления на основе текущих прогнозов и настроек + */ + fun scheduleAllNotifications(forecast: CycleForecastEntity, settings: CycleSettingsEntity) { + CoroutineScope(Dispatchers.IO).launch { + // Отменяем все предыдущие запланированные уведомления + cancelAllNotifications() + + // Планируем новые уведомления только если прогнозы надежны и даты не null + if (forecast.isReliable) { + forecast.nextPeriodStart?.let { nextPeriod -> + schedulePeriodNotification(nextPeriod, settings.periodReminderDaysBefore) + } + + forecast.nextOvulation?.let { ovulation -> + scheduleOvulationNotification(ovulation, settings.ovulationReminderDaysBefore) + } + + forecast.pmsStart?.let { pms -> + schedulePmsNotification(pms) + } + + forecast.nextPeriodStart?.let { nextPeriod -> + scheduleDeviationCheck(nextPeriod, settings.deviationAlertDays) + } + } + } + } + + /** + * Планирует уведомление о предстоящей менструации + */ + private fun schedulePeriodNotification(periodDate: LocalDate, daysBefore: Int) { + val notificationDate = periodDate.minusDays(daysBefore.toLong()) + if (notificationDate.isBefore(LocalDate.now())) return + + val data = workDataOf(EXTRA_NOTIFICATION_TYPE to TYPE_PERIOD) + scheduleNotification(WORK_TAG_PERIOD, notificationDate, data) + } + + /** + * Планирует уведомление о предстоящей овуляции + */ + private fun scheduleOvulationNotification(ovulationDate: LocalDate, daysBefore: Int) { + val notificationDate = ovulationDate.minusDays(daysBefore.toLong()) + if (notificationDate.isBefore(LocalDate.now())) return + + val data = workDataOf(EXTRA_NOTIFICATION_TYPE to TYPE_OVULATION) + scheduleNotification(WORK_TAG_OVULATION, notificationDate, data) + } + + /** + * Планирует уведомление о начале ПМС + */ + private fun schedulePmsNotification(pmsStartDate: LocalDate) { + if (pmsStartDate.isBefore(LocalDate.now())) return + + val data = workDataOf(EXTRA_NOTIFICATION_TYPE to TYPE_PMS) + scheduleNotification(WORK_TAG_PMS, pmsStartDate, data) + } + + /** + * Планирует проверку на отклонение (если менструация не началась в ожидаемый день) + */ + private fun scheduleDeviationCheck(expectedPeriodDate: LocalDate, deviationDays: Int) { + val checkDate = expectedPeriodDate.plusDays(deviationDays.toLong()) + + val data = workDataOf(EXTRA_NOTIFICATION_TYPE to TYPE_DEVIATION) + scheduleNotification(WORK_TAG_DEVIATION, checkDate, data) + } + + /** + * Планирует отложенную работу для показа уведомления в указанную дату + */ + private fun scheduleNotification(tag: String, date: LocalDate, data: Data) { + val today = LocalDate.now() + val delayDays = Duration.between( + today.atStartOfDay(), + date.atStartOfDay() + ).toDays() + + // Если дата уже прошла, не планируем уведомление + if (delayDays < 0) return + + val notificationWork = OneTimeWorkRequestBuilder() + .setInitialDelay(delayDays, TimeUnit.DAYS) + .setInputData(data) + .addTag(tag) + .build() + + workManager.enqueueUniqueWork( + tag, + ExistingWorkPolicy.REPLACE, + notificationWork + ) + } + + /** + * Отменяет все запланированные уведомления о цикле + */ + fun cancelAllNotifications() { + workManager.cancelAllWorkByTag(WORK_TAG_PERIOD) + workManager.cancelAllWorkByTag(WORK_TAG_OVULATION) + workManager.cancelAllWorkByTag(WORK_TAG_PMS) + workManager.cancelAllWorkByTag(WORK_TAG_DEVIATION) + } +} + +/** + * Worker для показа уведомлений о цикле + */ +class CycleNotificationWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val notificationType = inputData.getString(CycleNotificationManager.EXTRA_NOTIFICATION_TYPE) + ?: return Result.failure() + + showNotification(notificationType) + + return Result.success() + } + + private fun showNotification(type: String) { + val (title, message, id) = when (type) { + CycleNotificationManager.TYPE_PERIOD -> Triple( + "Скоро начнётся менструация", + "Подготовьтесь к началу менструации через несколько дней", + 1001 + ) + CycleNotificationManager.TYPE_OVULATION -> Triple( + "Приближается овуляция", + "Через несколько дней ожидается овуляция", + 1002 + ) + CycleNotificationManager.TYPE_PMS -> Triple( + "Вероятно начало ПМС", + "Обратите внимание на ваше самочувствие в эти дни", + 1003 + ) + CycleNotificationManager.TYPE_DEVIATION -> Triple( + "Отклонение от прогноза", + "Менструация не началась в ожидаемый период. Возможно, стоит обновить данные", + 1004 + ) + else -> return + } + + val intent = Intent(applicationContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra("destination", "cycle") + } + + val pendingIntent = PendingIntent.getActivity( + applicationContext, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val notification = NotificationCompat.Builder(applicationContext, CycleNotificationManager.CHANNEL_ID_CYCLE) + .setSmallIcon(R.drawable.ic_notification) // Требуется добавить иконку + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(id, notification) + } +} diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..ee95009 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/test/java/kr/smartsoltech/wellshe/domain/analytics/CycleAnalyticsTest.kt b/app/src/test/java/kr/smartsoltech/wellshe/domain/analytics/CycleAnalyticsTest.kt index f980f10..e943da2 100644 --- a/app/src/test/java/kr/smartsoltech/wellshe/domain/analytics/CycleAnalyticsTest.kt +++ b/app/src/test/java/kr/smartsoltech/wellshe/domain/analytics/CycleAnalyticsTest.kt @@ -1,18 +1,84 @@ package kr.smartsoltech.wellshe.domain.analytics import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity -import kr.smartsoltech.wellshe.data.entity.CycleStatsEntity import org.junit.Assert.* import org.junit.Test +import java.time.LocalDate class CycleAnalyticsTest { @Test fun testForecastHighConfidence() { - val periods = listOf(CyclePeriodEntity(id = 0, startTs = 1_700_000_000_000, endTs = 1_700_000_000_000 + 5 * 24 * 60 * 60 * 1000, notes = "")) - val stats = CycleStatsEntity(avgCycle = 28, variance = 1, lutealLen = 14) - val forecast = CycleAnalytics.forecast(periods, stats) + val periods = listOf( + CyclePeriodEntity( + id = 0, + startDate = LocalDate.now().minusDays(28), + endDate = LocalDate.now().minusDays(23), + cycleLength = 28, + flow = "medium", + symptoms = emptyList(), + mood = "neutral" + ), + CyclePeriodEntity( + id = 1, + startDate = LocalDate.now().minusDays(56), + endDate = LocalDate.now().minusDays(51), + cycleLength = 28, + flow = "medium", + symptoms = emptyList(), + mood = "neutral" + ) + ) + + val forecast = CycleAnalytics.forecast(periods, null) assertEquals("высокая", forecast.confidence) assertNotNull(forecast.nextStart) assertNotNull(forecast.fertileWindow) } + + @Test + fun testAnalyzeRegularity() { + val regularPeriods = listOf( + CyclePeriodEntity( + id = 0, + startDate = LocalDate.now().minusDays(28), + endDate = LocalDate.now().minusDays(23), + cycleLength = 28, + flow = "medium", + symptoms = emptyList(), + mood = "neutral" + ), + CyclePeriodEntity( + id = 1, + startDate = LocalDate.now().minusDays(56), + endDate = LocalDate.now().minusDays(51), + cycleLength = 28, + flow = "medium", + symptoms = emptyList(), + mood = "neutral" + ) + ) + + val regularity = CycleAnalytics.analyzeRegularity(regularPeriods) + assertNotNull(regularity) + assertTrue(regularity.isNotEmpty()) + } + + @Test + fun testPredictNextPeriods() { + val periods = listOf( + CyclePeriodEntity( + id = 0, + startDate = LocalDate.now().minusDays(28), + endDate = LocalDate.now().minusDays(23), + cycleLength = 28, + flow = "medium", + symptoms = emptyList(), + mood = "neutral" + ) + ) + + val predictions = CycleAnalytics.predictNextPeriods(periods, 3) + assertEquals(3, predictions.size) + assertTrue(!predictions[0].isBefore(LocalDate.now())) + } } diff --git a/app/src/test/java/kr/smartsoltech/wellshe/domain/analytics/SleepAnalyticsTest.kt b/app/src/test/java/kr/smartsoltech/wellshe/domain/analytics/SleepAnalyticsTest.kt index 8059e88..5d235db 100644 --- a/app/src/test/java/kr/smartsoltech/wellshe/domain/analytics/SleepAnalyticsTest.kt +++ b/app/src/test/java/kr/smartsoltech/wellshe/domain/analytics/SleepAnalyticsTest.kt @@ -8,8 +8,24 @@ class SleepAnalyticsTest { @Test fun testSleepDebt() { val logs = listOf( - SleepLogEntity(id = 0, startTs = 1000, endTs = 1000 + 8 * 3600_000, quality = 5), - SleepLogEntity(id = 0, startTs = 2000, endTs = 2000 + 7 * 3600_000, quality = 4) + SleepLogEntity( + id = 0, + date = java.time.LocalDate.now(), + bedTime = "22:00", + wakeTime = "06:00", + duration = 8.0f, + quality = "good", + notes = "" + ), + SleepLogEntity( + id = 0, + date = java.time.LocalDate.now().minusDays(1), + bedTime = "23:00", + wakeTime = "06:00", + duration = 7.0f, + quality = "normal", + notes = "" + ) ) val debt = SleepAnalytics.sleepDebt(logs, 8) assertEquals(1, debt) diff --git a/settings.gradle.kts b/settings.gradle.kts index 932f6cc..767cc15 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } // Добавляем репозиторий JitPack } }