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