Compare commits

3 Commits

Author SHA1 Message Date
6395c0fc36 main fucntions 2025-10-16 12:39:50 +09:00
f57cd956bd UI refactor 2025-10-15 21:57:19 +09:00
c00924be85 main commit 2025-10-14 20:10:15 +09:00
116 changed files with 25091 additions and 1879 deletions

123
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-10-12T11:23:35.923427438Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=R3CT80VPBQZ" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

1
.idea/gradle.xml generated
View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>

1
.idea/misc.xml generated
View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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 = 11,
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()
}

View File

@@ -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<String>? = value?.let {
if (it.isEmpty()) emptyList() else it.split("||")
}
@TypeConverter
fun listToString(list: List<String>?): String = list?.joinToString("||") ?: ""
}

View File

@@ -0,0 +1,776 @@
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()
)
}
}
/**
* Миграция базы данных с версии 2 на версию 3.
* Исправляет проблему с типом данных столбца date в таблице water_logs.
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `water_logs_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`date` INTEGER NOT NULL,
`amount` INTEGER NOT NULL,
`timestamp` INTEGER NOT NULL
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
// Для этого используем SQLite функцию strftime для преобразования строковой даты в timestamp
database.execSQL(
"""
INSERT INTO water_logs_new (id, date, amount, timestamp)
SELECT id,
CASE
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
ELSE strftime('%s', 'now') * 1000
END as date_int,
amount,
timestamp
FROM water_logs
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE water_logs")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE water_logs_new RENAME TO water_logs")
}
}
/**
* Миграция базы данных с версии 3 на версию 4.
* Исправляет проблему с типом данных столбца date в таблице sleep_logs.
*/
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `sleep_logs_new` (
`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
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
database.execSQL(
"""
INSERT INTO sleep_logs_new (id, date, bedTime, wakeTime, duration, quality, notes)
SELECT id,
CASE
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
ELSE strftime('%s', 'now') * 1000
END as date_int,
bedTime,
wakeTime,
duration,
quality,
notes
FROM sleep_logs
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE sleep_logs")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE sleep_logs_new RENAME TO sleep_logs")
}
}
/**
* Миграция базы данных с версии 4 на версию 5.
* Исправляет проблему с типом данных столбца date в таблице workouts.
*/
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `workouts_new` (
`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
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
database.execSQL(
"""
INSERT INTO workouts_new (id, date, type, name, duration, caloriesBurned, intensity, notes)
SELECT id,
CASE
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
ELSE strftime('%s', 'now') * 1000
END as date_int,
type,
name,
duration,
caloriesBurned,
intensity,
notes
FROM workouts
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE workouts")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE workouts_new RENAME TO workouts")
}
}
/**
* Миграция базы данных с версии 5 на версию 6.
* Исправляет проблему с типом данных столбца date в таблице calories.
*/
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `calories_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`date` INTEGER NOT NULL,
`consumed` INTEGER NOT NULL,
`burned` INTEGER NOT NULL,
`target` INTEGER NOT NULL
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
database.execSQL(
"""
INSERT INTO calories_new (id, date, consumed, burned, target)
SELECT id,
CASE
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
ELSE strftime('%s', 'now') * 1000
END as date_int,
consumed,
burned,
target
FROM calories
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE calories")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE calories_new RENAME TO calories")
}
}
/**
* Миграция базы данных с версии 6 на версию 7.
* Исправляет проблему с типом данных столбца date во всех оставшихся таблицах.
*/
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
// Пропускаем таблицу steps, так как она будет обработана в MIGRATION_7_8
// Начинаем с проверки существования таблицы health_records и наличия в ней поля date с типом TEXT
try {
val cursor = database.query("PRAGMA table_info(health_records)")
var hasDateColumn = false
var isTextType = false
if (cursor.moveToFirst()) {
do {
val columnName = cursor.getString(cursor.getColumnIndex("name"))
val columnType = cursor.getString(cursor.getColumnIndex("type"))
if (columnName == "date" && columnType.equals("TEXT", ignoreCase = true)) {
hasDateColumn = true
isTextType = true
break
}
} while (cursor.moveToNext())
}
cursor.close()
if (hasDateColumn && isTextType) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `health_records_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`date` INTEGER NOT NULL,
`type` TEXT NOT NULL,
`value` REAL NOT NULL,
`notes` TEXT NOT NULL
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
database.execSQL(
"""
INSERT INTO health_records_new (id, date, type, value, notes)
SELECT id,
CASE
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
ELSE strftime('%s', 'now') * 1000
END as date_int,
type,
value,
notes
FROM health_records
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE health_records")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE health_records_new RENAME TO health_records")
}
} catch (e: Exception) {
// Если таблица не существует или возникла другая ошибка, просто продолжаем
}
// Проверяем другие таблицы, которые могут содержать поля даты с типом TEXT
// Список таблиц для проверки
val tablesToCheck = listOf(
"cycle_periods",
"user_profiles",
"weight_logs",
"beverage_logs",
"workout_sessions"
)
for (table in tablesToCheck) {
try {
val cursor = database.query("PRAGMA table_info($table)")
val dateColumns = mutableListOf<String>()
val columns = mutableListOf<Pair<String, String>>()
if (cursor.moveToFirst()) {
do {
val columnNameIndex = cursor.getColumnIndex("name")
val columnTypeIndex = cursor.getColumnIndex("type")
if (columnNameIndex >= 0 && columnTypeIndex >= 0) {
val columnName = cursor.getString(columnNameIndex)
val columnType = cursor.getString(columnTypeIndex)
columns.add(columnName to columnType)
// Проверяем, есть ли в названии колонки слово "date" и тип TEXT
if (columnName.contains("date", ignoreCase = true) &&
columnType.equals("TEXT", ignoreCase = true)) {
dateColumns.add(columnName)
}
}
} while (cursor.moveToNext())
}
cursor.close()
// Если найдены столбцы с датами типа TEXT
if (dateColumns.isNotEmpty()) {
// Создаем имя для временной таблицы
val newTableName = "${table}_new"
// Создаем SQL для создания новой таблицы с правильными типами
val createTableSQL = StringBuilder()
createTableSQL.append("CREATE TABLE IF NOT EXISTS `$newTableName` (")
val columnDefinitions = columns.map { (name, type) ->
// Для столбцов с датой меняем тип на INTEGER
if (dateColumns.contains(name)) {
"`$name` INTEGER" + if (name == "id") " PRIMARY KEY AUTOINCREMENT NOT NULL" else " NOT NULL"
} else {
"`$name` $type"
}
}
createTableSQL.append(columnDefinitions.joinToString(", "))
createTableSQL.append(")")
// Выполняем создание новой таблицы
database.execSQL(createTableSQL.toString())
// Создаем SQL для копирования данных
val insertSQL = StringBuilder()
insertSQL.append("INSERT INTO `$newTableName` SELECT ")
val columnSelects = columns.map { (name, _) ->
if (dateColumns.contains(name)) {
"CASE WHEN $name IS NOT NULL THEN strftime('%s', $name) * 1000 ELSE strftime('%s', 'now') * 1000 END as $name"
} else {
name
}
}
insertSQL.append(columnSelects.joinToString(", "))
insertSQL.append(" FROM `$table`")
// Выполняем копирование данных
database.execSQL(insertSQL.toString())
// Удаляем старую таблицу и переименовываем новую
database.execSQL("DROP TABLE `$table`")
database.execSQL("ALTER TABLE `$newTableName` RENAME TO `$table`")
}
} catch (e: Exception) {
// Если таблица не существует или возникла другая ошибка, просто продолжаем
}
}
}
}
/**
* Миграция базы данных с версии 7 на версию 8.
* Исправляет проблему с типом данных столбца date в таблице steps.
*/
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `steps_new` (
`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
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
database.execSQL(
"""
INSERT INTO steps_new (id, date, steps, distance, caloriesBurned, target)
SELECT id,
CASE
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
ELSE strftime('%s', 'now') * 1000
END as date_int,
steps,
distance,
caloriesBurned,
target
FROM steps
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE steps")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE steps_new RENAME TO steps")
}
}
/**
* Миграция базы данных с версии 8 на версию 9.
* Исправляет проблему с типом данных столбца lastPeriodDate в таблице user_profile.
*/
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `user_profile_new` (
`id` INTEGER PRIMARY KEY 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
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя lastPeriodDate из TEXT в INTEGER
database.execSQL(
"""
INSERT INTO user_profile_new (
id, name, email, age, height, weight, targetWeight, activityLevel,
dailyWaterGoal, dailyCalorieGoal, dailyStepsGoal, cycleLength,
periodLength, lastPeriodDate, profileImagePath
)
SELECT
id, name, email, age, height, weight, targetWeight, activityLevel,
dailyWaterGoal, dailyCalorieGoal, dailyStepsGoal, cycleLength,
periodLength,
CASE
WHEN lastPeriodDate IS NOT NULL THEN strftime('%s', lastPeriodDate) * 1000
ELSE NULL
END,
profileImagePath
FROM user_profile
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE user_profile")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE user_profile_new RENAME TO user_profile")
}
}
/**
* Миграция базы данных с версии 9 на версию 10.
* Исправляет проблему с отсутствующей таблицей WorkoutSession.
*/
val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
// Проверяем, существует ли таблица WorkoutSession
try {
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='WorkoutSession'")
val hasTable = cursor.count > 0
cursor.close()
if (!hasTable) {
// Создаем таблицу WorkoutSession если она не существует
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `WorkoutSession` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`startedAt` INTEGER NOT NULL,
`endedAt` INTEGER,
`exerciseId` INTEGER NOT NULL,
`kcalTotal` REAL,
`distanceKm` REAL,
`notes` TEXT
)
""".trimIndent()
)
// Создаем индекс для столбца startedAt
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `WorkoutSession` (`startedAt`)"
)
} else {
// Если таблица существует, проверяем наличие необходимых столбцов
val columnCursor = database.query("PRAGMA table_info(WorkoutSession)")
val columns = mutableListOf<String>()
while (columnCursor.moveToNext()) {
val columnName = columnCursor.getString(columnCursor.getColumnIndex("name"))
columns.add(columnName)
}
columnCursor.close()
// Если нужных колонок нет, пересоздаем таблицу
val requiredColumns = listOf("id", "startedAt", "endedAt", "exerciseId",
"kcalTotal", "distanceKm", "notes")
if (!columns.containsAll(requiredColumns)) {
// Переименовываем старую таблицу
database.execSQL("ALTER TABLE `WorkoutSession` RENAME TO `WorkoutSession_old`")
// Создаем новую таблицу с правильной структурой
database.execSQL(
"""
CREATE TABLE `WorkoutSession` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`startedAt` INTEGER NOT NULL,
`endedAt` INTEGER,
`exerciseId` INTEGER NOT NULL,
`kcalTotal` REAL,
`distanceKm` REAL,
`notes` TEXT
)
""".trimIndent()
)
// Создаем индекс для столбца startedAt
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `WorkoutSession` (`startedAt`)"
)
// Пытаемся скопировать данные из старой таблицы, если это возможно
try {
database.execSQL(
"""
INSERT INTO WorkoutSession (id, startedAt, endedAt, exerciseId, kcalTotal, distanceKm, notes)
SELECT id, startedAt, endedAt, exerciseId, kcalTotal, distanceKm, notes
FROM WorkoutSession_old
""".trimIndent()
)
} catch (e: Exception) {
// Если копирование не удалось, просто продолжаем без данных
}
// Удаляем старую таблицу
database.execSQL("DROP TABLE IF EXISTS `WorkoutSession_old`")
}
}
} catch (e: Exception) {
// В случае любой ошибки, создаем таблицу заново
database.execSQL("DROP TABLE IF EXISTS `WorkoutSession`")
database.execSQL(
"""
CREATE TABLE `WorkoutSession` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`startedAt` INTEGER NOT NULL,
`endedAt` INTEGER,
`exerciseId` INTEGER NOT NULL,
`kcalTotal` REAL,
`distanceKm` REAL,
`notes` TEXT
)
""".trimIndent()
)
// Создаем индекс для столбца startedAt
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `WorkoutSession` (`startedAt`)"
)
}
// Также проверяем наличие таблицы WorkoutSessionParam и создаем её при необходимости
try {
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='WorkoutSessionParam'")
val hasTable = cursor.count > 0
cursor.close()
if (!hasTable) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `WorkoutSessionParam` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`sessionId` INTEGER NOT NULL,
`key` TEXT NOT NULL,
`valueNum` REAL,
`valueText` TEXT,
`unit` TEXT
)
""".trimIndent()
)
}
} catch (e: Exception) {
database.execSQL("DROP TABLE IF EXISTS `WorkoutSessionParam`")
database.execSQL(
"""
CREATE TABLE `WorkoutSessionParam` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`sessionId` INTEGER NOT NULL,
`key` TEXT NOT NULL,
`valueNum` REAL,
`valueText` TEXT,
`unit` TEXT
)
""".trimIndent()
)
}
// И проверяем наличие таблицы WorkoutEvent
try {
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='WorkoutEvent'")
val hasTable = cursor.count > 0
cursor.close()
if (!hasTable) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `WorkoutEvent` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`sessionId` INTEGER NOT NULL,
`timestamp` INTEGER NOT NULL,
`eventType` TEXT NOT NULL,
`valueNum` REAL,
`valueText` TEXT
)
""".trimIndent()
)
}
} catch (e: Exception) {
database.execSQL("DROP TABLE IF EXISTS `WorkoutEvent`")
database.execSQL(
"""
CREATE TABLE `WorkoutEvent` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`sessionId` INTEGER NOT NULL,
`timestamp` INTEGER NOT NULL,
`eventType` TEXT NOT NULL,
`valueNum` REAL,
`valueText` TEXT
)
""".trimIndent()
)
}
}
}
/**
* Миграция базы данных с версии 10 на версию 11.
* Исправляет проблему с несоответствием структуры таблицы WorkoutEvent.
*/
val MIGRATION_10_11 = object : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильной структурой
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `WorkoutEvent_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`sessionId` INTEGER NOT NULL,
`ts` INTEGER NOT NULL,
`eventType` TEXT NOT NULL,
`payloadJson` TEXT NOT NULL
)
""".trimIndent()
)
// Пытаемся скопировать данные из старой таблицы, преобразовывая структуру
try {
database.execSQL(
"""
INSERT INTO WorkoutEvent_new (id, sessionId, ts, eventType, payloadJson)
SELECT
id,
sessionId,
timestamp AS ts,
eventType,
CASE
WHEN valueText IS NOT NULL THEN valueText
WHEN valueNum IS NOT NULL THEN json_object('value', valueNum)
ELSE '{}'
END AS payloadJson
FROM WorkoutEvent
""".trimIndent()
)
} catch (e: Exception) {
// Если копирование не удалось из-за несовместимости данных,
// просто создаем пустую таблицу
}
// Удаляем старую таблицу
database.execSQL("DROP TABLE IF EXISTS WorkoutEvent")
// Переименовываем новую таблицу
database.execSQL("ALTER TABLE WorkoutEvent_new RENAME TO WorkoutEvent")
}
}

View File

@@ -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<Nutrient>
}
@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<Beverage>
}
@Dao
interface BeverageServingDao {
@Query("SELECT * FROM BeverageServing WHERE beverageId = :beverageId")
suspend fun getByBeverage(beverageId: Long): List<BeverageServing>
@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<BeverageServingNutrient>
@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<WaterLogEntity>
@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<BeverageLog>
@Insert
suspend fun insert(log: BeverageLog): Long
}
@Dao
interface BeverageLogNutrientDao {
@Query("SELECT * FROM BeverageLogNutrient WHERE beverageLogId = :beverageLogId")
suspend fun getByLog(beverageLogId: Long): List<BeverageLogNutrient>
@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<WeightLog>
}
@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<Exercise>
@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<ExerciseParam>
@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<ExerciseFormula>
@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<ExerciseFormulaVar>
@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<WorkoutSession>
@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<WorkoutSessionParam>
@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<WorkoutEvent>
@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
}

View File

@@ -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<CycleForecastEntity?>
@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()
}

View File

@@ -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<List<CycleHistoryEntity>>
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC")
suspend fun getAll(): List<CycleHistoryEntity>
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC LIMIT :limit")
suspend fun getRecentCycles(limit: Int): List<CycleHistoryEntity>
@Query("SELECT * FROM cycle_history WHERE atypical = 0 ORDER BY periodStart DESC LIMIT :limit")
suspend fun getRecentTypicalCycles(limit: Int): List<CycleHistoryEntity>
@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<CycleHistoryEntity>
}

View File

@@ -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<CyclePeriodEntity>
@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?
}

View File

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

View File

@@ -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<List<WaterLogEntity>>
@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<List<WaterLogEntity>>
}
@Dao
interface CyclePeriodDao {
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC")
fun getAllPeriods(): Flow<List<CyclePeriodEntity>>
@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<CyclePeriodEntity?>
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT :limit")
fun getRecentPeriods(limit: Int): Flow<List<CyclePeriodEntity>>
@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<List<CyclePeriodEntity>>
}
@Dao
interface SleepLogDao {
@Query("SELECT * FROM sleep_logs WHERE date = :date")

View File

@@ -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<List<HealthRecordEntity>>
@Query("SELECT * FROM health_records ORDER BY date DESC")
suspend fun getAll(): List<HealthRecordEntity>
@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<HealthRecordEntity>
@Query("SELECT * FROM health_records ORDER BY date DESC")
fun getAllFlow(): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>>
@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<HealthRecordEntity?>
}

View File

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

View File

@@ -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 для пониженной точности при определенных статусах
)

View File

@@ -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<String> = emptyList(),
val mood: String = "",
val cycleLength: Int? = null
)

View File

@@ -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<String> = emptyList(),
val mood: String = "",
val cycleLength: Int? = null
)

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.TypeConverter
class HealthRecordConverters {
@TypeConverter
fun fromSymptomsList(list: List<String>?): String? {
return list?.joinToString(separator = "|")
}
@TypeConverter
fun toSymptomsList(data: String?): List<String>? {
return data?.split("|")?.filter { it.isNotEmpty() }
}
}

View File

@@ -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<String>?,
val notes: String?
)

View File

@@ -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<Beverage> = beverageDao.search("%$query%")
suspend fun getServings(beverageId: Long): List<BeverageServing> = servingDao.getByBeverage(beverageId)
suspend fun getNutrients(servingId: Long): List<BeverageServingNutrient> = 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<Int> {
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<Pair<String, Float>> {
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<Exercise> = exerciseDao.search("%$query%")
suspend fun getParams(exerciseId: Long): List<ExerciseParam> = paramDao.getByExercise(exerciseId)
suspend fun getFormulas(exerciseId: Long): List<ExerciseFormula> = 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<Exercise> = exerciseDao.search("%$query%")
suspend fun getSessions(days: Int): List<WorkoutSession> {
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) будут реализованы с расчетом калорий по формуле
}

View File

@@ -0,0 +1,315 @@
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<CycleSettingsEntity?> = 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()
}
/**
* Обновляет настройки цикла
*/
suspend fun updateSettings(settings: CycleSettingsEntity) {
settingsDao.insertOrUpdate(settings)
}
// История циклов
fun getAllHistoryFlow(): Flow<List<CycleHistoryEntity>> = historyDao.getAllFlow()
suspend fun getAllHistory(): List<CycleHistoryEntity> = historyDao.getAll()
suspend fun getRecentCycles(limit: Int): List<CycleHistoryEntity> = historyDao.getRecentCycles(limit)
suspend fun getRecentTypicalCycles(limit: Int): List<CycleHistoryEntity> =
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<CycleForecastEntity?> = 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<CycleHistoryEntity>
): 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
}
// Рассчитываем л<><D0BB>теиновую фазу
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<CycleHistoryEntity>): Int {
if (history.size < 2) return 28 // Стандартный цикл если недостаточно данных
// Сортируем по дате начала
val sortedHistory = history.sortedBy { it.periodStart }
// Рассчитываем <20><>лину между началом каждого цикла
val cycleLengths = mutableListOf<Int>()
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> {
// Получаем все периоды из истории и преобразуем их в 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)
}
}

View File

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

View File

@@ -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<List<WaterIntake>> {
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<String>, 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<String>, 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<CyclePeriodEntity?> {
return cyclePeriodDao.getCurrentPeriod()
}
fun getRecentPeriods(): Flow<List<CyclePeriodEntity>> {
return cyclePeriodDao.getRecentPeriods(6)
suspend fun getRecentPeriods(): List<CyclePeriodEntity> {
return cyclePeriodDao.getAll().take(6)
}
// =================
@@ -281,13 +296,34 @@ class WellSheRepository @Inject constructor(
// ЗДОРОВЬЕ
// =================
fun getTodayHealthData(): Flow<HealthRecordEntity?> {
// TODO: Реализовать получение данных о здоровье за сегодня
return flowOf(null)
fun getTodayHealthData(): kotlinx.coroutines.flow.Flow<HealthRecordEntity?> {
val today = LocalDate.now()
return healthRecordDao.getByDateFlow(today)
}
suspend fun updateHealthRecord(record: HealthRecord) {
// TODO: Реализовать обновление записи о здоровье
fun getAllHealthRecords(): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>> {
return healthRecordDao.getAllFlow()
}
fun getRecentHealthRecords(limit: Int = 10): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>> {
return healthRecordDao.getAllFlow().map { records: List<HealthRecordEntity> ->
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<List<WaterLogEntity>> {
return waterLogDao.getWaterLogsForDate(date)
return flow {
emit(waterLogDao.getWaterLogsForDate(date))
}
}
}

View File

@@ -10,6 +10,19 @@ 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 kr.smartsoltech.wellshe.data.MIGRATION_2_3
import kr.smartsoltech.wellshe.data.MIGRATION_3_4
import kr.smartsoltech.wellshe.data.MIGRATION_4_5
import kr.smartsoltech.wellshe.data.MIGRATION_5_6
import kr.smartsoltech.wellshe.data.MIGRATION_6_7
import kr.smartsoltech.wellshe.data.MIGRATION_7_8
import kr.smartsoltech.wellshe.data.MIGRATION_8_9
import kr.smartsoltech.wellshe.data.MIGRATION_9_10
import kr.smartsoltech.wellshe.data.MIGRATION_10_11
import javax.inject.Singleton
@Module
@@ -28,7 +41,10 @@ object AppModule {
context,
AppDatabase::class.java,
"well_she_db"
).fallbackToDestructiveMigration().build()
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11)
.fallbackToDestructiveMigration()
.build()
// DAO providers
@Provides
@@ -55,6 +71,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

View File

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

View File

@@ -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<Long, Long>?,
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<CyclePeriodEntity>, stats: CycleStats? = null): CycleForecast {
fun forecast(periods: List<CyclePeriodEntity>, 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<CyclePeriodEntity>): String {
val stats = calculateStats(periods)
return when {
stats.variance < 2 -> "Очень регулярный"
stats.variance < 5 -> "Регулярный"
stats.variance < 10 -> "Умеренно регулярный"
else -> "Нерегулярный"
}
}
/**
* Предсказание следующих дат
*/
fun predictNextPeriods(periods: List<CyclePeriodEntity>, count: Int = 3): List<LocalDate> {
if (periods.isEmpty()) return emptyList()
val stats = calculateStats(periods)
val lastPeriod = periods.first()
val predictions = mutableListOf<LocalDate>()
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<Long, Long>?,
val confidence: String
)
data class CycleStats(
val avgCycle: Int,
val variance: Double,
val lutealLen: Int
)

View File

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

View File

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

View File

@@ -0,0 +1,125 @@
package kr.smartsoltech.wellshe.domain.models
import kr.smartsoltech.wellshe.ui.cycle.settings.FertileWindowMode
import kr.smartsoltech.wellshe.ui.cycle.settings.HormonalContraception
import kr.smartsoltech.wellshe.ui.cycle.settings.OvulationMethod
import java.time.LocalDate
// Типы событий изменения настроек для старого интерфейса
sealed class BasicSettingChange {
data class CycleLengthChanged(val days: Int) : BasicSettingChange()
data class CycleVariabilityChanged(val days: Int) : BasicSettingChange()
data class PeriodLengthChanged(val days: Int) : BasicSettingChange()
data class LutealPhaseChanged(val days: String) : BasicSettingChange() // "auto" или число
data class LastPeriodStartChanged(val date: LocalDate) : BasicSettingChange()
}
sealed class StatusChange {
data class HormonalContraceptionChanged(val type: HormonalContraception) : StatusChange()
data class PregnancyStatusChanged(val isPregnant: Boolean) : StatusChange()
data class PostpartumStatusChanged(val isPostpartum: Boolean) : StatusChange()
data class LactatingStatusChanged(val isLactating: Boolean) : StatusChange()
data class PerimenopauseStatusChanged(val perimenopause: Boolean) : StatusChange()
// Вложенные объекты для удобного создания событий
object Pregnant {
fun changed(value: Boolean) = PregnancyStatusChanged(value)
}
object Postpartum {
fun changed(value: Boolean) = PostpartumStatusChanged(value)
}
object Lactating {
fun changed(value: Boolean) = LactatingStatusChanged(value)
}
object Perimenopause {
fun changed(value: Boolean) = PerimenopauseStatusChanged(value)
}
}
sealed class HistorySetting {
data class HistoryWindowChanged(val cycles: Int) : HistorySetting()
data class ExcludeOutliersChanged(val exclude: Boolean) : HistorySetting()
// Вложенные объекты для более удобного обращения
object BaselineCycleLength {
fun changed(value: Int) = BasicSettingChange.CycleLengthChanged(value)
}
object CycleVariability {
fun changed(value: Int) = BasicSettingChange.CycleVariabilityChanged(value)
}
object PeriodLength {
fun changed(value: Int) = BasicSettingChange.PeriodLengthChanged(value)
}
object LutealPhase {
fun changed(value: String) = BasicSettingChange.LutealPhaseChanged(value)
}
object LastPeriodStart {
fun changed(value: LocalDate) = BasicSettingChange.LastPeriodStartChanged(value)
}
// Вложенные классы для UI
class WindowCycles(val cycles: Int) : HistorySetting()
class ExcludeOutliers(val exclude: Boolean) : HistorySetting()
}
sealed class SensorSetting {
data class TemperatureUnitChanged(val unit: TemperatureUnit) : SensorSetting()
data class BbtTimeWindowChanged(val timeWindow: String) : SensorSetting()
data class TimezoneChanged(val timezone: String) : SensorSetting()
// Вложенные классы для UI
class TempUnit(val unit: TemperatureUnit) : SensorSetting()
class BbtTimeWindow(val timeWindow: String) : SensorSetting()
class Timezone(val timezone: String) : SensorSetting()
}
sealed class NotificationSetting {
data class PeriodReminderDaysChanged(val days: Int) : NotificationSetting()
data class OvulationReminderDaysChanged(val days: Int) : NotificationSetting()
data class PmsWindowDaysChanged(val days: Int) : NotificationSetting()
data class DeviationAlertDaysChanged(val days: Int) : NotificationSetting()
data class FertileWindowModeChanged(val mode: FertileWindowMode) : NotificationSetting()
// Вложенные классы для UI
class PeriodReminder(val days: Int) : NotificationSetting()
class OvulationReminder(val days: Int) : NotificationSetting()
class PmsWindow(val days: Int) : NotificationSetting()
class DeviationAlert(val days: Int) : NotificationSetting()
class FertileWindowMode(val mode: kr.smartsoltech.wellshe.domain.models.FertileWindowMode) : NotificationSetting()
}
// Функции преобразования для всех типов
fun ovulationMethodFromString(value: String): OvulationMethod {
return when (value) {
"bbt" -> OvulationMethod.BBT
"lh_test" -> OvulationMethod.LH_TEST
"cervical_mucus" -> OvulationMethod.CERVICAL_MUCUS
"medical" -> OvulationMethod.MEDICAL
else -> OvulationMethod.AUTO
}
}
fun fertileWindowModeFromString(value: String): FertileWindowMode {
return when (value) {
"conservative" -> FertileWindowMode.CONSERVATIVE
"broad" -> FertileWindowMode.BROAD
else -> FertileWindowMode.BALANCED
}
}
fun hormonalContraceptionTypeFromString(value: String): HormonalContraception {
return when (value) {
"coc" -> HormonalContraception.COC
"iud" -> HormonalContraception.IUD
"implant" -> HormonalContraception.IMPLANT
"other" -> HormonalContraception.OTHER
else -> HormonalContraception.NONE
}
}
fun temperatureUnitFromString(value: String): TemperatureUnit {
return when (value.uppercase()) {
"F" -> TemperatureUnit.FAHRENHEIT
else -> TemperatureUnit.CELSIUS
}
}

View File

@@ -0,0 +1,2 @@
// Этот файл больше не используется, все классы перенесены в CycleSettingsEvents.kt

View File

@@ -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<LocalDate>() {
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<CycleSettingsJsonDto> = 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"
)
}

View File

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

View File

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

View File

@@ -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<JournalMedia>): String = Gson().toJson(media)
@TypeConverter
fun toMediaList(mediaString: String): List<JournalMedia> {
val type = object : TypeToken<List<JournalMedia>>() {}.type
return Gson().fromJson(mediaString, type)
}
}

View File

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

View File

@@ -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<JournalMedia> = emptyList()
)
// Модель медиафайла (картинка, видео, музыка)
data class JournalMedia(
val uri: String,
val type: MediaType
)
enum class MediaType {
IMAGE, VIDEO, AUDIO
}

View File

@@ -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<JournalEntryEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertEntry(entry: JournalEntryEntity): Long
@Update
suspend fun updateEntry(entry: JournalEntryEntity)
@Delete
suspend fun deleteEntry(entry: JournalEntryEntity)
}

View File

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

View File

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

View File

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

View File

@@ -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<String> = _cycleStability.asStateFlow()
private val _hydration = MutableStateFlow(85)
val hydration: StateFlow<Int> = _hydration.asStateFlow()
private val _weightChange = MutableStateFlow(-0.6f)
val weightChange: StateFlow<Float> = _weightChange.asStateFlow()
private val _caloriesBurned = MutableStateFlow(2900)
val caloriesBurned: StateFlow<Int> = _caloriesBurned.asStateFlow()
private val _weeklyInsight = MutableStateFlow("Лучшие тренировки приходятся на выходные; добавь лёгкое кардио в среду.")
val weeklyInsight: StateFlow<String> = _weeklyInsight.asStateFlow()
}

View File

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

View File

@@ -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<List<Int>>(listOf(1800, 1950, 2050, 1900, 2100, 2300, 2000))
val waterData: StateFlow<List<Int>> = _waterData.asStateFlow()
private val _todayWaterAmount = MutableStateFlow(1800)
val todayWaterAmount: StateFlow<Int> = _todayWaterAmount.asStateFlow()
private val _waterGoal = MutableStateFlow(2000) // 2л по умолчанию в мл
val waterGoal: StateFlow<Int> = _waterGoal.asStateFlow()
// Данные для вкладки "Вес"
private val _weightHistory = MutableStateFlow<List<Pair<String, Float>>>(
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<List<Pair<String, Float>>> = _weightHistory.asStateFlow()
private val _currentWeight = MutableStateFlow<Float?>(58.4f)
val currentWeight: StateFlow<Float?> = _currentWeight.asStateFlow()
private val _weeklyWeightDifference = MutableStateFlow(-0.6f)
val weeklyWeightDifference: StateFlow<Float> = _weeklyWeightDifference.asStateFlow()
// Данные для вкладки "Активность"
private val _activityData = MutableStateFlow<List<WorkoutData>>(
listOf(
WorkoutData("Пн", 250),
WorkoutData("Вт", 350),
WorkoutData("Ср", 410),
WorkoutData("Чт", 280),
WorkoutData("Пт", 500),
WorkoutData("Сб", 600),
WorkoutData("Вс", 420)
)
)
val activityData: StateFlow<List<WorkoutData>> = _activityData.asStateFlow()
private val _todayCaloriesBurned = MutableStateFlow(312)
val todayCaloriesBurned: StateFlow<Int> = _todayCaloriesBurned.asStateFlow()
private val _bestWorkoutDay = MutableStateFlow<Pair<String, Int>?>(Pair("Сб", 600))
val bestWorkoutDay: StateFlow<Pair<String, Int>?> = _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
)

View File

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

View File

@@ -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(
"График пот<D0BE><D182>ебления воды",
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))
}
}
}

View File

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

View File

@@ -0,0 +1,212 @@
package kr.smartsoltech.wellshe.ui.components
import androidx.compose.foundation.BorderStroke
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.Transparent,
border = BorderStroke(1.dp, color)
) {
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
)
)
}
}

View File

@@ -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("Добавить")
}
}
}
}
}
}

View File

@@ -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("Сохранить")
}
}
}
}
}
}

View File

@@ -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<Workout?>(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
)

View File

@@ -1,824 +1,200 @@
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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
onNavigateToSettings: () -> Unit = {} // Добавляем параметр для навигации к настройкам
) {
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)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
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
Scaffold(
topBar = {
TopAppBar(
title = { Text("Менструальный цикл") },
actions = {
IconButton(onClick = onNavigateToSettings) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = "Настройки цикла"
)
)
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)
) {
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.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<String>,
selectedMood: String,
onSymptomsUpdate: (List<String>) -> 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<LocalDate, LocalDate>?,
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<String>,
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)
) {
QuickActionsCard(
onMarkStart = { },
onMarkEnd = { },
onAddSymptom = { },
onAddNote = { }
)
)
}
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?.nextPeriodStart?.format(
DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
) ?: "",
tone = Color(0xFF2196F3),
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
)
)
}
}
}
}
}
}
@Composable
private fun PeriodHistoryCard(
recentPeriods: List<CyclePeriodEntity>,
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) }
StatCard(
title = "Овуляция",
value = previewState.forecast?.nextOvulation?.format(
DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
) ?: "",
tone = Color(0xFF4CAF50),
modifier = Modifier.weight(1f)
)
if (period != recentPeriods.last()) {
Spacer(modifier = Modifier.height(12.dp))
}
}
}
}
}
}
@Preview(showBackground = true, locale = "ru")
@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 CycleScreenPreviewRussian() {
CycleScreenPreview()
}
@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable
fun CycleScreenPreviewDark() {
CycleScreenPreview()
}

View File

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

View File

@@ -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<LocalDate, LocalDate>? = null,
// Симптомы и настроение сегодня
val todaySymptoms: List<String> = emptyList(),
val todayMood: String = "",
val showSymptomsEdit: Boolean = false,
// Аналитика
val averageCycleLength: Float = 0f,
val insights: List<String> = emptyList(),
val recentPeriods: List<CyclePeriodEntity> = emptyList(),
// Параметры цикла пользователя
val cycleSettings: CycleSettings = CycleSettings(
baselineLength = 28,
periodLength = 5,
lutealDays = 14,
lastPeriodStart = LocalDate.now().minusDays(14)
),
// Состояние загрузки и ошибки
val isLoading: Boolean = false,
val error: String? = null
)

View File

@@ -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<LocalDate, LocalDate>? = null,
val recentPeriods: List<CyclePeriodEntity> = emptyList(),
val averageCycleLength: Float = 0f,
val insights: List<String> = emptyList(),
val showSymptomsEdit: Boolean = false,
val todaySymptoms: List<String> = 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<CycleUiState> = _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<CyclePeriodEntity> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState
// Состояние для отображения диалога настроек
private val _showSettingsDialog = MutableStateFlow(false)
val showSettingsDialog: StateFlow<Boolean> = _showSettingsDialog
// События навигации
private val _navigationEvents = MutableStateFlow<NavigationEvent?>(null)
val navigationEvents: StateFlow<NavigationEvent?> = _navigationEvents
// История веса
private val _weightHistory = MutableStateFlow<Map<LocalDate, List<Float>>>(emptyMap())
val weightHistory: StateFlow<Map<LocalDate, List<Float>>> = _weightHistory
// История спорта
private val _sportHistory = MutableStateFlow<Map<LocalDate, List<String>>>(emptyMap())
val sportHistory: StateFlow<Map<LocalDate, List<String>>> = _sportHistory
// История воды
private val _waterHistory = MutableStateFlow<Map<LocalDate, List<Int>>>(emptyMap())
val waterHistory: StateFlow<Map<LocalDate, List<Int>>> = _waterHistory
init {
loadCycleData()
updateForecast()
updateWeekInsight()
}
fun openSettingsDialog() {
_showSettingsDialog.value = true
}
fun closeSettingsDialog() {
_showSettingsDialog.value = false
}
fun clearNavigationEvent() {
_navigationEvents.value = null
}
fun saveCycleSettings(newSettings: CycleSettings) {
_uiState.update { it.copy(cycleSettings = newSettings) }
closeSettingsDialog()
updateForecast()
updateWeekInsight()
}
fun updateCycleSettings(newSettings: CycleSettings) {
_uiState.update { it.copy(cycleSettings = newSettings) }
}
private fun updateForecast() {
val forecast = computeForecast(_uiState.value.cycleSettings)
_uiState.update { it.copy(forecast = forecast) }
}
private fun updateWeekInsight() {
val forecast = _uiState.value.forecast ?: return
val now = LocalDate.now()
val insight = "ПМС с ${fmt(forecast.pmsStart)}; окно фертильности: ${fmt(forecast.fertileStart)}${fmt(forecast.fertileEnd)}"
_uiState.update { it.copy(weekInsight = insight) }
}
fun loadCycleData() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
_uiState.update { it.copy(isLoading = true) }
try {
// Загружаем текущий период
repository.getCurrentCyclePeriod().collect { currentPeriod ->
val isPeriodActive = currentPeriod != null && currentPeriod.endDate == null
_uiState.value = _uiState.value.copy(
isPeriodActive = isPeriodActive,
isLoading = false
)
// Вычисляем текущий день цикла и фазу
calculateCycleInfo(currentPeriod)
}
// Загружаем историю периодов
repository.getRecentPeriods().collect { periods ->
val averageLength = calculateAverageCycleLength(periods)
val insights = generateCycleInsights(periods)
_uiState.value = _uiState.value.copy(
recentPeriods = periods,
averageCycleLength = averageLength,
insights = insights
)
}
// Загружаем настройки цикла пользователя
repository.getUserProfile().collect { user ->
_uiState.value = _uiState.value.copy(
cycleLength = user.cycleLength
)
}
val periods = cycleRepository.getAllPeriods()
_uiState.update { it.copy(recentPeriods = periods, isLoading = false) }
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message
)
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
}
}
private fun calculateCycleInfo(currentPeriod: CyclePeriodEntity?) {
val today = LocalDate.now()
val cycleLength = _uiState.value.cycleLength
fun prevMonth() {
_uiState.update { it.copy(month = it.month.minusMonths(1)) }
}
if (currentPeriod != null) {
val daysSinceStart = ChronoUnit.DAYS.between(currentPeriod.startDate, today).toInt() + 1
val currentCycleDay = if (daysSinceStart > cycleLength) {
// Если прошло больше дней чем длина цикла, начинаем новый цикл
(daysSinceStart - 1) % cycleLength + 1
fun nextMonth() {
_uiState.update { it.copy(month = it.month.plusMonths(1)) }
}
fun markPeriodStart() {
viewModelScope.launch {
val today = LocalDate.now()
val newPeriod = CyclePeriodEntity(startDate = today, endDate = null)
cycleRepository.insertPeriod(newPeriod)
loadCycleData()
// Обновляем настройки цикла с новой датой начала
val newSettings = _uiState.value.cycleSettings.copy(lastPeriodStart = today)
_uiState.update { it.copy(cycleSettings = newSettings) }
updateForecast()
updateWeekInsight()
}
}
fun markPeriodEnd() {
viewModelScope.launch {
val today = LocalDate.now()
val latestPeriod = _uiState.value.recentPeriods.firstOrNull { it.endDate == null }
latestPeriod?.let {
val updatedPeriod = it.copy(endDate = today)
cycleRepository.updatePeriod(updatedPeriod)
loadCycleData()
}
}
}
fun addSymptom() {
// Заглушка для демонстрации
}
fun addNote() {
// Заглушка для демонстрации
}
fun addSportActivity(date: LocalDate, activity: String) {
if (activity.isNotBlank()) {
_sportHistory.update { currentHistory ->
val updatedActivities = (currentHistory[date] ?: emptyList()) + activity
currentHistory.toMutableMap().apply { put(date, updatedActivities) }
}
}
}
fun removeSportActivity(date: LocalDate, activity: String) {
_sportHistory.update { currentHistory ->
val updatedActivities = (currentHistory[date] ?: emptyList()).filter { it != activity }
val updatedMap = currentHistory.toMutableMap()
if (updatedActivities.isEmpty()) {
updatedMap.remove(date)
} else {
daysSinceStart
updatedMap[date] = updatedActivities
}
val phase = calculatePhase(currentCycleDay, cycleLength)
val daysUntilNext = cycleLength - currentCycleDay
// Прогнозы
val nextPeriodDate = currentPeriod.startDate.plusDays(cycleLength.toLong())
val ovulationDay = cycleLength / 2 // Примерно в середине цикла
val ovulationDate = currentPeriod.startDate.plusDays(ovulationDay.toLong())
val fertilityStart = ovulationDate.minusDays(5)
val fertilityEnd = ovulationDate.plusDays(1)
_uiState.value = _uiState.value.copy(
currentCycleDay = currentCycleDay,
currentPhase = phase,
daysUntilNextPeriod = daysUntilNext.coerceAtLeast(0),
nextPeriodDate = nextPeriodDate,
ovulationDate = ovulationDate,
fertilityWindow = Pair(fertilityStart, fertilityEnd)
)
} else {
// Нет данных о текущем цикле
_uiState.value = _uiState.value.copy(
currentCycleDay = 1,
currentPhase = "Нет данных",
daysUntilNextPeriod = 0,
nextPeriodDate = null,
ovulationDate = null,
fertilityWindow = null
)
updatedMap
}
}
private fun calculatePhase(cycleDay: Int, cycleLength: Int): String {
return when {
cycleDay <= 5 -> "Менструация"
cycleDay <= cycleLength / 2 - 2 -> "Фолликулярная"
cycleDay <= cycleLength / 2 + 2 -> "Овуляция"
else -> "Лютеиновая"
}
}
private fun calculateAverageCycleLength(periods: List<CyclePeriodEntity>): Float {
if (periods.size < 2) return 0f
val cycleLengths = mutableListOf<Int>()
for (i in 0 until periods.size - 1) {
val currentPeriod = periods[i]
val nextPeriod = periods[i + 1]
val length = ChronoUnit.DAYS.between(nextPeriod.startDate, currentPeriod.startDate).toInt()
if (length > 0) {
cycleLengths.add(length)
}
}
return if (cycleLengths.isNotEmpty()) {
cycleLengths.average().toFloat()
} else {
0f
}
}
private fun generateCycleInsights(periods: List<CyclePeriodEntity>): List<String> {
val insights = mutableListOf<String>()
if (periods.size >= 3) {
val averageLength = calculateAverageCycleLength(periods)
when {
averageLength < 21 -> {
insights.add("Ваши циклы короче обычного. Рекомендуем консультацию с врачом.")
}
averageLength > 35 -> {
insights.add("Ваши циклы длиннее обычного. Стоит обратиться к специалисту.")
}
else -> {
insights.add("Длина ваших циклов в пределах нормы.")
}
}
// Анализ регулярности
val cycleLengths = mutableListOf<Int>()
for (i in 0 until periods.size - 1) {
val length = ChronoUnit.DAYS.between(periods[i + 1].startDate, periods[i].startDate).toInt()
if (length > 0) cycleLengths.add(length)
}
if (cycleLengths.size >= 2) {
val deviation = cycleLengths.map { kotlin.math.abs(it - averageLength) }.average()
if (deviation <= 3) {
insights.add("У вас очень регулярный цикл.")
} else if (deviation <= 7) {
insights.add("Ваш цикл достаточно регулярный.")
} else {
insights.add("Циклы нерегулярные. Рекомендуем отслеживать факторы, влияющие на цикл.")
}
}
// Анализ симптомов
val symptomsData = periods.mapNotNull { period ->
period.symptoms.split(",").filter { it.isNotBlank() }
}.flatten()
if (symptomsData.isNotEmpty()) {
val commonSymptoms = symptomsData.groupBy { it }.maxByOrNull { it.value.size }?.key
if (commonSymptoms != null) {
insights.add("Наиболее частый симптом: $commonSymptoms")
}
}
}
return insights
}
fun startPeriod() {
viewModelScope.launch {
try {
val today = LocalDate.now()
repository.addPeriod(
startDate = today,
endDate = null,
flow = "Средний",
symptoms = emptyList(),
mood = ""
)
_uiState.value = _uiState.value.copy(isPeriodActive = true)
loadCycleData() // Перезагружаем данные
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
fun addWaterRecord(date: LocalDate, amount: Int) {
if (amount > 0) {
_waterHistory.update { currentHistory ->
val updatedAmounts = (currentHistory[date] ?: emptyList()) + amount
currentHistory.toMutableMap().apply { put(date, updatedAmounts) }
}
}
}
fun endPeriod() {
viewModelScope.launch {
try {
val today = LocalDate.now()
val currentPeriod = _uiState.value.recentPeriods.firstOrNull { it.endDate == null }
fun removeWaterRecord(date: LocalDate, index: Int) {
_waterHistory.update { currentHistory ->
val current = currentHistory[date] ?: return@update currentHistory
if (index < 0 || index >= current.size) return@update currentHistory
if (currentPeriod != null) {
repository.addPeriod(
startDate = currentPeriod.startDate,
endDate = today,
flow = currentPeriod.flow,
symptoms = currentPeriod.symptoms.split(","),
mood = currentPeriod.mood
)
val updated = current.toMutableList().apply { removeAt(index) }
val updatedMap = currentHistory.toMutableMap()
if (updated.isEmpty()) {
updatedMap.remove(date)
} else {
updatedMap[date] = updated
}
updatedMap
}
}
_uiState.value = _uiState.value.copy(isPeriodActive = false)
loadCycleData() // Перезагружаем данные
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
fun addOrUpdateWeight(date: LocalDate, weight: Float) {
if (weight > 0) {
_weightHistory.update { currentHistory ->
val updatedWeights = (currentHistory[date] ?: emptyList()) + weight
currentHistory.toMutableMap().apply { put(date, updatedWeights) }
}
}
}
fun toggleSymptomsEdit() {
_uiState.value = _uiState.value.copy(
showSymptomsEdit = !_uiState.value.showSymptomsEdit
)
}
fun updateSymptoms(symptoms: List<String>) {
_uiState.value = _uiState.value.copy(todaySymptoms = symptoms)
}
fun updateMood(mood: String) {
_uiState.value = _uiState.value.copy(todayMood = mood)
}
fun saveTodayData() {
viewModelScope.launch {
try {
val today = LocalDate.now()
val symptoms = _uiState.value.todaySymptoms
val mood = _uiState.value.todayMood
// TODO: Сохранить симптомы и настроение за сегодня
// Это может быть отдельная таблица или обновление текущего периода
_uiState.value = _uiState.value.copy(
showSymptomsEdit = false,
todaySymptoms = emptyList(),
todayMood = ""
)
loadCycleData() // Перезагружаем данные
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
private fun fmt(date: LocalDate): String {
return date.format(DateTimeFormatter.ofPattern("dd MMM"))
}
}

View File

@@ -0,0 +1,128 @@
package kr.smartsoltech.wellshe.ui.cycle
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kr.smartsoltech.wellshe.model.JournalEntry
import kr.smartsoltech.wellshe.model.JournalMedia
import kr.smartsoltech.wellshe.model.MediaType
import androidx.compose.foundation.background
@Composable
fun JournalEditorDialog(
initialEntry: JournalEntry?,
onSave: (JournalEntry) -> Unit,
onDelete: () -> Unit,
onDismiss: () -> Unit
) {
var text by remember { mutableStateOf(initialEntry?.text ?: "") }
var media by remember { mutableStateOf(initialEntry?.media ?: emptyList()) }
var showMediaPicker by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
Button(onClick = {
onSave(
initialEntry?.copy(text = text, media = media) ?: JournalEntry(
id = 0L,
date = initialEntry?.date ?: java.time.LocalDate.now(),
text = text,
media = media
)
)
}) {
Text("Сохранить")
}
},
dismissButton = {
if (initialEntry != null) {
Button(onClick = onDelete, colors = ButtonDefaults.buttonColors(containerColor = Color.Red)) {
Text("Удалить", color = Color.White)
}
}
Button(onClick = onDismiss) {
Text("Отмена")
}
},
title = { Text(if (initialEntry == null) "Новая запись" else "Редактировать запись") },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
// Форматирование текста (минимум: жирный, курсив)
Row(modifier = Modifier.padding(bottom = 8.dp)) {
IconButton(onClick = { text += "**жирный**" }) {
Icon(Icons.Default.FormatBold, contentDescription = "Жирный")
}
IconButton(onClick = { text += "*курсив*" }) {
Icon(Icons.Default.FormatItalic, contentDescription = "Курсив")
}
IconButton(onClick = { text += "- элемент списка\n" }) {
Icon(Icons.Default.FormatListBulleted, contentDescription = "Список")
}
}
BasicTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.background(Color(0xFFF8F9FA), shape = MaterialTheme.shapes.small)
.padding(8.dp)
)
Spacer(Modifier.height(8.dp))
// Медиа
Row {
Button(onClick = { showMediaPicker = true }) {
Icon(Icons.Default.AddPhotoAlternate, contentDescription = "Добавить медиа")
Text("Медиа")
}
}
if (media.isNotEmpty()) {
Column {
media.forEach { m ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
when (m.type) {
MediaType.IMAGE -> Icons.Default.Image
MediaType.VIDEO -> Icons.Default.Videocam
MediaType.AUDIO -> Icons.Default.MusicNote
},
contentDescription = null
)
Text(m.uri)
IconButton(onClick = { media = media - m }) {
Icon(Icons.Default.Delete, contentDescription = "Удалить медиа")
}
}
}
}
}
// Примитивный медиа-пикер (заглушка)
if (showMediaPicker) {
AlertDialog(
onDismissRequest = { showMediaPicker = false },
confirmButton = {
Button(onClick = {
// Пример добавления картинки (заглушка)
media = media + JournalMedia(uri = "media_uri_example", type = MediaType.IMAGE)
showMediaPicker = false
}) {
Text("Добавить картинку")
}
},
title = { Text("Добавить медиа") },
text = { Text("Здесь будет выбор медиафайла") }
)
}
}
}
)
}

View File

@@ -0,0 +1,59 @@
package kr.smartsoltech.wellshe.ui.cycle
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.time.LocalDate
@Composable
fun ModernDatePickerDialog(
onDateSelected: (LocalDate) -> Unit,
onDismiss: () -> Unit
) {
var selectedDate by remember { mutableStateOf(LocalDate.now()) }
val daysInMonth = selectedDate.lengthOfMonth()
val monthDates = (1..daysInMonth).map { selectedDate.withDayOfMonth(it) }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
Button(onClick = { onDateSelected(selectedDate) }) { Text("Выбрать") }
},
dismissButton = {
Button(onClick = onDismiss) { Text("Отмена") }
},
title = { Text("Выбор даты") },
text = {
Column {
// Горизонтальный календарь
LazyRow(Modifier.fillMaxWidth()) {
items(monthDates) { date ->
val isSelected = date == selectedDate
Button(
onClick = { selectedDate = date },
colors = ButtonDefaults.buttonColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
),
modifier = Modifier.padding(2.dp)
) {
Text("${date.dayOfMonth}")
}
}
}
Spacer(Modifier.height(8.dp))
// Ручной ввод
OutlinedTextField(
value = selectedDate.toString(),
onValueChange = {
runCatching { selectedDate = LocalDate.parse(it) }
},
label = { Text("Дата (ГГГГ-ММ-ДД)") },
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
)
}
}
)
}

View File

@@ -0,0 +1,49 @@
package kr.smartsoltech.wellshe.ui.cycle
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.time.LocalDate
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.foundation.background
@Composable
fun SportContent(viewModel: CycleViewModel) {
val sportHistory = viewModel.sportHistory.collectAsState()
var selectedDate by remember { mutableStateOf(LocalDate.now()) }
var activityInput by remember { mutableStateOf("") }
val activities = sportHistory.value[selectedDate] ?: emptyList()
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text("Спорт", style = MaterialTheme.typography.titleLarge)
OutlinedTextField(
value = activityInput,
onValueChange = { activityInput = it },
label = { Text("Активность") },
modifier = Modifier.fillMaxWidth()
)
Button(onClick = {
viewModel.addSportActivity(selectedDate, activityInput)
activityInput = ""
}, modifier = Modifier.fillMaxWidth()) {
Text("Добавить")
}
Spacer(Modifier.height(8.dp))
Text("Активности за выбранный день:", style = MaterialTheme.typography.titleMedium)
LazyColumn {
items(activities) { act ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(act, style = MaterialTheme.typography.bodyLarge)
IconButton(onClick = { viewModel.removeSportActivity(selectedDate, act) }) {
Icon(Icons.Default.Delete, contentDescription = "Удалить")
}
}
}
}
}
}

View File

@@ -0,0 +1,57 @@
package kr.smartsoltech.wellshe.ui.cycle
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.time.LocalDate
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import kotlinx.coroutines.launch
import androidx.compose.foundation.background
@Composable
fun WaterContent(viewModel: CycleViewModel) {
val waterHistory by viewModel.waterHistory.collectAsState()
var selectedDate by remember { mutableStateOf<LocalDate>(LocalDate.now()) }
var waterInput by remember { mutableStateOf<String>("") }
val records = waterHistory[selectedDate] ?: emptyList<Int>()
val scope = rememberCoroutineScope()
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text("Вода", style = MaterialTheme.typography.titleLarge)
OutlinedTextField(
value = waterInput,
onValueChange = { waterInput = it },
label = { Text("Выпито (мл)") },
modifier = Modifier.fillMaxWidth()
)
Button(onClick = {
scope.launch {
viewModel.addWaterRecord(selectedDate, waterInput.toIntOrNull() ?: 0)
}
waterInput = ""
}, modifier = Modifier.fillMaxWidth()) {
Text("Добавить")
}
Spacer(Modifier.height(8.dp))
Text("Записи за выбранный день:", style = MaterialTheme.typography.titleMedium)
LazyColumn {
items(records) { rec ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("${rec} мл", style = MaterialTheme.typography.bodyLarge)
IconButton(onClick = {
scope.launch {
viewModel.removeWaterRecord(selectedDate, rec)
}
}) {
Icon(Icons.Default.Delete, contentDescription = "Удалить")
}
}
}
}
}
}

View File

@@ -0,0 +1,74 @@
package kr.smartsoltech.wellshe.ui.cycle
import java.time.LocalDate
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Composable
fun WeightContent(viewModel: CycleViewModel) {
val weightHistory by viewModel.weightHistory.collectAsState()
var selectedDate by remember { mutableStateOf<LocalDate>(LocalDate.now()) }
var weightInput by remember { mutableStateOf<String>("") }
val daysInMonth = selectedDate.lengthOfMonth()
val monthDates = (1..daysInMonth).map { selectedDate.withDayOfMonth(it) }
val weights = weightHistory[selectedDate] ?: emptyList<Float>()
val scope = rememberCoroutineScope()
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text("Вес", style = MaterialTheme.typography.titleLarge)
LazyRow(Modifier.fillMaxWidth()) {
items(monthDates) { date ->
val isSelected = date == selectedDate
Button(
onClick = { selectedDate = date },
colors = ButtonDefaults.buttonColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
),
modifier = Modifier.padding(2.dp)
) {
Text("${date.dayOfMonth}")
}
}
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = weightInput,
onValueChange = { weightInput = it },
label = { Text("Вес (кг)") },
modifier = Modifier.fillMaxWidth()
)
Button(onClick = {
scope.launch {
viewModel.addOrUpdateWeight(selectedDate, weightInput.toFloatOrNull() ?: 0f)
}
weightInput = ""
}, modifier = Modifier.fillMaxWidth()) {
Text("Сохранить")
}
Spacer(Modifier.height(8.dp))
Text("История за выбранный день:", style = MaterialTheme.typography.titleMedium)
weights.forEach { w: Float ->
Text("${w} кг", style = MaterialTheme.typography.bodyLarge)
}
Spacer(Modifier.height(16.dp))
Text("График веса за месяц:", style = MaterialTheme.typography.titleMedium)
// Примитивный график
Row(Modifier.fillMaxWidth()) {
monthDates.forEach { date ->
val ws = weightHistory[date]
val weight = ws?.lastOrNull() ?: 0f
Box(Modifier.height((weight * 2).dp).width(8.dp).background(MaterialTheme.colorScheme.primary))
}
}
}
}

View File

@@ -0,0 +1,244 @@
package kr.smartsoltech.wellshe.ui.cycle.components
import androidx.compose.foundation.layout.*
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kr.smartsoltech.wellshe.model.CycleForecast
import kr.smartsoltech.wellshe.ui.theme.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.*
/**
* Вычисление аналитики цикла для отображения текущего статуса
*/
@Composable
fun CycleStatusCard(
forecast: CycleForecast?,
modifier: Modifier = Modifier
) {
if (forecast == null) return
val today = LocalDate.now()
val cycleAnalytics = computeCycleAnalytics(today, forecast)
val dateFormatter = DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = CycleTabColor.copy(alpha = 0.15f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Статус на сегодня",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
// Текущий день цикла и фаза
Text(
text = "День цикла ${cycleAnalytics.cycleDay} (${cycleAnalytics.phaseName} фаза)",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Divider(modifier = Modifier.padding(vertical = 4.dp))
// Ближайшие события
Text(
text = "Ближайшие события:",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
// Показываем информацию о фертильном окне
if (!cycleAnalytics.flags.isFertileToday) {
val fertileStartFormatted = cycleAnalytics.ranges.fertile.first.format(dateFormatter)
val fertileEndFormatted = cycleAnalytics.ranges.fertile.second.format(dateFormatter)
Text(
text = "• Фертильное окно: $fertileStartFormatted$fertileEndFormatted",
style = MaterialTheme.typography.bodyMedium
)
} else {
Text(
text = "• Фертильное окно: сейчас",
style = MaterialTheme.typography.bodyMedium
)
}
// Информация о дне овуляции
val ovulationFormatted = cycleAnalytics.next.ovulation.format(dateFormatter)
val daysToOvulation = cycleAnalytics.deltas.daysToOvulation
val ovulationText = when {
daysToOvulation < 0 -> "• Овуляция: была $ovulationFormatted (${-daysToOvulation} дн. назад)"
daysToOvulation == 0 -> "• Овуляция: сегодня"
else -> "• Овуляция: $ovulationFormatted (через $daysToOvulation дн.)"
}
Text(
text = ovulationText,
style = MaterialTheme.typography.bodyMedium
)
// Информация о ПМС
if (cycleAnalytics.flags.isPMSToday) {
Text(
text = "• ПМС: сейчас",
style = MaterialTheme.typography.bodyMedium
)
} else if (today.isBefore(cycleAnalytics.ranges.pms.first)) {
val pmsStartFormatted = cycleAnalytics.ranges.pms.first.format(dateFormatter)
Text(
text = "• ПМС: с $pmsStartFormatted",
style = MaterialTheme.typography.bodyMedium
)
}
// Информация о следующей менструации
val nextPeriodFormatted = cycleAnalytics.next.nextPeriodStart.format(dateFormatter)
val daysToNextPeriod = cycleAnalytics.deltas.daysToNextPeriod
val nextPeriodText = when {
cycleAnalytics.flags.isMenstruationToday -> "• Менструация: сейчас"
else -> "• След. менструация: $nextPeriodFormatted (через $daysToNextPeriod дн.)"
}
Text(
text = nextPeriodText,
style = MaterialTheme.typography.bodyMedium
)
// Дополнительная подсказка о текущем статусе
if (cycleAnalytics.flags.isMenstruationToday ||
cycleAnalytics.flags.isFertileToday ||
cycleAnalytics.flags.isOvulationToday ||
cycleAnalytics.flags.isPMSToday) {
Divider(modifier = Modifier.padding(vertical = 4.dp))
val tipText = when {
cycleAnalytics.flags.isOvulationToday -> "Пик вероятности зачатия сегодня. Важно следить за самочувствием, возможны изменения настроения и энергии."
cycleAnalytics.flags.isMenstruationToday -> "В эти дни важно обеспечить организму покой и комфорт. Следите за самочувствием и избегайте чрезмерных нагрузок."
cycleAnalytics.flags.isFertileToday -> "Повышенная вероятность зачатия. Хорошее время для физической активности и новых начинаний."
cycleAnalytics.flags.isPMSToday -> "Возможны колебания настроения и некоторый дискомфорт. Важно следить за гидратацией и сбалансированным питанием."
else -> ""
}
Text(
text = tipText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/**
* Вычисление аналитических данных цикла
*/
private fun computeCycleAnalytics(today: LocalDate, forecast: CycleForecast): CycleAnalytics {
// Используем поля из модели CycleForecast
val nextPeriodStart = forecast.nextPeriodStart
val ovulation = forecast.nextOvulation
val fertileStart = forecast.fertileStart
val fertileEnd = forecast.fertileEnd
val pmsStart = forecast.pmsStart
val periodEnd = forecast.periodEnd
// Вычисляем примерную дату начала текущего цикла
val estimatedCycleStart = if (today < nextPeriodStart) {
nextPeriodStart.minusDays(28) // Примерная длина цикла, если точных данных нет
} else {
nextPeriodStart
}
// Считаем текущий день цикла
val cycleDay = ChronoUnit.DAYS.between(estimatedCycleStart, today) + 1
// Определяем фазы
val isMenstruationToday = today in nextPeriodStart..periodEnd
val isFertileToday = today in fertileStart..fertileEnd
val isOvulationToday = today == ovulation
val isPMSToday = today in pmsStart..nextPeriodStart.minusDays(1)
// Определяем текущую фазу
val phaseName = when {
isMenstruationToday -> "Менструация"
today.isBefore(ovulation) -> "Фолликулярная"
today == ovulation -> "Овуляция"
today.isAfter(ovulation) -> "Лютеиновая"
else -> "Неопределена"
}
// Вычисляем дни до следующих событий
val daysToOvulation = ChronoUnit.DAYS.between(today, ovulation)
val daysToNextPeriod = ChronoUnit.DAYS.between(today, nextPeriodStart)
return CycleAnalytics(
cycleDay = cycleDay.toInt(),
phaseName = phaseName,
ranges = CycleRanges(
period = Pair(nextPeriodStart, periodEnd),
fertile = Pair(fertileStart, fertileEnd),
pms = Pair(pmsStart, nextPeriodStart.minusDays(1))
),
next = NextEvents(
ovulation = ovulation,
nextPeriodStart = nextPeriodStart
),
flags = CycleFlags(
isMenstruationToday = isMenstruationToday,
isFertileToday = isFertileToday,
isOvulationToday = isOvulationToday,
isPMSToday = isPMSToday
),
deltas = TimingDeltas(
daysToOvulation = daysToOvulation.toInt(),
daysToNextPeriod = daysToNextPeriod.toInt()
)
)
}
/**
* Класс для аналитики цикла
*/
data class CycleAnalytics(
val cycleDay: Int,
val phaseName: String,
val ranges: CycleRanges,
val next: NextEvents,
val flags: CycleFlags,
val deltas: TimingDeltas
)
data class CycleRanges(
val period: Pair<LocalDate, LocalDate>,
val fertile: Pair<LocalDate, LocalDate>,
val pms: Pair<LocalDate, LocalDate>
)
data class NextEvents(
val ovulation: LocalDate,
val nextPeriodStart: LocalDate
)
data class CycleFlags(
val isMenstruationToday: Boolean,
val isFertileToday: Boolean,
val isOvulationToday: Boolean,
val isPMSToday: Boolean
)
data class TimingDeltas(
val daysToOvulation: Int,
val daysToNextPeriod: Int
)

View File

@@ -0,0 +1,261 @@
package kr.smartsoltech.wellshe.ui.cycle.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kr.smartsoltech.wellshe.model.CycleForecast
import kr.smartsoltech.wellshe.ui.components.PhasePill
import kr.smartsoltech.wellshe.ui.theme.*
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.*
/**
* Карточка календаря цикла
*/
@Composable
fun CycleCalendarCard(
month: YearMonth,
onPrev: () -> Unit,
onNext: () -> Unit,
forecast: CycleForecast?,
modifier: Modifier = Modifier,
onOpenSettings: () -> Unit = {} // Добавлен новый параметр для обработки нажатия на кнопку настроек
) {
val daysOfWeek = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
val monthFormat = DateTimeFormatter.ofPattern("LLLL yyyy", Locale("ru"))
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = Color.Transparent
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Заголовок карточки с иконкой и кнопкой настроек
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
imageVector = Icons.Default.WbSunny,
contentDescription = null,
tint = Color(0xFFF9A825)
)
Text(
text = "Календарь цикла",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
// Кнопка настроек цикла
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Filled.Tune,
contentDescription = "Настройки цикла",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Заголовок месяца с кнопками навигации
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onPrev) {
Icon(
imageVector = Icons.Default.KeyboardArrowLeft,
contentDescription = "Предыдущий месяц"
)
}
Text(
text = month.format(monthFormat).replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
IconButton(onClick = onNext) {
Icon(
imageVector = Icons.Default.KeyboardArrowRight,
contentDescription = "Следующий месяц"
)
}
}
// Дни недели (заголовки)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
daysOfWeek.forEach { day ->
Text(
text = day,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
// Сетка дней календаря
CalendarGrid(month, forecast)
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Легенда фаз
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
PhasePill(label = "Менструация", color = PeriodColor)
PhasePill(label = "Фертильное окно", color = FertileColor)
PhasePill(label = "Овуляция", color = OvulationColor)
PhasePill(label = "ПМС", color = PmsColor)
}
// Блок расширенной аналитики "Статус на сегодня"
if (forecast != null) {
Spacer(modifier = Modifier.height(16.dp))
CycleStatusCard(forecast = forecast)
}
}
}
}
/**
* Сетка календаря с днями месяца
*/
@Composable
fun CalendarGrid(
month: YearMonth,
forecast: CycleForecast?
) {
// Получаем текущую дату
val today = LocalDate.now()
// Получаем первый день месяца и последний день месяца
val firstDay = month.atDay(1)
val lastDay = month.atEndOfMonth()
// Вычисляем смещение первого дня месяца
val dayOfWeekValue = firstDay.dayOfWeek.value // 1 для понедельника, 7 для воскресенья
val firstDayOffset = dayOfWeekValue - 1 // Смещение (0 для понедельника)
// Создаем полную сетку календаря (6 недель по 7 дней)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
for (week in 0 until 6) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
for (dayOfWeek in 0 until 7) {
val dayNumber = week * 7 + dayOfWeek - firstDayOffset + 1
val isValidDay = dayNumber in 1..lastDay.dayOfMonth
if (isValidDay) {
val date = month.atDay(dayNumber)
CalendarDay(date = date, today = today, forecast = forecast, modifier = Modifier.weight(1f))
} else {
// Пустая ячейка для выравнивания
Box(
modifier = Modifier
.size(36.dp)
.weight(1f)
)
}
}
}
}
}
}
/**
* Отображение одного дня календаря
*/
@Composable
fun CalendarDay(
date: LocalDate,
today: LocalDate,
forecast: CycleForecast?,
modifier: Modifier = Modifier
) {
// Определяем, в какой фазе цикла находится день
val isPeriod = forecast?.let { date in it.nextPeriodStart..it.periodEnd } ?: false
val isFertile = forecast?.let { date in it.fertileStart..it.fertileEnd } ?: false
val isPms = forecast?.let { date in it.pmsStart..it.nextPeriodStart.minusDays(1) } ?: false
val isOvulation = forecast?.let { date == it.nextOvulation } ?: false
val isToday = date == today
// Создаем базовый модификатор для всех дней
var finalModifier = modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
// Добавляем контур в зависимости от фазы (приоритет: Ovulation > Period > Fertile > PMS)
finalModifier = when {
isOvulation -> finalModifier
.border(BorderStroke(2.dp, OvulationColor), RoundedCornerShape(8.dp))
isPeriod -> finalModifier
.border(BorderStroke(1.dp, PeriodColor), RoundedCornerShape(8.dp))
isFertile -> finalModifier
.border(BorderStroke(1.dp, FertileColor), RoundedCornerShape(8.dp))
isPms -> finalModifier
.border(BorderStroke(1.dp, PmsColor), RoundedCornerShape(8.dp))
else -> finalModifier
.border(BorderStroke(1.dp, Color.Transparent), RoundedCornerShape(8.dp))
}
// Добавляем дополнительный тонкий контур для выделения сегодняшнего дня
if (isToday && !isOvulation && !isPeriod && !isFertile && !isPms) {
finalModifier = finalModifier.border(
BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.6f)),
RoundedCornerShape(8.dp)
)
}
Box(
modifier = finalModifier,
contentAlignment = Alignment.Center
) {
Text(
text = date.dayOfMonth.toString(),
style = MaterialTheme.typography.labelMedium,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface
)
}
}

View File

@@ -0,0 +1,17 @@
package kr.smartsoltech.wellshe.ui.cycle.components
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kr.smartsoltech.wellshe.model.CycleForecast
import java.time.YearMonth
/**
* Этот файл содержал дубликаты функций, определённых в CycleCalendar.kt
* Для устранения конфликтов при компиляции, функции CycleCalendarCard и CalendarGrid были удалены
* Используйте функции из CycleCalendar.kt вместо них
*/
@Deprecated("Используйте CycleCalendar.kt вместо этого файла")
object CycleComponentsDeprecated {
// Пустой объект для предотвращения ошибок при компиляции
}

View File

@@ -0,0 +1,149 @@
package kr.smartsoltech.wellshe.ui.cycle.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Healing
import androidx.compose.material.icons.filled.Note
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* Карточка с быстрыми действиями
*/
@Composable
fun QuickActionsCard(
onMarkStart: () -> Unit,
onMarkEnd: () -> Unit,
onAddSymptom: () -> Unit,
onAddNote: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Быстрые действия",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
QuickActionButton(
text = "Отметить начало",
onClick = onMarkStart,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
QuickActionButton(
text = "Отметить окончание",
onClick = onMarkEnd,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedQuickActionButton(
text = "Симптом",
onClick = onAddSymptom,
modifier = Modifier.weight(1f),
icon = Icons.Default.Healing
)
OutlinedQuickActionButton(
text = "Заметка",
onClick = onAddNote,
modifier = Modifier.weight(1f),
icon = Icons.Default.Note
)
}
}
}
}
/**
* Кнопка для быстрых действий (заполненная)
*/
@Composable
fun QuickActionButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
colors: ButtonColors = ButtonDefaults.buttonColors()
) {
Button(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
colors = colors,
modifier = modifier,
contentPadding = PaddingValues(vertical = 12.dp)
) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium
)
}
}
/**
* Кнопка для быстрых действий (контурная)
*/
@Composable
fun OutlinedQuickActionButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: androidx.compose.ui.graphics.vector.ImageVector? = null
) {
OutlinedButton(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
modifier = modifier,
contentPadding = PaddingValues(vertical = 12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
Text(
text = text,
style = MaterialTheme.typography.labelMedium
)
}
}
}

View File

@@ -0,0 +1,568 @@
package kr.smartsoltech.wellshe.ui.cycle.settings
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
import kr.smartsoltech.wellshe.domain.models.*
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* Основное содержимое экрана настроек цикла
*/
@Composable
fun CycleSettingsContent(
settings: CycleSettingsEntity,
validationState: CycleSettingsViewModel.ValidationState,
cycleHistory: List<CycleHistoryEntity>,
exportImportState: CycleSettingsViewModel.ExportImportState,
onBasicSettingChanged: (BasicSettingChange) -> Unit,
onOvulationMethodChanged: (OvulationMethod) -> Unit,
onAllowManualOvulationChanged: (Boolean) -> Unit,
onStatusChanged: (StatusChange) -> Unit,
onHistorySettingChanged: (HistorySetting) -> Unit,
onSensorSettingChanged: (SensorSetting) -> Unit,
onNotificationSettingChanged: (NotificationSetting) -> Unit,
onCycleAtypicalToggled: (Long, Boolean) -> Unit,
onExportSettings: () -> Unit,
onImportSettings: (String) -> Unit,
onResetExportImportState: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Секция основных настроек
BasicSettingsSection(
settings = settings,
validationState = validationState,
onSettingChanged = onBasicSettingChanged
)
Divider()
// Секция метода определения овуляции
OvulationMethodSection(
currentMethod = ovulationMethodFromString(settings.ovulationMethod),
allowManualOvulation = settings.allowManualOvulation,
onMethodChanged = onOvulationMethodChanged,
onAllowManualChanged = onAllowManualOvulationChanged
)
Divider()
// Секция статусов
StatusSection(
hormonalContraception = HormonalContraceptionType.fromString(settings.hormonalContraception),
isPregnant = settings.isPregnant,
isPostpartum = settings.isPostpartum,
isLactating = settings.isLactating,
perimenopause = settings.perimenopause,
onStatusChanged = onStatusChanged
)
Divider()
// Секция настроек сенсоров и единиц измерения
SensorSettingsSection(
tempUnit = temperatureUnitFromString(settings.tempUnit),
bbtTimeWindow = settings.bbtTimeWindow,
timezone = settings.timezone,
validationState = validationState,
onSettingChanged = onSensorSettingChanged
)
Divider()
// Секция настроек уведомлений
NotificationSettingsSection(
periodReminderDays = settings.periodReminderDaysBefore,
ovulationReminderDays = settings.ovulationReminderDaysBefore,
pmsWindowDays = settings.pmsWindowDays,
deviationAlertDays = settings.deviationAlertDays,
fertileWindowMode = toModelFertileWindowMode(fertileWindowModeFromString(settings.fertileWindowMode)),
onSettingChanged = onNotificationSettingChanged
)
Divider()
// Секция настроек истории
HistorySettingsSection(
historyWindowCycles = settings.historyWindowCycles,
excludeOutliers = settings.excludeOutliers,
onSettingChanged = onHistorySettingChanged
)
// История циклов
CycleHistorySection(
cycleHistory = cycleHistory,
onCycleAtypicalToggled = onCycleAtypicalToggled
)
Divider()
// Секция экспорта/импорта
ExportImportSection(
exportImportState = exportImportState,
onExportSettings = onExportSettings,
onImportSettings = onImportSettings,
onResetExportImportState = onResetExportImportState
)
// Отступ внизу для скролла
Spacer(modifier = Modifier.height(32.dp))
}
}
/**
* Секция основных настроек цикла
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BasicSettingsSection(
settings: CycleSettingsEntity,
validationState: CycleSettingsViewModel.ValidationState,
onSettingChanged: (BasicSettingChange) -> Unit
) {
var showDatePicker by remember { mutableStateOf(false) }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Основные параметры цикла",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
// Базовая длина цикла
OutlinedTextField(
value = settings.baselineCycleLength.toString(),
onValueChange = {
try {
onSettingChanged(BasicSettingChange.CycleLengthChanged(it.toInt()))
} catch (e: NumberFormatException) {
// Игнорируем некорректный ввод
}
},
label = { Text("Базовая длина цикла (дни)") },
isError = validationState.baselineCycleLengthError != null,
supportingText = {
validationState.baselineCycleLengthError?.let { Text(it) }
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Вариабельность цикла
OutlinedTextField(
value = settings.cycleVariabilityDays.toString(),
onValueChange = {
try {
onSettingChanged(BasicSettingChange.CycleVariabilityChanged(it.toInt()))
} catch (e: NumberFormatException) {
// Игнорируем некорректный ввод
}
},
label = { Text("Вариабельность (±дни)") },
isError = validationState.cycleVariabilityError != null,
supportingText = {
validationState.cycleVariabilityError?.let { Text(it) }
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Длительность менструации
OutlinedTextField(
value = settings.periodLengthDays.toString(),
onValueChange = {
try {
onSettingChanged(BasicSettingChange.PeriodLengthChanged(it.toInt()))
} catch (e: NumberFormatException) {
// Игнорируем некорректный ввод
}
},
label = { Text("Длительность менструации (дни)") },
isError = validationState.periodLengthError != null,
supportingText = {
validationState.periodLengthError?.let { Text(it) }
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Лютеиновая фаза
OutlinedTextField(
value = settings.lutealPhaseDays,
onValueChange = {
onSettingChanged(BasicSettingChange.LutealPhaseChanged(it))
},
label = { Text("Лютеиновая фаза (дни или 'auto')") },
isError = validationState.lutealPhaseError != null,
supportingText = {
validationState.lutealPhaseError?.let {
Text(it)
} ?: Text("Оставьте 'auto' или укажите значение 8-17 дней")
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Дата последней менструации
OutlinedButton(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth()
) {
val dateText = if (settings.lastPeriodStart != null) {
"Последняя менструация: ${
settings.lastPeriodStart.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
}"
} else {
"Выбрать дату последней менструации"
}
Text(dateText)
}
}
// Показываем диалог выбора даты
if (showDatePicker) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = settings.lastPeriodStart?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
datePickerState.selectedDateMillis?.let { millis ->
val date = java.time.Instant.ofEpochMilli(millis)
.atZone(java.time.ZoneId.systemDefault())
.toLocalDate()
onSettingChanged(BasicSettingChange.LastPeriodStartChanged(date))
}
showDatePicker = false
}
) {
Text("Подтвердить")
}
},
dismissButton = {
TextButton(
onClick = { showDatePicker = false }
) {
Text("Отмена")
}
}
) {
DatePicker(state = datePickerState)
}
}
}
/**
* Секция метода определения овуляции
*/
@Composable
fun OvulationMethodSection(
currentMethod: OvulationMethod,
allowManualOvulation: Boolean,
onMethodChanged: (OvulationMethod) -> Unit,
onAllowManualChanged: (Boolean) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Метод определения овуляции",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
// Список методов овуляции
val methods = OvulationMethod.values()
methods.forEach { method ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = currentMethod == method,
onClick = { onMethodChanged(method) }
)
Text(
text = when (method) {
OvulationMethod.AUTO -> "Автоматически (календарный метод)"
OvulationMethod.BBT -> "Базальная температура тела"
OvulationMethod.LH_TEST -> "Тест на ЛГ"
OvulationMethod.CERVICAL_MUCUS -> "Цервикальная слизь"
OvulationMethod.MEDICAL -> "Медицинское подтверждение"
},
modifier = Modifier.padding(start = 8.dp)
)
}
}
// Переключатель для ручной фиксации
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Switch(
checked = allowManualOvulation,
onCheckedChange = onAllowManualChanged
)
Text(
text = "Разрешить ручную фиксацию овуляции",
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
/**
* Функция-адаптер для преобразования типов между доменной моделью и UI-слоем
*/
private fun HormonalContraceptionType.toUiModel(): HormonalContraception {
return when(this) {
HormonalContraceptionType.NONE -> HormonalContraception.NONE
HormonalContraceptionType.COC -> HormonalContraception.COC
HormonalContraceptionType.IUD -> HormonalContraception.IUD
HormonalContraceptionType.IMPLANT -> HormonalContraception.IMPLANT
HormonalContraceptionType.OTHER -> HormonalContraception.OTHER
}
}
/**
* Секция статусов, влияющих на точность прогнозов
*/
@Composable
fun StatusSection(
hormonalContraception: HormonalContraceptionType,
isPregnant: Boolean,
isPostpartum: Boolean,
isLactating: Boolean,
perimenopause: Boolean,
onStatusChanged: (StatusChange) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Статусы (влияют на точность)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
// Гормональная контрацепция
Text("Гормональная контрацепция:")
val contraceptionTypes = HormonalContraceptionType.values()
contraceptionTypes.forEach { type ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = hormonalContraception == type,
onClick = { onStatusChanged(StatusChange.HormonalContraceptionChanged(type.toUiModel())) }
)
Text(
text = when (type) {
HormonalContraceptionType.NONE -> "Нет"
HormonalContraceptionType.COC -> "Комбинированные оральные контрацептивы (КОК)"
HormonalContraceptionType.IUD -> "Гормональная внутриматочная спираль"
HormonalContraceptionType.IMPLANT -> "Имплант"
HormonalContraceptionType.OTHER -> "Другое"
},
modifier = Modifier.padding(start = 8.dp)
)
}
}
// Другие статусы (чекбоксы)
val statusItems = listOf(
Triple("Беременность", isPregnant) { value: Boolean ->
onStatusChanged(StatusChange.PregnancyStatusChanged(value))
},
Triple("Послеродовой период", isPostpartum) { value: Boolean ->
onStatusChanged(StatusChange.PostpartumStatusChanged(value))
},
Triple("Грудное вскармливание", isLactating) { value: Boolean ->
onStatusChanged(StatusChange.LactatingStatusChanged(value))
},
Triple("Перименопауза", perimenopause) { value: Boolean ->
onStatusChanged(StatusChange.PerimenopauseStatusChanged(value))
}
)
statusItems.forEach { (label, isChecked, onCheckedChange) ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isChecked,
onCheckedChange = onCheckedChange
)
Text(
text = label,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
}
/**
* Секция истории циклов
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CycleHistorySection(
cycleHistory: List<CycleHistoryEntity>,
onCycleAtypicalToggled: (Long, Boolean) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "История циклов",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (cycleHistory.isEmpty()) {
Text(
text = "История циклов пуста",
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
// Показываем историю циклов
val sortedHistory = cycleHistory.sortedByDescending { it.periodStart }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
sortedHistory.forEach { cycle ->
ElevatedCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Начало: ${cycle.periodStart.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))}",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold
)
if (cycle.atypical) {
Badge(
contentColor = MaterialTheme.colorScheme.onError,
containerColor = MaterialTheme.colorScheme.error
) {
Text("Атипичный")
}
}
}
cycle.periodEnd?.let {
Text(
text = "Конец: ${it.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))}",
style = MaterialTheme.typography.bodyMedium
)
}
cycle.ovulationDate?.let {
Text(
text = "Овуляция: ${it.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))}",
style = MaterialTheme.typography.bodyMedium
)
}
if (cycle.notes.isNotEmpty()) {
Text(
text = "Примечания: ${cycle.notes}",
style = MaterialTheme.typography.bodySmall
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
OutlinedButton(
onClick = { onCycleAtypicalToggled(cycle.id, !cycle.atypical) }
) {
Text(if (cycle.atypical) "Пометить как типичный" else "Пометить как атипичный")
}
}
}
}
}
}
}
}
}
private fun fertileWindowModeFromString(value: String): kr.smartsoltech.wellshe.ui.cycle.settings.FertileWindowMode {
return when (value.lowercase()) {
"conservative" -> kr.smartsoltech.wellshe.ui.cycle.settings.FertileWindowMode.CONSERVATIVE
"broad" -> kr.smartsoltech.wellshe.ui.cycle.settings.FertileWindowMode.BROAD
else -> kr.smartsoltech.wellshe.ui.cycle.settings.FertileWindowMode.BALANCED
}
}
/**
* Функция-адаптер для преобразования типа FertileWindowMode между UI и domain моделями
*/
private fun toModelFertileWindowMode(uiMode: kr.smartsoltech.wellshe.ui.cycle.settings.FertileWindowMode): kr.smartsoltech.wellshe.domain.models.FertileWindowMode {
return when (uiMode) {
kr.smartsoltech.wellshe.ui.cycle.settings.FertileWindowMode.CONSERVATIVE -> kr.smartsoltech.wellshe.domain.models.FertileWindowMode.CONSERVATIVE
kr.smartsoltech.wellshe.ui.cycle.settings.FertileWindowMode.BALANCED -> kr.smartsoltech.wellshe.domain.models.FertileWindowMode.BALANCED
kr.smartsoltech.wellshe.ui.cycle.settings.FertileWindowMode.BROAD -> kr.smartsoltech.wellshe.domain.models.FertileWindowMode.BROAD
}
}
// Для полной реализации всех секций потребуется продолжить в новом файле
// Из-за ограничений размера файла

View File

@@ -0,0 +1,478 @@
package kr.smartsoltech.wellshe.ui.cycle.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import kr.smartsoltech.wellshe.domain.models.FertileWindowMode
import kr.smartsoltech.wellshe.domain.models.TemperatureUnit
import kr.smartsoltech.wellshe.domain.models.SensorSetting
import kr.smartsoltech.wellshe.domain.models.NotificationSetting
import kr.smartsoltech.wellshe.domain.models.HistorySetting
/**
* Секция настроек сенсоров и единиц измерения
*/
@Composable
fun SensorSettingsSection(
tempUnit: TemperatureUnit,
bbtTimeWindow: String,
timezone: String,
validationState: CycleSettingsViewModel.ValidationState,
onSettingChanged: (SensorSetting) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Сенсоры и единицы измерения",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
// Единицы измерения температуры
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Единица измерения температуры:",
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = tempUnit == TemperatureUnit.CELSIUS,
onClick = { onSettingChanged(SensorSetting.TempUnit(TemperatureUnit.CELSIUS)) }
)
Text("°C")
}
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = tempUnit == TemperatureUnit.FAHRENHEIT,
onClick = { onSettingChanged(SensorSetting.TempUnit(TemperatureUnit.FAHRENHEIT)) }
)
Text("°F")
}
}
}
// Временное окно для БТТ
OutlinedTextField(
value = bbtTimeWindow,
onValueChange = { onSettingChanged(SensorSetting.BbtTimeWindow(it)) },
label = { Text("Временное окно для измерения БТТ") },
placeholder = { Text("06:00-10:00") },
isError = validationState.bbtTimeWindowError != null,
supportingText = {
validationState.bbtTimeWindowError?.let { Text(it) }
?: Text("Формат: ЧЧ:ММ-ЧЧ:ММ")
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Временная зона
OutlinedTextField(
value = timezone,
onValueChange = { onSettingChanged(SensorSetting.Timezone(it)) },
label = { Text("Временная зона") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
}
/**
* Секция настроек уведомлений
*/
@Composable
fun NotificationSettingsSection(
periodReminderDays: Int,
ovulationReminderDays: Int,
pmsWindowDays: Int,
deviationAlertDays: Int,
fertileWindowMode: FertileWindowMode,
onSettingChanged: (NotificationSetting) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Уведомления",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
// Напоминание о менструации
OutlinedTextField(
value = periodReminderDays.toString(),
onValueChange = {
try {
onSettingChanged(NotificationSetting.PeriodReminder(it.toInt()))
} catch (e: NumberFormatException) {
// Игнорируем некорректный ввод
}
},
label = { Text("Напоминание о менструации (дней до)") },
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Напоминание об овуляции
OutlinedTextField(
value = ovulationReminderDays.toString(),
onValueChange = {
try {
onSettingChanged(NotificationSetting.OvulationReminder(it.toInt()))
} catch (e: NumberFormatException) {
// Игнорируем некорректный ввод
}
},
label = { Text("Напоминание об овуляции (дней до)") },
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Окно ПМС
OutlinedTextField(
value = pmsWindowDays.toString(),
onValueChange = {
try {
onSettingChanged(NotificationSetting.PmsWindow(it.toInt()))
} catch (e: NumberFormatException) {
// Игнорируем некорректный ввод
}
},
label = { Text("Окно ПМС (дней)") },
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Оповещение об отклонении
OutlinedTextField(
value = deviationAlertDays.toString(),
onValueChange = {
try {
onSettingChanged(NotificationSetting.DeviationAlert(it.toInt()))
} catch (e: NumberFormatException) {
// Игнорируем некорректный ввод
}
},
label = { Text("Оповещение об отклонении (дней после ожидаемой даты)") },
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Режим фертильного окна
Text(
text = "Режим определения фертильного окна:",
modifier = Modifier.padding(top = 8.dp)
)
val fertileWindowModes = FertileWindowMode.values()
fertileWindowModes.forEach { mode ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = fertileWindowMode == mode,
onClick = { onSettingChanged(NotificationSetting.FertileWindowMode(mode)) }
)
Column(
modifier = Modifier.padding(start = 8.dp)
) {
Text(
text = when (mode) {
FertileWindowMode.CONSERVATIVE -> "Консервативный"
FertileWindowMode.BALANCED -> "Сбалансированный"
FertileWindowMode.BROAD -> "Широкий"
}
)
Text(
text = when (mode) {
FertileWindowMode.CONSERVATIVE -> "3 дня до овуляции + день овуляции"
FertileWindowMode.BALANCED -> "5 дней до овуляции + день овуляции"
FertileWindowMode.BROAD -> "7 дней до овуляции + день овуляции"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
/**
* Секция настроек истории
*/
@Composable
fun HistorySettingsSection(
historyWindowCycles: Int,
excludeOutliers: Boolean,
onSettingChanged: (HistorySetting) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Настройки истории",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
// Окно истории для расчётов
OutlinedTextField(
value = historyWindowCycles.toString(),
onValueChange = {
try {
onSettingChanged(HistorySetting.WindowCycles(it.toInt()))
} catch (e: NumberFormatException) {
// Игнорируем некорректный ввод
}
},
label = { Text("Количество циклов для расчётов") },
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Исключать выбросы
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Switch(
checked = excludeOutliers,
onCheckedChange = { onSettingChanged(HistorySetting.ExcludeOutliers(it)) }
)
Text(
text = "Исключать выбросы (циклы < 18 или > 60 дней)",
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
/**
* Секция экспорта/импорта настроек
*/
@Composable
fun ExportImportSection(
exportImportState: CycleSettingsViewModel.ExportImportState,
onExportSettings: () -> Unit,
onImportSettings: (String) -> Unit,
onResetExportImportState: () -> Unit
) {
var showImportDialog by remember { mutableStateOf(false) }
var importJson by remember { mutableStateOf("") }
val clipboardManager = LocalClipboardManager.current
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Экспорт/Импорт настроек",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = onExportSettings,
modifier = Modifier.weight(1f)
) {
Text("Экспортировать")
}
Button(
onClick = { showImportDialog = true },
modifier = Modifier.weight(1f)
) {
Text("Импортировать")
}
}
when (val state = exportImportState) {
is CycleSettingsViewModel.ExportImportState.Loading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
is CycleSettingsViewModel.ExportImportState.ExportSuccess -> {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Text(
text = "Настройки экспортированы:",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
OutlinedTextField(
value = state.json,
onValueChange = { },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.height(120.dp),
readOnly = true
)
Button(
onClick = {
clipboardManager.setText(AnnotatedString(state.json))
onResetExportImportState()
},
modifier = Modifier.align(Alignment.End)
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Копировать в буфер обмена"
)
Spacer(modifier = Modifier.width(4.dp))
Text("Копировать")
}
}
}
is CycleSettingsViewModel.ExportImportState.ImportSuccess -> {
Text(
text = "Настройки успешно импортированы!",
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth()
)
// Автоматически сбрасываем состояние через небольшую задержку
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(3000)
onResetExportImportState()
}
}
is CycleSettingsViewModel.ExportImportState.Error -> {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth()
)
TextButton(
onClick = onResetExportImportState,
modifier = Modifier.align(Alignment.End)
) {
Text("Закрыть")
}
}
else -> { /* Ничего не показываем в состоянии Idle */ }
}
}
// Диалог импорта настроек
if (showImportDialog) {
Dialog(
onDismissRequest = {
showImportDialog = false
importJson = ""
}
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
Text(
text = "Импорт настроек из JSON",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = importJson,
onValueChange = { importJson = it },
modifier = Modifier
.fillMaxWidth()
.height(150.dp),
label = { Text("Вставьте JSON настроек") }
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(
onClick = {
showImportDialog = false
importJson = ""
}
) {
Text("Отмена")
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (importJson.isNotBlank()) {
onImportSettings(importJson)
showImportDialog = false
importJson = ""
}
},
enabled = importJson.isNotBlank()
) {
Text("Импортировать")
}
}
}
}
}
}
}

View File

@@ -0,0 +1,121 @@
package kr.smartsoltech.wellshe.ui.cycle.settings
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
import java.time.LocalDate
/**
* Расширения для преобразования между UI-моделью и моделью базы данных
*/
// Преобразование из Entity в UI-модель
fun CycleSettingsEntity.toUiModel(): CycleSettings {
return CycleSettings(
baselineCycleLength = this.baselineCycleLength,
cycleVariabilityDays = this.cycleVariabilityDays,
periodLengthDays = this.periodLengthDays,
lutealPhaseDays = if (this.lutealPhaseDays == "auto") null else this.lutealPhaseDays.toIntOrNull(),
lastPeriodStart = this.lastPeriodStart ?: LocalDate.now().minusDays(28),
ovulationMethod = when (this.ovulationMethod) {
"auto" -> OvulationMethod.AUTO
"bbt" -> OvulationMethod.BBT
"lh_test" -> OvulationMethod.LH_TEST
"cervical_mucus" -> OvulationMethod.CERVICAL_MUCUS
"medical" -> OvulationMethod.MEDICAL
else -> OvulationMethod.AUTO
},
allowManualOvulation = this.allowManualOvulation,
hormonalContraception = when (this.hormonalContraception) {
"none" -> HormonalContraception.NONE
"coc" -> HormonalContraception.COC
"iud" -> HormonalContraception.IUD
"implant" -> HormonalContraception.IMPLANT
"other" -> HormonalContraception.OTHER
else -> HormonalContraception.NONE
},
isPregnant = this.isPregnant,
isPostpartum = this.isPostpartum,
isLactating = this.isLactating,
perimenopause = this.perimenopause,
historyWindowCycles = this.historyWindowCycles,
excludeOutliers = this.excludeOutliers,
tempUnitCelsius = this.tempUnit == "C",
// Разделение временного окна на две части
bbtWindowStart = this.bbtTimeWindow.split("-").getOrNull(0) ?: "06:00",
bbtWindowEnd = this.bbtTimeWindow.split("-").getOrNull(1) ?: "10:00",
timezoneIana = this.timezone,
periodReminderDaysBefore = this.periodReminderDaysBefore,
ovulationReminderDaysBefore = this.ovulationReminderDaysBefore,
pmsWindowDays = this.pmsWindowDays,
deviationAlertDays = this.deviationAlertDays,
fertileWindowMode = when (this.fertileWindowMode) {
"conservative" -> FertileWindowMode.CONSERVATIVE
"balanced" -> FertileWindowMode.BALANCED
"broad" -> FertileWindowMode.BROAD
else -> FertileWindowMode.BALANCED
}
)
}
// Преобразование из UI-модели в Entity
fun CycleSettings.toEntity(): CycleSettingsEntity {
return CycleSettingsEntity(
id = 1, // Singleton ID
baselineCycleLength = this.baselineCycleLength,
cycleVariabilityDays = this.cycleVariabilityDays,
periodLengthDays = this.periodLengthDays,
lutealPhaseDays = this.lutealPhaseDays?.toString() ?: "auto",
lastPeriodStart = this.lastPeriodStart,
ovulationMethod = when (this.ovulationMethod) {
OvulationMethod.AUTO -> "auto"
OvulationMethod.BBT -> "bbt"
OvulationMethod.LH_TEST -> "lh_test"
OvulationMethod.CERVICAL_MUCUS -> "cervical_mucus"
OvulationMethod.MEDICAL -> "medical"
},
allowManualOvulation = this.allowManualOvulation,
hormonalContraception = when (this.hormonalContraception) {
HormonalContraception.NONE -> "none"
HormonalContraception.COC -> "coc"
HormonalContraception.IUD -> "iud"
HormonalContraception.IMPLANT -> "implant"
HormonalContraception.OTHER -> "other"
},
isPregnant = this.isPregnant,
isPostpartum = this.isPostpartum,
isLactating = this.isLactating,
perimenopause = this.perimenopause,
historyWindowCycles = this.historyWindowCycles,
excludeOutliers = this.excludeOutliers,
tempUnit = if (this.tempUnitCelsius) "C" else "F",
bbtTimeWindow = "${this.bbtWindowStart}-${this.bbtWindowEnd}",
timezone = this.timezoneIana,
periodReminderDaysBefore = this.periodReminderDaysBefore,
ovulationReminderDaysBefore = this.ovulationReminderDaysBefore,
pmsWindowDays = this.pmsWindowDays,
deviationAlertDays = this.deviationAlertDays,
fertileWindowMode = when (this.fertileWindowMode) {
FertileWindowMode.CONSERVATIVE -> "conservative"
FertileWindowMode.BALANCED -> "balanced"
FertileWindowMode.BROAD -> "broad"
}
)
}
// Вспомогательные расширения для ViewModel
fun OvulationMethod.toStorageString(): String {
return when (this) {
OvulationMethod.AUTO -> "auto"
OvulationMethod.BBT -> "bbt"
OvulationMethod.LH_TEST -> "lh_test"
OvulationMethod.CERVICAL_MUCUS -> "cervical_mucus"
OvulationMethod.MEDICAL -> "medical"
}
}
fun FertileWindowMode.toStorageString(): String {
return when (this) {
FertileWindowMode.CONSERVATIVE -> "conservative"
FertileWindowMode.BALANCED -> "balanced"
FertileWindowMode.BROAD -> "broad"
}
}

View File

@@ -0,0 +1,526 @@
package kr.smartsoltech.wellshe.ui.cycle.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.format.DateTimeFormatter
// ---------- Модель (минимально достаточная для UI) ----------
enum class OvulationMethod { AUTO, BBT, LH_TEST, CERVICAL_MUCUS, MEDICAL }
enum class HormonalContraception { NONE, COC, IUD, IMPLANT, OTHER }
enum class FertileWindowMode { CONSERVATIVE, BALANCED, BROAD }
@Immutable
data class CycleSettings(
val baselineCycleLength: Int = 28, // 18..60
val cycleVariabilityDays: Int = 3, // 0..10
val periodLengthDays: Int = 5, // 1..10
val lutealPhaseDays: Int? = null, // null => auto (14)
val lastPeriodStart: LocalDate = LocalDate.now().minusDays(28),
val ovulationMethod: OvulationMethod = OvulationMethod.AUTO,
val allowManualOvulation: Boolean = true,
val hormonalContraception: HormonalContraception = HormonalContraception.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 tempUnitCelsius: Boolean = true,
val bbtWindowStart: String = "05:00",
val bbtWindowEnd: String = "08:00",
val timezoneIana: String = "Asia/Seoul",
val periodReminderDaysBefore: Int = 2,
val ovulationReminderDaysBefore: Int = 1,
val pmsWindowDays: Int = 3,
val deviationAlertDays: Int = 5,
val fertileWindowMode: FertileWindowMode = FertileWindowMode.BALANCED
)
// ---------- Публичное API экрана ----------
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CycleSettingsScreen(
initial: CycleSettings = CycleSettings(),
onClose: () -> Unit,
onSave: (CycleSettings) -> Unit
) {
var state by remember { mutableStateOf(initial) }
var showDatePicker by remember { mutableStateOf(false) }
val (isValid, errorMsg) = remember(state) { validate(state) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Настройки цикла") },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = "Закрыть") }
},
actions = {
IconButton(
enabled = isValid,
onClick = { onSave(state) }
) { Icon(Icons.Filled.Save, contentDescription = "Сохранить") }
}
)
}
) { inner ->
Column(
modifier = Modifier
.padding(inner)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// ---- Блок «Базовые параметры цикла» ----
SectionCard("Базовые параметры") {
NumberField(
label = "Опорная длина цикла (1860)",
value = state.baselineCycleLength,
onValueChange = { state = state.copy(baselineCycleLength = it.coerceIn(18, 60)) }
)
NumberField(
label = "Вариабельность (±010)",
value = state.cycleVariabilityDays,
onValueChange = { state = state.copy(cycleVariabilityDays = it.coerceIn(0, 10)) }
)
NumberField(
label = "Длительность менструации (110)",
value = state.periodLengthDays,
onValueChange = { state = state.copy(periodLengthDays = it.coerceIn(1, 10)) }
)
// Лютеиновая фаза (auto / 8..17)
Row(verticalAlignment = Alignment.CenterVertically) {
val auto = state.lutealPhaseDays == null
AssistChip(
onClick = { state = state.copy(lutealPhaseDays = null) },
label = { Text("Авто") },
leadingIcon = {},
colors = AssistChipDefaults.assistChipColors()
)
Spacer(Modifier.width(12.dp))
NumberField(
label = "Лютеиновая фаза (817, вручную)",
value = state.lutealPhaseDays ?: 14,
enabled = !auto,
onValueChange = { state = state.copy(lutealPhaseDays = it.coerceIn(8, 17)) },
modifier = Modifier.weight(1f)
)
Switch(
checked = !auto,
onCheckedChange = { manual ->
state = state.copy(lutealPhaseDays = if (manual) 14 else null)
}
)
}
// Дата начала последней менструации
OutlinedButton(onClick = { showDatePicker = true }) {
Text("Начало последней менструации: ${state.lastPeriodStart.formatRus()}")
}
}
// ---- Блок «Метод овуляции / ручной ввод» ----
SectionCard("Овуляция") {
EnumDropdown(
label = "Метод определения",
value = state.ovulationMethod,
options = OvulationMethod.entries,
titleMapper = {
when (it) {
OvulationMethod.AUTO -> "Авто"
OvulationMethod.BBT -> "BBT"
OvulationMethod.LH_TEST -> "Тесты LH"
OvulationMethod.CERVICAL_MUCUS -> "Цервикальная слизь"
OvulationMethod.MEDICAL -> "Мед.данные"
}
},
onChange = { state = state.copy(ovulationMethod = it) }
)
LabeledSwitch(
label = "Разрешить ручной ввод даты овуляции",
checked = state.allowManualOvulation,
onCheckedChange = { state = state.copy(allowManualOvulation = it) }
)
}
// ---- Блок «Статусы» ----
SectionCard("Статусы") {
EnumDropdown(
label = "Гормональная контрацепция",
value = state.hormonalContraception,
options = HormonalContraception.entries,
titleMapper = {
when (it) {
HormonalContraception.NONE -> "Нет"
HormonalContraception.COC -> "КОК"
HormonalContraception.IUD -> "ВМС"
HormonalContraception.IMPLANT -> "Имплант"
HormonalContraception.OTHER -> "Другое"
}
},
onChange = { state = state.copy(hormonalContraception = it) }
)
FlowToggles(
items = listOf(
"Беременность" to state.isPregnant,
"Постпартум" to state.isPostpartum,
"Лактация" to state.isLactating,
"Пременопауза" to state.perimenopause
),
onToggle = { idx, v ->
state = when (idx) {
0 -> state.copy(isPregnant = v)
1 -> state.copy(isPostpartum = v)
2 -> state.copy(isLactating = v)
else -> state.copy(perimenopause = v)
}
}
)
}
// ---- Блок «История и алгоритм» ----
SectionCard("История и алгоритм") {
NumberField(
label = "Окно истории (циклов, 112)",
value = state.historyWindowCycles,
onValueChange = { state = state.copy(historyWindowCycles = it.coerceIn(1, 12)) }
)
LabeledSwitch(
label = "Исключать аномальные циклы",
checked = state.excludeOutliers,
onCheckedChange = { state = state.copy(excludeOutliers = it) }
)
}
// ---- Блок «Единицы и сенсоры» ----
SectionCard("Единицы и сенсоры") {
LabeledSwitch(
label = "Температура: °C (выкл — °F)",
checked = state.tempUnitCelsius,
onCheckedChange = { state = state.copy(tempUnitCelsius = it) }
)
TwoFieldsRow(
left = {
OutlinedTextField(
value = state.bbtWindowStart,
onValueChange = { state = state.copy(bbtWindowStart = it) },
label = { Text("BBT: окно с") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
right = {
OutlinedTextField(
value = state.bbtWindowEnd,
onValueChange = { state = state.copy(bbtWindowEnd = it) },
label = { Text("до") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
)
OutlinedTextField(
value = state.timezoneIana,
onValueChange = { state = state.copy(timezoneIana = it) },
label = { Text("Часовой пояс (IANA)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
// ---- Блок «Уведомления» ----
SectionCard("Уведомления") {
NumberField(
label = "Напоминание о менструации (дней до)",
value = state.periodReminderDaysBefore,
onValueChange = { state = state.copy(periodReminderDaysBefore = it.coerceIn(0, 10)) }
)
NumberField(
label = "Напоминание об овуляции (дней до)",
value = state.ovulationReminderDaysBefore,
onValueChange = { state = state.copy(ovulationReminderDaysBefore = it.coerceIn(0, 10)) }
)
NumberField(
label = "Окно ПМС (дней)",
value = state.pmsWindowDays,
onValueChange = { state = state.copy(pmsWindowDays = it.coerceIn(0, 7)) }
)
NumberField(
label = "Оповещение об отклонении цикла (дней)",
value = state.deviationAlertDays,
onValueChange = { state = state.copy(deviationAlertDays = it.coerceIn(1, 14)) }
)
EnumDropdown(
label = "Фертильное окно — режим",
value = state.fertileWindowMode,
options = FertileWindowMode.entries,
titleMapper = {
when (it) {
FertileWindowMode.CONSERVATIVE -> "Консервативный"
FertileWindowMode.BALANCED -> "Сбалансированный"
FertileWindowMode.BROAD -> "Широкий"
}
},
onChange = { state = state.copy(fertileWindowMode = it) }
)
}
if (!isValid && errorMsg != null) {
Text(errorMsg, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelMedium)
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = onClose,
modifier = Modifier.weight(1f)
) { Text("Отмена") }
Button(
onClick = { onSave(state) },
enabled = isValid,
modifier = Modifier.weight(1f)
) { Text("Сохранить") }
}
}
}
if (showDatePicker) {
val statePicker = rememberDatePickerState(initialSelectedDateMillis = state.lastPeriodStart.toEpochMillis())
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
val millis = statePicker.selectedDateMillis
if (millis != null) {
state = state.copy(lastPeriodStart = millis.toLocalDate())
}
showDatePicker = false
}) { Text("Готово") }
},
dismissButton = { TextButton(onClick = { showDatePicker = false }) { Text("Отмена") } }
) { DatePicker(state = statePicker) }
}
}
// ---------- Тот же UI как BottomSheet (если нужно модалкой) ----------
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CycleSettingsSheet(
initial: CycleSettings = CycleSettings(),
onDismiss: () -> Unit,
onSave: (CycleSettings) -> Unit
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
CycleSettingsScreen(
initial = initial,
onClose = onDismiss,
onSave = onSave
)
}
}
// Главный экран настроек для интеграции с навигацией и ViewModel
@Composable
fun CycleSettingsMainScreen(
viewModel: CycleSettingsViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
val cycleSettings by viewModel.cycleSettingsFlow.collectAsState(initial = CycleSettings())
val coroutineScope = rememberCoroutineScope()
CycleSettingsScreen(
initial = cycleSettings,
onClose = onNavigateBack,
onSave = { settings ->
coroutineScope.launch {
viewModel.saveSettings(settings)
onNavigateBack()
}
}
)
}
// ---------- Вспомогательные UI-компоненты ----------
@Composable
private fun SectionCard(
title: String,
content: @Composable ColumnScope.() -> Unit
) {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
content()
}
}
}
@Composable
private fun NumberField(
label: String,
value: Int,
onValueChange: (Int) -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = value.toString(),
onValueChange = { s -> s.filter { it.isDigit() }.takeIf { it.isNotEmpty() }?.toInt()?.let(onValueChange) },
label = { Text(label) },
singleLine = true,
enabled = enabled,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = modifier.fillMaxWidth()
)
}
@Composable
private fun LabeledSwitch(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(label)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
fun TwoFieldsRow(
left: @Composable () -> Unit,
right: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.fillMaxWidth()
) {
Box(modifier = Modifier.weight(1f)) { left() }
Box(modifier = Modifier.weight(1f)) { right() }
}
}
@Composable
fun FlowToggles(
items: List<Pair<String, Boolean>>,
onToggle: (Int, Boolean) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items.forEachIndexed { index, (label, checked) ->
LabeledSwitch(
label = label,
checked = checked,
onCheckedChange = { onToggle(index, it) }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> EnumDropdown(
label: String,
value: T,
options: List<T>,
titleMapper: (T) -> String,
onChange: (T) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = titleMapper(value),
onValueChange = {},
label = { Text(label) },
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { opt ->
DropdownMenuItem(
text = { Text(titleMapper(opt)) },
onClick = {
onChange(opt)
expanded = false
}
)
}
}
}
}
// ---------- Валидация ----------
private fun validate(s: CycleSettings): Pair<Boolean, String?> {
if (s.baselineCycleLength !in 18..60) return false to "Длина цикла должна быть 1860 дней"
if (s.cycleVariabilityDays !in 0..10) return false to "Вариабельность 010"
if (s.periodLengthDays !in 1..10) return false to "Менструация от 1 до 10 дней"
if (s.lutealPhaseDays != null && s.lutealPhaseDays !in 8..17) return false to "Лютеиновая фаза 817 или Авто"
if (s.historyWindowCycles !in 1..12) return false to "Окно истории 112 циклов"
if (s.periodReminderDaysBefore !in 0..10) return false to "Напоминание о менструации 010 дней"
if (s.ovulationReminderDaysBefore !in 0..10) return false to "Напоминание об овуляции 010 дней"
if (s.pmsWindowDays !in 0..7) return false to "Окно ПМС 07 дней"
if (s.deviationAlertDays !in 1..14) return false to "Оповещение об отклонении 114 дней"
return true to null
}
// ---------- Утилиты даты ----------
private fun LocalDate.formatRus(): String = format(DateTimeFormatter.ofPattern("d MMM yyyy"))
private fun LocalDate.toEpochMillis(): Long =
atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
private fun Long.toLocalDate(): LocalDate =
java.time.Instant.ofEpochMilli(this).atZone(java.time.ZoneId.systemDefault()).toLocalDate()
// ---------- Превью ----------
@Preview(showBackground = true, widthDp = 380)
@Composable
private fun CycleSettingsScreenPreview() {
val demo = CycleSettings()
MaterialTheme {
CycleSettingsScreen(
initial = demo,
onClose = {},
onSave = {}
)
}
}

View File

@@ -0,0 +1,583 @@
package kr.smartsoltech.wellshe.ui.cycle.settings
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
import kr.smartsoltech.wellshe.data.repository.CycleRepository
import kr.smartsoltech.wellshe.domain.models.*
import kr.smartsoltech.wellshe.domain.services.CycleSettingsExportService
import kr.smartsoltech.wellshe.workers.CycleNotificationManager
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import javax.inject.Inject
/**
* ViewModel для экрана настроек цикла
*/
@HiltViewModel
class CycleSettingsViewModel @Inject constructor(
private val cycleRepository: CycleRepository,
private val exportService: CycleSettingsExportService,
private val notificationManager: CycleNotificationManager
) : ViewModel() {
// Текущее состояние настроек
private val _settingsState = MutableStateFlow<CycleSettingsEntity?>(null)
val settingsState: StateFlow<CycleSettingsEntity?> = _settingsState.asStateFlow()
// Flow для UI-модели настроек цикла
val cycleSettingsFlow = _settingsState.map { entity ->
entity?.toUiModel() ?: CycleSettings()
}
// История циклов
private val _cycleHistory = MutableStateFlow<List<CycleHistoryEntity>>(emptyList())
val cycleHistory: StateFlow<List<CycleHistoryEntity>> = _cycleHistory.asStateFlow()
// Состояние операций экспорта/импорта
private val _exportImportState = MutableStateFlow<ExportImportState>(ExportImportState.Idle)
val exportImportState: StateFlow<ExportImportState> = _exportImportState.asStateFlow()
// Состояние валидации полей
private val _validationState = MutableStateFlow(ValidationState())
val validationState: StateFlow<ValidationState> = _validationState.asStateFlow()
// Последнее действие для отмены (Undo)
private var lastAction: UndoAction? = null
// События для UI
private val _uiEvents = MutableLiveData<UiEvent>()
val uiEvents: LiveData<UiEvent> = _uiEvents
init {
loadSettings()
loadCycleHistory()
}
/**
* Загружает настройки цикла из репозитория
*/
private fun loadSettings() {
viewModelScope.launch {
cycleRepository.getSettingsFlow().collect { settings ->
_settingsState.value = settings ?: CycleSettingsEntity()
}
}
}
/**
* Загружает историю циклов из репозитория
*/
private fun loadCycleHistory() {
viewModelScope.launch {
cycleRepository.getAllHistoryFlow().collect { history ->
_cycleHistory.value = history
}
}
}
/**
* Обновляет базовую длину цикла
*/
fun updateBaselineCycleLength(length: Int) {
val validatedLength = length.coerceIn(18, 60)
if (validatedLength != length) {
_validationState.value = _validationState.value.copy(
baselineCycleLengthError = "Длина цикла должна быть от 18 до 60 дней"
)
} else {
_validationState.value = _validationState.value.copy(baselineCycleLengthError = null)
}
updateSetting {
it.copy(baselineCycleLength = validatedLength)
}
}
/**
* Обновляет вариабельность цикла
*/
fun updateCycleVariability(days: Int) {
val validatedDays = days.coerceIn(0, 10)
if (validatedDays != days) {
_validationState.value = _validationState.value.copy(
cycleVariabilityError = "Вариабельность должна быть от 0 до 10 дней"
)
} else {
_validationState.value = _validationState.value.copy(cycleVariabilityError = null)
}
updateSetting {
it.copy(cycleVariabilityDays = validatedDays)
}
}
/**
* Обновляет длительность периода
*/
fun updatePeriodLength(days: Int) {
val validatedDays = days.coerceIn(1, 10)
if (validatedDays != days) {
_validationState.value = _validationState.value.copy(
periodLengthError = "Длительность периода должна быть от 1 до 10 дней"
)
} else {
_validationState.value = _validationState.value.copy(periodLengthError = null)
}
updateSetting {
it.copy(periodLengthDays = validatedDays)
}
}
/**
* Обновляет лютеиновую фазу
*/
fun updateLutealPhase(value: String) {
val validatedValue = if (value != "auto") {
try {
val days = value.toInt()
if (days in 8..17) {
_validationState.value = _validationState.value.copy(lutealPhaseError = null)
days.toString()
} else {
_validationState.value = _validationState.value.copy(
lutealPhaseError = "Лютеиновая фаза должна быть от 8 до 17 дней"
)
value
}
} catch (e: NumberFormatException) {
_validationState.value = _validationState.value.copy(
lutealPhaseError = "Введите число или 'auto'"
)
value
}
} else {
_validationState.value = _validationState.value.copy(lutealPhaseError = null)
"auto"
}
updateSetting {
it.copy(lutealPhaseDays = validatedValue)
}
}
/**
* Обновляет дату последней менструации
*/
fun updateLastPeriodStart(date: LocalDate) {
updateSetting(shouldSaveLastAction = true) {
it.copy(lastPeriodStart = date)
}
}
/**
* Обновляет метод определения овуляции
*/
fun updateOvulationMethod(method: OvulationMethod) {
updateSetting {
it.copy(ovulationMethod = method.toStorageString())
}
}
/**
* Обновляет разрешение на ручное указание овуляции
*/
fun updateAllowManualOvulation(allow: Boolean) {
updateSetting {
it.copy(allowManualOvulation = allow)
}
}
/**
* Обновляет тип гормональной контрацепции
*/
fun updateHormonalContraception(type: HormonalContraceptionType) {
updateSetting(shouldSaveLastAction = true) {
it.copy(hormonalContraception = type.toStorageString())
}
}
/**
* Обновляет статус беременности
*/
fun updatePregnancyStatus(isPregnant: Boolean) {
updateSetting(shouldSaveLastAction = true) {
it.copy(isPregnant = isPregnant)
}
}
/**
* Обновляет статус послеродового периода
*/
fun updatePostpartumStatus(isPostpartum: Boolean) {
updateSetting(shouldSaveLastAction = true) {
it.copy(isPostpartum = isPostpartum)
}
}
/**
* Обновляет статус грудного вскармливания
*/
fun updateLactatingStatus(isLactating: Boolean) {
updateSetting(shouldSaveLastAction = true) {
it.copy(isLactating = isLactating)
}
}
/**
* Обновляет статус перименопаузы
*/
fun updatePerimenopauseStatus(perimenopause: Boolean) {
updateSetting(shouldSaveLastAction = true) {
it.copy(perimenopause = perimenopause)
}
}
/**
* Обновляет окно истории для расчетов
*/
fun updateHistoryWindow(cycles: Int) {
updateSetting {
it.copy(historyWindowCycles = cycles.coerceIn(2, 12))
}
}
/**
* Обновляет исключение выбросов при расчетах
*/
fun updateExcludeOutliers(exclude: Boolean) {
updateSetting {
it.copy(excludeOutliers = exclude)
}
}
/**
* Обновляет единицы измерения температуры
*/
fun updateTemperatureUnit(unit: TemperatureUnit) {
updateSetting {
it.copy(tempUnit = unit.toStorageString())
}
}
/**
* Обновляет временное окно для измерения базальной температуры
*/
fun updateBbtTimeWindow(timeWindow: String) {
// Валидация формата "HH:mm-HH:mm"
val isValid = try {
val parts = timeWindow.split("-")
val formatter = DateTimeFormatter.ofPattern("HH:mm")
formatter.parse(parts[0])
formatter.parse(parts[1])
true
} catch (e: Exception) {
false
}
if (!isValid) {
_validationState.value = _validationState.value.copy(
bbtTimeWindowError = "Формат должен быть ЧЧ:ММ-ЧЧ:ММ"
)
} else {
_validationState.value = _validationState.value.copy(bbtTimeWindowError = null)
}
updateSetting {
it.copy(bbtTimeWindow = timeWindow)
}
}
/**
* Обновляет timezone
*/
fun updateTimezone(timezone: String) {
updateSetting {
it.copy(timezone = timezone)
}
}
/**
* Обновляет количество дней для напоминания о менструации
*/
fun updatePeriodReminderDays(days: Int) {
updateSetting {
it.copy(periodReminderDaysBefore = days.coerceIn(0, 7))
}
}
/**
* Обновляет количество дней для напоминания об овуляции
*/
fun updateOvulationReminderDays(days: Int) {
updateSetting {
it.copy(ovulationReminderDaysBefore = days.coerceIn(0, 7))
}
}
/**
* Обновляет окно ПМС
*/
fun updatePmsWindowDays(days: Int) {
updateSetting {
it.copy(pmsWindowDays = days.coerceIn(1, 7))
}
}
/**
* Обновляет количество дней для оповещения об отклонении
*/
fun updateDeviationAlertDays(days: Int) {
updateSetting {
it.copy(deviationAlertDays = days.coerceIn(1, 14))
}
}
/**
* Обновляет режим фертильного окна
*/
fun updateFertileWindowMode(mode: FertileWindowMode) {
updateSetting {
it.copy(fertileWindowMode = mode.toStorageString())
}
}
/**
* Сбрасывает настройки к рекомендуемым значениям по умолчанию
*/
fun resetToDefaults() {
viewModelScope.launch {
// Сохраняем текущие настройки для возможности отмены
val currentSettings = _settingsState.value
lastAction = if (currentSettings != null) {
UndoAction.ResetSettings(currentSettings)
} else {
null
}
// Сбрасываем все ошибки валидации
_validationState.value = ValidationState()
// Сбрасываем настройки к рекомендуемым
cycleRepository.resetToDefaults()
// Отправляем событие для показа снекбара
_uiEvents.value = UiEvent.ShowSnackbar(
message = "Настройки сброшены к рекомендуемым",
actionLabel = "Отменить",
action = { undoLastAction() }
)
}
}
/**
* Помечает цикл как атипичный или типичный
*/
fun toggleCycleAtypical(cycleId: Long, atypical: Boolean) {
viewModelScope.launch {
cycleRepository.markCycleAsAtypical(cycleId, atypical)
// Оповещаем пользователя о действии
val message = if (atypical) {
"Цикл помечен как атипичный"
} else {
"Цикл помечен как типичный"
}
_uiEvents.value = UiEvent.ShowSnackbar(message = message)
}
}
/**
* Экспортирует настройки в JSON
*/
fun exportSettingsToJson() {
viewModelScope.launch {
_exportImportState.value = ExportImportState.Loading
try {
val settings = _settingsState.value ?: CycleSettingsEntity()
val json = exportService.exportSettingsToJson(settings)
_exportImportState.value = ExportImportState.ExportSuccess(json)
} catch (e: Exception) {
_exportImportState.value = ExportImportState.Error("Ошибка экспорта: ${e.message}")
}
}
}
/**
* Импортирует настройки из JSON
*/
fun importSettingsFromJson(json: String) {
viewModelScope.launch {
_exportImportState.value = ExportImportState.Loading
try {
val importedSettings = exportService.importSettingsFromJson(json)
if (importedSettings != null) {
// Сохраняем текущие настройки для возможности отмены
val currentSettings = _settingsState.value
if (currentSettings != null) {
lastAction = UndoAction.ImportSettings(currentSettings)
}
// Сохраняем импортированные настройки
cycleRepository.saveSettings(importedSettings)
_exportImportState.value = ExportImportState.ImportSuccess
// Отправляем событие для показа снекбара
_uiEvents.value = UiEvent.ShowSnackbar(
message = "Настройки успешно импортированы",
actionLabel = "Отменить",
action = { undoLastAction() }
)
} else {
_exportImportState.value = ExportImportState.Error("Некорректный формат JSON")
}
} catch (e: Exception) {
_exportImportState.value = ExportImportState.Error("Ошибка импорта: ${e.message}")
}
}
}
/**
* Отменяет последнее критичное действие (Undo)
*/
fun undoLastAction() {
viewModelScope.launch {
val action = lastAction
if (action != null) {
when (action) {
is UndoAction.ResetSettings -> {
cycleRepository.saveSettings(action.previousSettings)
_uiEvents.value = UiEvent.ShowSnackbar("Изменения отменены")
}
is UndoAction.ImportSettings -> {
cycleRepository.saveSettings(action.previousSettings)
_uiEvents.value = UiEvent.ShowSnackbar("Импорт отменен")
}
is UndoAction.UpdateCriticalSetting -> {
cycleRepository.saveSettings(action.previousSettings)
_uiEvents.value = UiEvent.ShowSnackbar("Изменение отменено")
}
}
// Очищаем последнее действие после отмены
lastAction = null
}
}
}
/**
* Сбрасывает состояние экспорта/импорта
*/
fun resetExportImportState() {
_exportImportState.value = ExportImportState.Idle
}
/**
* Обновляет настройку цикла и сохраняет её в репозиторий
*/
private fun updateSetting(
shouldSaveLastAction: Boolean = false,
update: (CycleSettingsEntity) -> CycleSettingsEntity
) {
viewModelScope.launch {
val currentSettings = _settingsState.value ?: CycleSettingsEntity()
// Сохраняем текущие настройки для критичных изменений
if (shouldSaveLastAction) {
lastAction = UndoAction.UpdateCriticalSetting(currentSettings)
}
// Обновляем и сохраняем настройки
val updatedSettings = update(currentSettings)
cycleRepository.saveSettings(updatedSettings)
// Если это критичное изменение, показываем снекбар с возможностью отмены
if (shouldSaveLastAction) {
_uiEvents.value = UiEvent.ShowSnackbar(
message = "Настройки обновлены",
actionLabel = "Отменить",
action = { undoLastAction() }
)
}
}
}
/**
* Сохраняет все настройки цикла
*/
suspend fun saveSettings(settings: CycleSettings) {
val entity = settings.toEntity()
cycleRepository.saveSettings(entity)
// Уведомления будут запланированы внутри метода saveSettings репозитория через recalculateForecasts
}
/**
* Модель состояния валидации полей
*/
data class ValidationState(
val baselineCycleLengthError: String? = null,
val cycleVariabilityError: String? = null,
val periodLengthError: String? = null,
val lutealPhaseError: String? = null,
val bbtTimeWindowError: String? = null
) {
/**
* Проверяет, есть ли ошибки валидации
*/
fun hasErrors(): Boolean {
return baselineCycleLengthError != null ||
cycleVariabilityError != null ||
periodLengthError != null ||
lutealPhaseError != null ||
bbtTimeWindowError != null
}
}
/**
* Модель состояния экспорта/импорта
*/
sealed class ExportImportState {
object Idle : ExportImportState()
object Loading : ExportImportState()
data class ExportSuccess(val json: String) : ExportImportState()
object ImportSuccess : ExportImportState()
data class Error(val message: String) : ExportImportState()
}
/**
* Модель для отмены последнего действия
*/
sealed class UndoAction {
data class ResetSettings(val previousSettings: CycleSettingsEntity) : UndoAction()
data class ImportSettings(val previousSettings: CycleSettingsEntity) : UndoAction()
data class UpdateCriticalSetting(val previousSettings: CycleSettingsEntity) : UndoAction()
}
/**
* События для UI
*/
sealed class UiEvent {
data class ShowSnackbar(
val message: String,
val actionLabel: String? = null,
val action: (() -> Unit)? = null
) : UiEvent()
object SettingsSaved : UiEvent()
}
}

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.*
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -23,7 +24,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import kr.smartsoltech.wellshe.domain.model.*
import kr.smartsoltech.wellshe.ui.theme.*
@@ -300,14 +300,11 @@ private fun QuickActionsRow(
fontWeight = FontWeight.SemiBold,
color = TextPrimary
),
modifier = Modifier.padding(horizontal = 4.dp)
modifier = Modifier.padding(bottom = 12.dp)
)
Spacer(modifier = Modifier.height(12.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 4.dp)
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(quickActions) { action ->
QuickActionCard(
@@ -327,7 +324,7 @@ private fun QuickActionCard(
) {
Card(
modifier = modifier
.width(120.dp)
.width(140.dp)
.clickable { onClick() },
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(
@@ -345,14 +342,14 @@ private fun QuickActionCard(
imageVector = action.icon,
contentDescription = null,
tint = action.iconColor,
modifier = Modifier.size(24.dp)
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = action.title,
style = MaterialTheme.typography.bodySmall.copy(
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Medium,
color = action.textColor
),
@@ -695,13 +692,13 @@ private fun getSleepQualityText(quality: SleepQuality): String {
private fun getWorkoutIcon(type: WorkoutType): ImageVector {
return when (type) {
WorkoutType.CARDIO -> Icons.Default.DirectionsRun
WorkoutType.CARDIO -> Icons.AutoMirrored.Filled.DirectionsRun
WorkoutType.STRENGTH -> Icons.Default.FitnessCenter
WorkoutType.YOGA -> Icons.Default.SelfImprovement
WorkoutType.PILATES -> Icons.Default.SelfImprovement
WorkoutType.RUNNING -> Icons.Default.DirectionsRun
WorkoutType.WALKING -> Icons.Default.DirectionsWalk
WorkoutType.CYCLING -> Icons.Default.DirectionsBike
WorkoutType.RUNNING -> Icons.AutoMirrored.Filled.DirectionsRun
WorkoutType.WALKING -> Icons.AutoMirrored.Filled.DirectionsWalk
WorkoutType.CYCLING -> Icons.AutoMirrored.Filled.DirectionsBike
WorkoutType.SWIMMING -> Icons.Default.Pool
}
}

View File

@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
import kr.smartsoltech.wellshe.data.entity.SleepLogEntity
@@ -14,6 +15,7 @@ import kr.smartsoltech.wellshe.data.repository.WellSheRepository
import kr.smartsoltech.wellshe.domain.model.*
import javax.inject.Inject
import java.time.LocalDate
import java.time.temporal.ChronoUnit
data class DashboardUiState(
val user: User = User(),
@@ -45,12 +47,16 @@ class DashboardViewModel @Inject constructor(
try {
// Загружаем данные пользователя
repository.getUserProfile().collect { user ->
repository.getUserProfile().catch {
// Игнорируем ошибки, используем дефолтные данные
}.collect { user: User ->
_uiState.value = _uiState.value.copy(user = user)
}
// Загружаем данные о здоровье
repository.getTodayHealthData().collect { healthEntity ->
repository.getTodayHealthData().catch {
// Игнорируем ошибки, используем дефолтные данные
}.collect { healthEntity: HealthRecordEntity? ->
val healthData = healthEntity?.let { convertHealthEntityToModel(it) } ?: HealthData()
_uiState.value = _uiState.value.copy(todayHealth = healthData)
}
@@ -59,13 +65,16 @@ class DashboardViewModel @Inject constructor(
loadSleepData()
// Загружаем данные о цикле
repository.getCurrentCyclePeriod().collect { cycleEntity ->
repository.getRecentPeriods().let { periods ->
val cycleEntity = periods.firstOrNull()
val cycleData = cycleEntity?.let { convertCycleEntityToModel(it) } ?: CycleData()
_uiState.value = _uiState.value.copy(cycleData = cycleData)
}
// Загружаем тренировки
repository.getRecentWorkouts().collect { workoutEntities ->
repository.getRecentWorkouts().catch {
// Игнорируем ошибки
}.collect { workoutEntities: List<WorkoutSession> ->
val workouts = workoutEntities.map { convertWorkoutEntityToModel(it) }
_uiState.value = _uiState.value.copy(recentWorkouts = workouts)
}
@@ -93,7 +102,7 @@ class DashboardViewModel @Inject constructor(
val sleepEntity = repository.getSleepForDate(yesterday)
val sleepData = sleepEntity?.let { convertSleepEntityToModel(it) } ?: SleepData()
_uiState.value = _uiState.value.copy(sleepData = sleepData)
} catch (e: Exception) {
} catch (_: Exception) {
// Игнорируем ошибки загрузки сна
}
}
@@ -101,10 +110,12 @@ class DashboardViewModel @Inject constructor(
private suspend fun loadTodayFitnessData() {
try {
val today = LocalDate.now()
repository.getFitnessDataForDate(today).collect { fitnessData ->
repository.getFitnessDataForDate(today).catch {
// Игнорируем ошибки
}.collect { fitnessData: FitnessData ->
_uiState.value = _uiState.value.copy(todaySteps = fitnessData.steps)
}
} catch (e: Exception) {
} catch (_: Exception) {
// Игнорируем ошибки загрузки фитнеса
}
}
@@ -112,11 +123,13 @@ class DashboardViewModel @Inject constructor(
private suspend fun loadTodayWaterData() {
try {
val today = LocalDate.now()
repository.getWaterIntakeForDate(today).collect { waterIntakes ->
repository.getWaterIntakeForDate(today).catch {
// Игнорируем ошибки
}.collect { waterIntakes: List<WaterIntake> ->
val totalAmount = waterIntakes.sumOf { it.amount.toDouble() }.toFloat()
_uiState.value = _uiState.value.copy(todayWater = totalAmount)
}
} catch (e: Exception) {
} catch (_: Exception) {
// Игнорируем ошибки загрузки воды
}
}
@@ -135,10 +148,10 @@ class DashboardViewModel @Inject constructor(
heartRate = entity.heartRate ?: 70,
bloodPressureSystolic = entity.bloodPressureS ?: 120,
bloodPressureDiastolic = entity.bloodPressureD ?: 80,
mood = convertMoodStringToEnum(entity.mood),
energyLevel = entity.energyLevel,
stressLevel = entity.stressLevel,
symptoms = entity.symptoms.split(",").filter { it.isNotBlank() }
mood = convertMoodStringToEnum(entity.mood ?: "neutral"),
energyLevel = entity.energyLevel ?: 5,
stressLevel = entity.stressLevel ?: 5,
symptoms = entity.symptoms ?: emptyList()
)
}
@@ -158,13 +171,13 @@ class DashboardViewModel @Inject constructor(
return CycleData(
id = entity.id.toString(),
userId = "current_user",
cycleLength = entity.cycleLength,
cycleLength = entity.cycleLength ?: 28,
periodLength = entity.endDate?.let {
java.time.temporal.ChronoUnit.DAYS.between(entity.startDate, it).toInt() + 1
ChronoUnit.DAYS.between(entity.startDate, it).toInt() + 1
} ?: 5,
lastPeriodDate = entity.startDate,
nextPeriodDate = entity.startDate.plusDays(entity.cycleLength.toLong()),
ovulationDate = entity.startDate.plusDays((entity.cycleLength / 2).toLong())
nextPeriodDate = entity.startDate.plusDays((entity.cycleLength ?: 28).toLong()),
ovulationDate = entity.startDate.plusDays(((entity.cycleLength ?: 28) / 2).toLong())
)
}
@@ -215,14 +228,4 @@ class DashboardViewModel @Inject constructor(
else -> WorkoutType.CARDIO
}
}
private fun convertWorkoutIntensityStringToEnum(intensity: String): WorkoutIntensity {
return when (intensity.lowercase()) {
"low" -> WorkoutIntensity.LOW
"moderate" -> WorkoutIntensity.MODERATE
"high" -> WorkoutIntensity.HIGH
"intense" -> WorkoutIntensity.INTENSE
else -> WorkoutIntensity.MODERATE
}
}
}

View File

@@ -44,7 +44,7 @@ class FitnessViewModel @Inject constructor(
val today = LocalDate.now()
// Загружаем данные фитнеса за сегодня
repository.getFitnessDataForDate(today).collect { fitnessData ->
repository.getFitnessDataForDate(today).collect { fitnessData: FitnessData ->
val calories = calculateCaloriesFromSteps(fitnessData.steps)
val distance = calculateDistanceFromSteps(fitnessData.steps)
@@ -101,92 +101,64 @@ class FitnessViewModel @Inject constructor(
}
}
fun toggleStepTracking() {
viewModelScope.launch {
val isTracking = _uiState.value.isTrackingSteps
if (isTracking) {
repository.stopStepTracking()
} else {
repository.startStepTracking()
}
_uiState.value = _uiState.value.copy(isTrackingSteps = !isTracking)
}
}
fun startWorkout(type: String, notes: String = "") {
viewModelScope.launch {
val workout = WorkoutSession(
type = type,
date = LocalDate.now(),
startTime = LocalDateTime.now(),
notes = notes
)
repository.startWorkout(workout)
loadFitnessData() // Перезагружаем данные
}
}
fun endWorkout(workoutId: Long, duration: Int, caloriesBurned: Int, distance: Float = 0f) {
viewModelScope.launch {
repository.endWorkout(workoutId, duration, caloriesBurned, distance)
loadFitnessData() // Перезагружаем данные
}
}
fun startStepTracking() {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isTrackingSteps = true)
repository.startStepTracking()
_uiState.value = _uiState.value.copy(isTrackingSteps = true)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
fun stopStepTracking() {
viewModelScope.launch {
try {
repository.stopStepTracking()
_uiState.value = _uiState.value.copy(isTrackingSteps = false)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
fun startWorkout(workoutType: String) {
viewModelScope.launch {
try {
val workout = WorkoutSession(
id = 0,
type = workoutType,
date = LocalDate.now(),
startTime = LocalDateTime.now(),
duration = 0,
caloriesBurned = 0,
distance = 0f
)
repository.startWorkout(workout)
loadFitnessData() // Перезагружаем данные
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
fun endWorkout(workoutId: Long, duration: Int, caloriesBurned: Int, distance: Float) {
viewModelScope.launch {
try {
repository.endWorkout(workoutId, duration, caloriesBurned, distance)
loadFitnessData() // Перезагружаем данные
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
fun updateSteps(steps: Int) {
viewModelScope.launch {
try {
repository.updateTodaySteps(steps)
val calories = calculateCaloriesFromSteps(steps)
val distance = calculateDistanceFromSteps(steps)
_uiState.value = _uiState.value.copy(
todaySteps = steps,
caloriesBurned = calories,
distance = distance
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
private fun calculateCaloriesFromSteps(steps: Int): Int {
// Приблизительная формула: 1 шаг = 0.04 калории
return (steps * 0.04).toInt()
}
private fun calculateDistanceFromSteps(steps: Int): Float {
// Приблизительная формула: 1 шаг = 0.8 метра
return (steps * 0.8f) / 1000f // конвертируем в километры
}
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
// Вспомогательные функции для расчетов
private fun calculateCaloriesFromSteps(steps: Int): Int {
// Средний расход калорий: около 0.04 ккал на шаг
return (steps * 0.04).toInt()
}
private fun calculateDistanceFromSteps(steps: Int): Float {
// Средняя длина шага: около 0.7 метра
return steps * 0.7f / 1000 // Переводим в км
}
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@@ -28,6 +29,9 @@ import java.time.format.DateTimeFormatter
@Composable
fun HealthOverviewScreen(
onNavigateBack: () -> Unit,
onWater: () -> Unit,
onWeight: () -> Unit,
onSport: () -> Unit,
modifier: Modifier = Modifier,
viewModel: HealthViewModel = hiltViewModel()
) {
@@ -43,44 +47,41 @@ fun HealthOverviewScreen(
.background(
Brush.verticalGradient(
colors = listOf(
SuccessGreenLight.copy(alpha = 0.3f),
Color(0xFFFFF0F5),
Color(0xFFFAF0E6),
NeutralWhite
)
)
)
) {
TopAppBar(
title = {
Text(
text = "Здоровье",
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
color = TextPrimary
)
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Назад",
tint = TextPrimary
)
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color.White,
shadowElevation = 4.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(onClick = onWater) {
Icon(Icons.Default.LocalDrink, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Вода")
}
},
actions = {
IconButton(onClick = { viewModel.toggleEditMode() }) {
Icon(
imageVector = if (uiState.isEditMode) Icons.Default.Save else Icons.Default.Edit,
contentDescription = if (uiState.isEditMode) "Сохранить" else "Редактировать",
tint = SuccessGreen
)
Button(onClick = onWeight) {
Icon(Icons.Default.MonitorWeight, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Вес")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = NeutralWhite.copy(alpha = 0.95f)
)
)
Button(onClick = onSport) {
Icon(Icons.Default.FitnessCenter, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Спорт")
}
}
}
LazyColumn(
modifier = Modifier

View File

@@ -241,7 +241,19 @@ private fun VitalSignsCard(
onValueChange = {
systolic = it
it.toIntOrNull()?.let { sys ->
val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now())
val currentRecord = healthRecord ?: HealthRecordEntity(
date = LocalDate.now(),
weight = 0f,
heartRate = 0,
bloodPressureS = 0,
bloodPressureD = 0,
temperature = 36.6f,
mood = "",
energyLevel = 5,
stressLevel = 5,
symptoms = emptyList(),
notes = ""
)
onRecordUpdate(currentRecord.copy(bloodPressureS = sys))
}
},
@@ -256,7 +268,19 @@ private fun VitalSignsCard(
onValueChange = {
diastolic = it
it.toIntOrNull()?.let { dia ->
val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now())
val currentRecord = healthRecord ?: HealthRecordEntity(
date = LocalDate.now(),
weight = 0f,
heartRate = 0,
bloodPressureS = 0,
bloodPressureD = 0,
temperature = 36.6f,
mood = "",
energyLevel = 5,
stressLevel = 5,
symptoms = emptyList(),
notes = ""
)
onRecordUpdate(currentRecord.copy(bloodPressureD = dia))
}
},
@@ -274,7 +298,19 @@ private fun VitalSignsCard(
onValueChange = {
heartRate = it
it.toIntOrNull()?.let { hr ->
val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now())
val currentRecord = healthRecord ?: HealthRecordEntity(
date = LocalDate.now(),
weight = 0f,
heartRate = 0,
bloodPressureS = 0,
bloodPressureD = 0,
temperature = 36.6f,
mood = "",
energyLevel = 5,
stressLevel = 5,
symptoms = emptyList(),
notes = ""
)
onRecordUpdate(currentRecord.copy(heartRate = hr))
}
},
@@ -290,7 +326,19 @@ private fun VitalSignsCard(
value = notes,
onValueChange = {
notes = it
val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now())
val currentRecord = healthRecord ?: HealthRecordEntity(
date = LocalDate.now(),
weight = 0f,
heartRate = 0,
bloodPressureS = 0,
bloodPressureD = 0,
temperature = 36.6f,
mood = "",
energyLevel = 5,
stressLevel = 5,
symptoms = emptyList(),
notes = ""
)
onRecordUpdate(currentRecord.copy(notes = it))
},
label = { Text("Заметки") },
@@ -329,7 +377,7 @@ private fun VitalSignsCard(
)
}
if (healthRecord.notes.isNotEmpty()) {
if ((healthRecord.notes ?: "").isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
@@ -339,7 +387,7 @@ private fun VitalSignsCard(
shape = RoundedCornerShape(8.dp)
) {
Text(
text = healthRecord.notes,
text = healthRecord.notes ?: "",
style = MaterialTheme.typography.bodyMedium.copy(
color = TextPrimary
),

View File

@@ -38,21 +38,29 @@ class HealthViewModel @Inject constructor(
try {
// Загружаем данные о здоровье за сегодня
repository.getTodayHealthData().collect { todayRecord ->
repository.getTodayHealthData().collect { todayRecord: HealthRecordEntity? ->
_uiState.value = _uiState.value.copy(
todayRecord = todayRecord,
lastUpdateDate = todayRecord?.date,
todaySymptoms = todayRecord?.symptoms?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
todaySymptoms = todayRecord?.symptoms ?: emptyList(),
todayNotes = todayRecord?.notes ?: "",
isLoading = false
)
}
// Загружаем недельные данные веса
loadWeeklyWeights()
repository.getAllHealthRecords().collect { records: List<HealthRecordEntity> ->
val weightsMap = records
.filter { it.weight != null && it.weight > 0f }
.groupBy { it.date }
.mapValues { entry -> entry.value.last().weight ?: 0f }
_uiState.value = _uiState.value.copy(weeklyWeights = weightsMap)
}
// Загружаем последние записи
loadRecentRecords()
repository.getRecentHealthRecords().collect { records: List<HealthRecordEntity> ->
_uiState.value = _uiState.value.copy(recentRecords = records)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
@@ -63,26 +71,6 @@ class HealthViewModel @Inject constructor(
}
}
private suspend fun loadWeeklyWeights() {
try {
// Временная заглушка - методы репозитория пока не реализованы
val weightsMap = emptyMap<LocalDate, Float>()
_uiState.value = _uiState.value.copy(weeklyWeights = weightsMap)
} catch (e: Exception) {
// Игнорируем ошибки загрузки весов
}
}
private suspend fun loadRecentRecords() {
try {
// Временная заглушка - методы репозитория пока не реализованы
val records = emptyList<HealthRecordEntity>()
_uiState.value = _uiState.value.copy(recentRecords = records)
} catch (e: Exception) {
// Игнорируем ошибки загрузки записей
}
}
fun updateVitals(weight: Float?, heartRate: Int?, bpSystolic: Int?, bpDiastolic: Int?, temperature: Float?) {
viewModelScope.launch {
try {
@@ -102,13 +90,15 @@ class HealthViewModel @Inject constructor(
heartRate = heartRate,
bloodPressureS = bpSystolic,
bloodPressureD = bpDiastolic,
temperature = temperature
temperature = temperature,
mood = "",
energyLevel = 5,
stressLevel = 5,
symptoms = emptyList(),
notes = ""
)
}
// Временная заглушка - метод saveHealthRecord пока не реализован
// repository.saveHealthRecord(updatedRecord)
repository.saveHealthRecord(updatedRecord)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
@@ -124,13 +114,19 @@ class HealthViewModel @Inject constructor(
} else {
HealthRecordEntity(
date = LocalDate.now(),
mood = mood
weight = 0f,
heartRate = 0,
bloodPressureS = 0,
bloodPressureD = 0,
temperature = 36.6f,
mood = mood,
energyLevel = 5,
stressLevel = 5,
symptoms = emptyList(),
notes = ""
)
}
// Временная заглушка - метод saveHealthRecord пока не реализован
// repository.saveHealthRecord(updatedRecord)
repository.saveHealthRecord(updatedRecord)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
@@ -146,13 +142,19 @@ class HealthViewModel @Inject constructor(
} else {
HealthRecordEntity(
date = LocalDate.now(),
energyLevel = energy
weight = 0f,
heartRate = 0,
bloodPressureS = 0,
bloodPressureD = 0,
temperature = 36.6f,
mood = "",
energyLevel = energy,
stressLevel = 5,
symptoms = emptyList(),
notes = ""
)
}
// Временная заглушка - метод saveHealthRecord пока не реализован
// repository.saveHealthRecord(updatedRecord)
repository.saveHealthRecord(updatedRecord)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
@@ -168,13 +170,19 @@ class HealthViewModel @Inject constructor(
} else {
HealthRecordEntity(
date = LocalDate.now(),
stressLevel = stress
weight = 0f,
heartRate = 0,
bloodPressureS = 0,
bloodPressureD = 0,
temperature = 36.6f,
mood = "",
energyLevel = 5,
stressLevel = stress,
symptoms = emptyList(),
notes = ""
)
}
// Временная заглушка - метод saveHealthRecord пока не реализован
// repository.saveHealthRecord(updatedRecord)
repository.saveHealthRecord(updatedRecord)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
@@ -183,23 +191,27 @@ class HealthViewModel @Inject constructor(
fun updateSymptoms(symptoms: List<String>) {
_uiState.value = _uiState.value.copy(todaySymptoms = symptoms)
viewModelScope.launch {
try {
val currentRecord = _uiState.value.todayRecord
val symptomsString = symptoms.joinToString(",")
val updatedRecord = if (currentRecord != null) {
currentRecord.copy(symptoms = symptomsString)
currentRecord.copy(symptoms = symptoms)
} else {
HealthRecordEntity(
date = LocalDate.now(),
symptoms = symptomsString
weight = 0f,
heartRate = 0,
bloodPressureS = 0,
bloodPressureD = 0,
temperature = 36.6f,
mood = "",
energyLevel = 5,
stressLevel = 5,
symptoms = symptoms,
notes = ""
)
}
// Временная заглушка - метод saveHealthRecord пока не реализован
// repository.saveHealthRecord(updatedRecord)
repository.saveHealthRecord(updatedRecord)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
@@ -208,7 +220,6 @@ class HealthViewModel @Inject constructor(
fun updateNotes(notes: String) {
_uiState.value = _uiState.value.copy(todayNotes = notes)
viewModelScope.launch {
try {
val currentRecord = _uiState.value.todayRecord
@@ -217,13 +228,19 @@ class HealthViewModel @Inject constructor(
} else {
HealthRecordEntity(
date = LocalDate.now(),
weight = 0f,
heartRate = 0,
bloodPressureS = 0,
bloodPressureD = 0,
temperature = 36.6f,
mood = "",
energyLevel = 5,
stressLevel = 5,
symptoms = emptyList(),
notes = notes
)
}
// Временная заглушка - метод saveHealthRecord пока не реализован
// repository.saveHealthRecord(updatedRecord)
repository.saveHealthRecord(updatedRecord)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
@@ -233,8 +250,7 @@ class HealthViewModel @Inject constructor(
fun deleteHealthRecord(record: HealthRecordEntity) {
viewModelScope.launch {
try {
// Временная заглушка - метод deleteHealthRecord пока не реализован
// repository.deleteHealthRecord(record.id)
repository.deleteHealthRecord(record.id)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}

View File

@@ -0,0 +1,77 @@
package kr.smartsoltech.wellshe.ui.health
import androidx.compose.runtime.Composable
import androidx.compose.material3.*
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.LaunchedEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SportScreen(onBack: () -> Unit, viewModel: SportViewModel = hiltViewModel()) {
val exercises by viewModel.exercises.collectAsState()
val sessions by viewModel.sessions.collectAsState()
val activeSessionId by viewModel.activeSessionId.collectAsState()
val (search, setSearch) = remember { mutableStateOf("") }
val (selectedExercise, setSelectedExercise) = remember { mutableStateOf<Long?>(null) }
LaunchedEffect(Unit) { viewModel.loadExercises() ; viewModel.loadSessions() }
Scaffold(
topBar = {
TopAppBar(title = { Text("Тренировки и спорт") }, navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = null) }
})
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
OutlinedTextField(
value = search,
onValueChange = {
setSearch(it)
viewModel.loadExercises(it)
},
label = { Text("Поиск упражнения") },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
Text("Выберите упражнение:", style = MaterialTheme.typography.titleSmall)
exercises.forEach { ex ->
Row(Modifier.fillMaxWidth().clickable { setSelectedExercise(ex.id) }) {
RadioButton(selected = selectedExercise == ex.id, onClick = { setSelectedExercise(ex.id) })
Text(ex.name, Modifier.padding(start = 8.dp))
}
}
Spacer(Modifier.height(16.dp))
if (activeSessionId == null && selectedExercise != null) {
Button(onClick = { viewModel.startSession(selectedExercise!!) }, modifier = Modifier.fillMaxWidth()) {
Text("Старт тренировки")
}
}
if (activeSessionId != null) {
Button(onClick = { viewModel.stopSession() }, modifier = Modifier.fillMaxWidth()) {
Text("Стоп тренировки")
}
// TODO: таймер, параметры, онлайн-расчёт калорий
}
Spacer(Modifier.height(24.dp))
Text("История тренировок за неделю", style = MaterialTheme.typography.titleSmall)
sessions.forEach { session ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(session.startedAt.toString(), Modifier.weight(1f))
Text("${session.kcalTotal?.let { String.format("%.0f ккал", it) } ?: ""}", Modifier.weight(1f))
Text("${session.distanceKm?.let { String.format("%.2f км", it) } ?: ""}", Modifier.weight(1f))
}
}
}
}
}

View File

@@ -0,0 +1,57 @@
package kr.smartsoltech.wellshe.ui.health
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.data.repo.WorkoutService
import kr.smartsoltech.wellshe.data.entity.Exercise
import kr.smartsoltech.wellshe.data.entity.WorkoutSession
import javax.inject.Inject
@HiltViewModel
class SportViewModel @Inject constructor(
private val workoutService: WorkoutService
) : ViewModel() {
private val _exercises = MutableStateFlow<List<Exercise>>(emptyList())
val exercises: StateFlow<List<Exercise>> = _exercises
private val _sessions = MutableStateFlow<List<WorkoutSession>>(emptyList())
val sessions: StateFlow<List<WorkoutSession>> = _sessions
private val _activeSessionId = MutableStateFlow<Long?>(null)
val activeSessionId: StateFlow<Long?> = _activeSessionId
fun loadExercises(query: String = "") {
viewModelScope.launch {
_exercises.value = workoutService.searchExercises(query)
}
}
fun startSession(exerciseId: Long) {
viewModelScope.launch {
val sessionId = workoutService.startSession(exerciseId)
_activeSessionId.value = sessionId
loadSessions()
}
}
fun stopSession() {
viewModelScope.launch {
val sessionId = _activeSessionId.value
if (sessionId != null) {
workoutService.stopSession(sessionId)
}
_activeSessionId.value = null
loadSessions()
}
}
fun loadSessions() {
viewModelScope.launch {
_sessions.value = workoutService.getSessions(days = 7)
}
}
}

View File

@@ -0,0 +1,64 @@
package kr.smartsoltech.wellshe.ui.health
import androidx.compose.runtime.Composable
import androidx.compose.material3.*
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.foundation.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WaterScreen(onBack: () -> Unit, viewModel: WaterViewModel = hiltViewModel()) {
val waterToday by viewModel.waterToday.collectAsState()
val dailyGoal by viewModel.dailyGoal.collectAsState()
val waterHistory by viewModel.waterHistory.collectAsState()
LaunchedEffect(Unit) { viewModel.loadHistory() }
Scaffold(
topBar = {
TopAppBar(title = { Text("Вода и напитки") }, navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = null) }
})
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
Text("Быстрые кнопки объёмов воды и напитков", style = MaterialTheme.typography.titleMedium)
Row(Modifier.padding(vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
listOf(200, 300, 500).forEach { ml ->
Button(onClick = { viewModel.logWater(ml) }) {
Text("+${ml} мл")
}
}
}
Spacer(Modifier.height(16.dp))
Text("Сегодня: $waterToday мл / $dailyGoal мл", style = MaterialTheme.typography.bodyLarge)
LinearProgressIndicator(
progress = (waterToday / dailyGoal.toFloat()).coerceIn(0f, 1f),
modifier = Modifier.fillMaxWidth().height(12.dp),
color = Color(0xFF42A5F5)
)
Spacer(Modifier.height(24.dp))
Text("График за неделю", style = MaterialTheme.typography.titleSmall)
Canvas(modifier = Modifier.fillMaxWidth().height(120.dp)) {
val max = (waterHistory.maxOrNull() ?: 1).toFloat()
val barWidth = size.width / (waterHistory.size.coerceAtLeast(1))
waterHistory.forEachIndexed { i, v ->
drawRect(
color = Color(0xFF42A5F5),
topLeft = androidx.compose.ui.geometry.Offset(i * barWidth, size.height * (1 - v / max)),
size = androidx.compose.ui.geometry.Size(barWidth * 0.7f, size.height * (v / max))
)
}
}
}
}
}

View File

@@ -0,0 +1,45 @@
package kr.smartsoltech.wellshe.ui.health
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.data.repo.DrinkLogger
import kotlinx.coroutines.flow.update
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
class WaterViewModel @Inject constructor(
private val drinkLogger: DrinkLogger
) : ViewModel() {
private val _waterToday = MutableStateFlow(0)
val waterToday: StateFlow<Int> = _waterToday
private val _waterHistory = MutableStateFlow<List<Int>>(emptyList())
val waterHistory: StateFlow<List<Int>> = _waterHistory
private val _dailyGoal = MutableStateFlow(2000) // по умолчанию 2 литра
val dailyGoal: StateFlow<Int> = _dailyGoal
fun setDailyGoal(goal: Int) { _dailyGoal.value = goal }
fun logWater(volumeMl: Int) {
viewModelScope.launch {
drinkLogger.logWater(ts = Instant.now(), volumeMl = volumeMl)
loadHistory()
}
}
fun loadHistory() {
viewModelScope.launch {
val history = drinkLogger.getWaterHistory(days = 7)
_waterHistory.value = history
_waterToday.value = history.lastOrNull() ?: 0
}
}
// TODO: прогресс-бар
}

View File

@@ -0,0 +1,71 @@
package kr.smartsoltech.wellshe.ui.health
import androidx.compose.runtime.Composable
import androidx.compose.material3.*
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.foundation.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WeightScreen(onBack: () -> Unit, viewModel: WeightViewModel = hiltViewModel()) {
val weightToday by viewModel.weightToday.collectAsState()
val weightHistory by viewModel.weightHistory.collectAsState()
val (inputWeight, setInputWeight) = remember { mutableStateOf("") }
LaunchedEffect(Unit) { viewModel.loadHistory() }
Scaffold(
topBar = {
TopAppBar(title = { Text("Контроль веса") }, navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = null) }
})
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
Text("Последний вес: ${weightToday?.let { String.format("%.1f", it) } ?: ""} кг", style = MaterialTheme.typography.titleMedium)
Row(Modifier.padding(vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = inputWeight,
onValueChange = setInputWeight,
label = { Text("Новый вес (кг)") },
modifier = Modifier.weight(1f)
)
Button(onClick = {
inputWeight.toFloatOrNull()?.let {
viewModel.addWeight(it)
setInputWeight("")
}
}) {
Text("Сохранить")
}
}
Spacer(Modifier.height(16.dp))
Text("График веса за неделю", style = MaterialTheme.typography.titleSmall)
Canvas(modifier = Modifier.fillMaxWidth().height(120.dp)) {
val max = (weightHistory.maxOfOrNull { it.second } ?: 1f)
val min = (weightHistory.minOfOrNull { it.second } ?: 0f)
val barWidth = size.width / (weightHistory.size.coerceAtLeast(1))
weightHistory.forEachIndexed { i, pair ->
val v = pair.second
val norm = if (max > min) (v - min) / (max - min) else 0.5f
drawRect(
color = Color(0xFFAB47BC),
topLeft = androidx.compose.ui.geometry.Offset(i * barWidth, size.height * (1 - norm)),
size = androidx.compose.ui.geometry.Size(barWidth * 0.7f, size.height * norm)
)
}
}
}
}
}

View File

@@ -0,0 +1,37 @@
package kr.smartsoltech.wellshe.ui.health
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.data.repo.WeightRepository
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
class WeightViewModel @Inject constructor(
private val weightRepository: WeightRepository
) : ViewModel() {
private val _weightToday = MutableStateFlow<Float?>(null)
val weightToday: StateFlow<Float?> = _weightToday
private val _weightHistory = MutableStateFlow<List<Pair<String, Float>>>(emptyList())
val weightHistory: StateFlow<List<Pair<String, Float>>> = _weightHistory
fun addWeight(kg: Float) {
viewModelScope.launch {
weightRepository.addWeight(ts = Instant.now(), kg = kg)
loadHistory()
}
}
fun loadHistory() {
viewModelScope.launch {
val history = weightRepository.getWeightHistory(days = 7)
_weightHistory.value = history
_weightToday.value = history.lastOrNull()?.second
}
}
}

View File

@@ -0,0 +1,258 @@
package kr.smartsoltech.wellshe.ui.mood
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.ModeNight
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kr.smartsoltech.wellshe.ui.components.InfoCard
import kr.smartsoltech.wellshe.ui.components.StatCard
import kr.smartsoltech.wellshe.ui.theme.MoodTabColor
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
/**
* Экран "Настроение" для отслеживания сна и эмоционального состояния
*/
@Composable
fun MoodScreen(
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Статистические карточки
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
StatCard(
title = "Сон",
value = "7.2 ч",
tone = Color(0xFF673AB7), // Фиолетовый для сна
modifier = Modifier.weight(1f)
)
StatCard(
title = "Стресс",
value = "3/10",
tone = Color(0xFFE91E63), // Розовый для стресса
modifier = Modifier.weight(1f)
)
}
// Карточка дневника
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MoodTabColor.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Заголовок
Text(
text = "Дневник",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
// Содержимое дневника
Text(
text = "Сегодня было продуктивно, немного тревоги перед встречей. Выполнила все запланированные задачи, чувствую удовлетворение от проделанной работы.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Кнопки действий
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = { /* TODO */ }) {
Text("Редактировать")
}
TextButton(onClick = { /* TODO */ }) {
Text("Добавить запись")
}
}
}
}
// Карточка сна
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Заголовок с иконкой
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.ModeNight,
contentDescription = null,
tint = Color(0xFF673AB7)
)
Text(
text = "Качество сна",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
// Оценка сна
Column(
modifier = Modifier.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Продолжительность")
Text("7.2 часа", fontWeight = FontWeight.SemiBold)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Качество")
Text("Хорошее", fontWeight = FontWeight.SemiBold)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Пробуждения")
Text("1 раз", fontWeight = FontWeight.SemiBold)
}
}
// Кнопка добавления записи
OutlinedButton(
onClick = { /* TODO */ },
modifier = Modifier.fillMaxWidth()
) {
Text("Записать сон")
}
}
}
// Карточка эмоций
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Заголовок с иконкой
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = null,
tint = Color(0xFFE91E63)
)
Text(
text = "Эмоциональное состояние",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
// Текущее настроение
Text(
text = "Текущее настроение: Спокойствие, удовлетворение",
style = MaterialTheme.typography.bodyMedium
)
// Кнопки эмоций
EmojiButtonsRow()
}
}
// Карточка рекомендаций
InfoCard(
title = "Рекомендации",
content = "Стабильный сон и низкий уровень стресса положительно влияют на ваш цикл. Рекомендуется поддерживать текущий режим для гормонального баланса."
)
}
}
/**
* Строка кнопок с эмодзи для выбора эмоций
*/
@Composable
fun EmojiButtonsRow() {
val emojis = listOf("😊", "😌", "🙂", "😐", "😔", "😢", "😡")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
emojis.forEach { emoji ->
OutlinedButton(
onClick = { /* TODO */ },
contentPadding = PaddingValues(12.dp),
modifier = Modifier.size(44.dp),
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Text(
text = emoji,
style = MaterialTheme.typography.titleMedium
)
}
}
}
}
@Preview(showBackground = true)
@Composable
fun MoodScreenPreview() {
WellSheTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MoodScreen()
}
}
}

View File

@@ -0,0 +1,33 @@
package kr.smartsoltech.wellshe.ui.mood
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@HiltViewModel
class MoodViewModel @Inject constructor() : ViewModel() {
// Данные для экрана настроения
private val _sleepHours = MutableStateFlow(7.2f)
val sleepHours: StateFlow<Float> = _sleepHours.asStateFlow()
private val _stressLevel = MutableStateFlow(3)
val stressLevel: StateFlow<Int> = _stressLevel.asStateFlow()
private val _journalEntry = MutableStateFlow("Сегодня было продуктивно, немного тревоги перед встречей.")
val journalEntry: StateFlow<String> = _journalEntry.asStateFlow()
fun updateSleepHours(hours: Float) {
_sleepHours.value = hours
}
fun updateStressLevel(level: Int) {
_stressLevel.value = level
}
fun updateJournalEntry(entry: String) {
_journalEntry.value = entry
}
}

View File

@@ -0,0 +1,55 @@
package kr.smartsoltech.wellshe.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import kr.smartsoltech.wellshe.ui.analytics.AnalyticsScreen
import kr.smartsoltech.wellshe.ui.body.BodyScreen
import kr.smartsoltech.wellshe.ui.cycle.CycleScreen
import kr.smartsoltech.wellshe.ui.mood.MoodScreen
import kr.smartsoltech.wellshe.ui.profile.ProfileScreen
@Composable
fun AppNavGraph(
navController: NavHostController,
startDestination: String = BottomNavItem.Cycle.route
) {
NavHost(
navController = navController,
startDestination = startDestination
) {
composable(BottomNavItem.Cycle.route) {
CycleScreen(
onNavigateToSettings = {
navController.navigate("cycle_settings")
}
)
}
// Маршрут к экрану настроек цикла
composable("cycle_settings") {
kr.smartsoltech.wellshe.ui.cycle.settings.CycleSettingsMainScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
composable(BottomNavItem.Body.route) {
BodyScreen()
}
composable(BottomNavItem.Mood.route) {
MoodScreen()
}
composable(BottomNavItem.Analytics.route) {
AnalyticsScreen()
}
composable(BottomNavItem.Profile.route) {
ProfileScreen()
}
}
}

View File

@@ -0,0 +1,52 @@
package kr.smartsoltech.wellshe.ui.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Модель навигационного элемента для нижней панели навигац<D0B0><D186>и
*/
sealed class BottomNavItem(
val route: String,
val title: String,
val icon: ImageVector
) {
data object Cycle : BottomNavItem(
route = "cycle",
title = "Цикл",
icon = Icons.Default.WbSunny
)
data object Body : BottomNavItem(
route = "body",
title = "Тело",
icon = Icons.Default.WaterDrop
)
data object Mood : BottomNavItem(
route = "mood",
title = "Настроение",
icon = Icons.Default.Favorite
)
data object Analytics : BottomNavItem(
route = "analytics",
title = "Аналитика",
icon = Icons.Default.BarChart
)
data object Profile : BottomNavItem(
route = "profile",
title = "Профиль",
icon = Icons.Default.Person
)
companion object {
val items = listOf(Cycle, Body, Mood, Analytics, Profile)
}
}

Some files were not shown because too many files have changed in this diff Show More