main fucntions

This commit is contained in:
2025-10-16 12:39:50 +09:00
parent f57cd956bd
commit 6395c0fc36
28 changed files with 14462 additions and 206 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>

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

2
.idea/vcs.xml generated
View File

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

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

@@ -43,7 +43,7 @@ import androidx.room.TypeConverter
ExerciseFormulaVar::class,
CatalogVersion::class
],
version = 2,
version = 11,
exportSchema = true
)
@TypeConverters(LocalDateConverter::class, InstantConverter::class, StringListConverter::class)

View File

@@ -96,3 +96,681 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
)
}
}
/**
* Миграция базы данных с версии 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

@@ -51,6 +51,13 @@ class CycleRepository @Inject constructor(
recalculateForecasts()
}
/**
* Обновляет настройки цикла
*/
suspend fun updateSettings(settings: CycleSettingsEntity) {
settingsDao.insertOrUpdate(settings)
}
// История циклов
fun getAllHistoryFlow(): Flow<List<CycleHistoryEntity>> = historyDao.getAllFlow()

View File

@@ -14,6 +14,15 @@ 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
@@ -33,7 +42,7 @@ object AppModule {
AppDatabase::class.java,
"well_she_db"
)
.addMigrations(MIGRATION_1_2)
.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()

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

@@ -3,6 +3,8 @@ package kr.smartsoltech.wellshe.ui.cycle
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.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -28,7 +30,8 @@ import java.util.Locale
@Composable
fun CycleScreen(
modifier: Modifier = Modifier,
viewModel: CycleViewModel = hiltViewModel()
viewModel: CycleViewModel = hiltViewModel(),
onNavigateToSettings: () -> Unit = {} // Добавляем параметр для навигации к настройкам
) {
val uiState by viewModel.uiState.collectAsState()
val scrollState = rememberScrollState()
@@ -38,12 +41,26 @@ fun CycleScreen(
viewModel.loadCycleData()
}
Scaffold { paddingValues ->
Scaffold(
topBar = {
TopAppBar(
title = { Text("Менструальный цикл") },
actions = {
IconButton(onClick = onNavigateToSettings) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = "Настройки цикла"
)
}
}
)
}
) { paddingValues ->
Column(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {

View File

@@ -1,8 +1,6 @@
package kr.smartsoltech.wellshe.ui.cycle.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -13,7 +11,6 @@ import androidx.compose.ui.unit.dp
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
import kr.smartsoltech.wellshe.domain.models.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@@ -55,7 +52,7 @@ fun CycleSettingsContent(
// Секция метода определения овуляции
OvulationMethodSection(
currentMethod = OvulationMethod.fromString(settings.ovulationMethod),
currentMethod = ovulationMethodFromString(settings.ovulationMethod),
allowManualOvulation = settings.allowManualOvulation,
onMethodChanged = onOvulationMethodChanged,
onAllowManualChanged = onAllowManualOvulationChanged
@@ -77,7 +74,7 @@ fun CycleSettingsContent(
// Секция настроек сенсоров и единиц измерения
SensorSettingsSection(
tempUnit = TemperatureUnit.fromString(settings.tempUnit),
tempUnit = temperatureUnitFromString(settings.tempUnit),
bbtTimeWindow = settings.bbtTimeWindow,
timezone = settings.timezone,
validationState = validationState,
@@ -92,7 +89,7 @@ fun CycleSettingsContent(
ovulationReminderDays = settings.ovulationReminderDaysBefore,
pmsWindowDays = settings.pmsWindowDays,
deviationAlertDays = settings.deviationAlertDays,
fertileWindowMode = FertileWindowMode.fromString(settings.fertileWindowMode),
fertileWindowMode = toModelFertileWindowMode(fertileWindowModeFromString(settings.fertileWindowMode)),
onSettingChanged = onNotificationSettingChanged
)
@@ -153,7 +150,7 @@ fun BasicSettingsSection(
value = settings.baselineCycleLength.toString(),
onValueChange = {
try {
onSettingChanged(BasicSettingChange.BaselineCycleLength(it.toInt()))
onSettingChanged(BasicSettingChange.CycleLengthChanged(it.toInt()))
} catch (e: NumberFormatException) {
// Игнорируем некорректный ввод
}
@@ -172,7 +169,7 @@ fun BasicSettingsSection(
value = settings.cycleVariabilityDays.toString(),
onValueChange = {
try {
onSettingChanged(BasicSettingChange.CycleVariability(it.toInt()))
onSettingChanged(BasicSettingChange.CycleVariabilityChanged(it.toInt()))
} catch (e: NumberFormatException) {
// Игнорируем некорректный ввод
}
@@ -191,7 +188,7 @@ fun BasicSettingsSection(
value = settings.periodLengthDays.toString(),
onValueChange = {
try {
onSettingChanged(BasicSettingChange.PeriodLength(it.toInt()))
onSettingChanged(BasicSettingChange.PeriodLengthChanged(it.toInt()))
} catch (e: NumberFormatException) {
// Игнорируем некорректный ввод
}
@@ -209,7 +206,7 @@ fun BasicSettingsSection(
OutlinedTextField(
value = settings.lutealPhaseDays,
onValueChange = {
onSettingChanged(BasicSettingChange.LutealPhase(it))
onSettingChanged(BasicSettingChange.LutealPhaseChanged(it))
},
label = { Text("Лютеиновая фаза (дни или 'auto')") },
isError = validationState.lutealPhaseError != null,
@@ -253,7 +250,7 @@ fun BasicSettingsSection(
val date = java.time.Instant.ofEpochMilli(millis)
.atZone(java.time.ZoneId.systemDefault())
.toLocalDate()
onSettingChanged(BasicSettingChange.LastPeriodStart(date))
onSettingChanged(BasicSettingChange.LastPeriodStartChanged(date))
}
showDatePicker = false
}
@@ -341,6 +338,19 @@ fun OvulationMethodSection(
}
}
/**
* Функция-адаптер для преобразования типов между доменной моделью и 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
}
}
/**
* Секция статусов, влияющих на точность прогнозов
*/
@@ -376,7 +386,7 @@ fun StatusSection(
) {
RadioButton(
selected = hormonalContraception == type,
onClick = { onStatusChanged(StatusChange.HormonalContraception(type)) }
onClick = { onStatusChanged(StatusChange.HormonalContraceptionChanged(type.toUiModel())) }
)
Text(
@@ -395,16 +405,16 @@ fun StatusSection(
// Другие статусы (чекбоксы)
val statusItems = listOf(
Triple("Беременность", isPregnant) { value: Boolean ->
onStatusChanged(StatusChange.Pregnant(value))
onStatusChanged(StatusChange.PregnancyStatusChanged(value))
},
Triple("Послеродовой период", isPostpartum) { value: Boolean ->
onStatusChanged(StatusChange.Postpartum(value))
onStatusChanged(StatusChange.PostpartumStatusChanged(value))
},
Triple("Грудное вскармливание", isLactating) { value: Boolean ->
onStatusChanged(StatusChange.Lactating(value))
onStatusChanged(StatusChange.LactatingStatusChanged(value))
},
Triple("Перименопауза", perimenopause) { value: Boolean ->
onStatusChanged(StatusChange.Perimenopause(value))
onStatusChanged(StatusChange.PerimenopauseStatusChanged(value))
}
)
@@ -535,5 +545,24 @@ fun CycleHistorySection(
}
}
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

@@ -17,6 +17,9 @@ 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
/**
* Секция настроек сенсоров и единиц измерения

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

@@ -2,189 +2,486 @@ 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.ArrowBack
import androidx.compose.material.icons.filled.Refresh
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.R
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
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(
viewModel: CycleSettingsViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
initial: CycleSettings = CycleSettings(),
onClose: () -> Unit,
onSave: (CycleSettings) -> Unit
) {
val settings by viewModel.settingsState.collectAsStateWithLifecycle()
val validationState by viewModel.validationState.collectAsStateWithLifecycle()
val exportImportState by viewModel.exportImportState.collectAsStateWithLifecycle()
val cycleHistory by viewModel.cycleHistory.collectAsStateWithLifecycle()
var state by remember { mutableStateOf(initial) }
var showDatePicker by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
// Отслеживаем события UI из ViewModel
LaunchedEffect(Unit) {
viewModel.uiEvents.observeForever { event ->
when (event) {
is CycleSettingsViewModel.UiEvent.ShowSnackbar -> {
coroutineScope.launch {
val result = if (event.actionLabel != null && event.action != null) {
snackbarHostState.showSnackbar(
message = event.message,
actionLabel = event.actionLabel,
duration = SnackbarDuration.Long
)
} else {
snackbarHostState.showSnackbar(
message = event.message,
duration = SnackbarDuration.Short
)
}
// Вызываем действие отмены, если пользователь нажал на кнопку действия
if (result == SnackbarResult.ActionPerformed && event.action != null) {
event.action.invoke()
}
}
}
}
}
}
val (isValid, errorMsg) = remember(state) { validate(state) }
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Настройки цикла") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Назад"
)
}
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = "Закрыть") }
},
actions = {
IconButton(onClick = { viewModel.resetToDefaults() }) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Сбросить к рекомендуемым"
)
}
IconButton(
enabled = isValid,
onClick = { onSave(state) }
) { Icon(Icons.Filled.Save, contentDescription = "Сохранить") }
}
)
}
) { paddingValues ->
) { inner ->
Column(
modifier = Modifier
.padding(inner)
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (settings == null) {
// Показываем загрузку, если настройки еще не получены
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
// ---- Блок «Базовые параметры цикла» ----
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)
}
} else {
// Основное содержимое экрана настроек
CycleSettingsContent(
settings = settings!!,
validationState = validationState,
cycleHistory = cycleHistory,
exportImportState = exportImportState,
onBasicSettingChanged = {
)
}
// Дата начала последней менструации
OutlinedButton(onClick = { showDatePicker = true }) {
Text("Начало последней менструации: ${state.lastPeriodStart.formatRus()}")
}
}
// ---- Блок «Метод овуляции / ручной ввод» ----
SectionCard("Овуляция") {
EnumDropdown(
label = "Метод определения",
value = state.ovulationMethod,
options = OvulationMethod.entries,
titleMapper = {
when (it) {
is BasicSettingChange.BaselineCycleLength ->
viewModel.updateBaselineCycleLength(it.value)
is BasicSettingChange.CycleVariability ->
viewModel.updateCycleVariability(it.value)
is BasicSettingChange.PeriodLength ->
viewModel.updatePeriodLength(it.value)
is BasicSettingChange.LutealPhase ->
viewModel.updateLutealPhase(it.value)
is BasicSettingChange.LastPeriodStart ->
viewModel.updateLastPeriodStart(it.value)
OvulationMethod.AUTO -> "Авто"
OvulationMethod.BBT -> "BBT"
OvulationMethod.LH_TEST -> "Тесты LH"
OvulationMethod.CERVICAL_MUCUS -> "Цервикальная слизь"
OvulationMethod.MEDICAL -> "Мед.данные"
}
},
onOvulationMethodChanged = {
viewModel.updateOvulationMethod(it)
},
onAllowManualOvulationChanged = {
viewModel.updateAllowManualOvulation(it)
},
onStatusChanged = {
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) {
is StatusChange.HormonalContraception ->
viewModel.updateHormonalContraception(it.value)
is StatusChange.Pregnant ->
viewModel.updatePregnancyStatus(it.value)
is StatusChange.Postpartum ->
viewModel.updatePostpartumStatus(it.value)
is StatusChange.Lactating ->
viewModel.updateLactatingStatus(it.value)
is StatusChange.Perimenopause ->
viewModel.updatePerimenopauseStatus(it.value)
HormonalContraception.NONE -> "Нет"
HormonalContraception.COC -> "КОК"
HormonalContraception.IUD -> "ВМС"
HormonalContraception.IMPLANT -> "Имплант"
HormonalContraception.OTHER -> "Другое"
}
},
onHistorySettingChanged = {
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) {
is HistorySetting.WindowCycles ->
viewModel.updateHistoryWindow(it.value)
is HistorySetting.ExcludeOutliers ->
viewModel.updateExcludeOutliers(it.value)
FertileWindowMode.CONSERVATIVE -> "Консервативный"
FertileWindowMode.BALANCED -> "Сбалансированный"
FertileWindowMode.BROAD -> "Широкий"
}
},
onSensorSettingChanged = {
when (it) {
is SensorSetting.TempUnit ->
viewModel.updateTemperatureUnit(it.value)
is SensorSetting.BbtTimeWindow ->
viewModel.updateBbtTimeWindow(it.value)
is SensorSetting.Timezone ->
viewModel.updateTimezone(it.value)
onChange = { state = state.copy(fertileWindowMode = it) }
)
}
},
onNotificationSettingChanged = {
when (it) {
is NotificationSetting.PeriodReminder ->
viewModel.updatePeriodReminderDays(it.value)
is NotificationSetting.OvulationReminder ->
viewModel.updateOvulationReminderDays(it.value)
is NotificationSetting.PmsWindow ->
viewModel.updatePmsWindowDays(it.value)
is NotificationSetting.DeviationAlert ->
viewModel.updateDeviationAlertDays(it.value)
is NotificationSetting.FertileWindowMode ->
viewModel.updateFertileWindowMode(it.value)
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("Готово") }
},
onCycleAtypicalToggled = { cycleId, atypical ->
viewModel.toggleCycleAtypical(cycleId, atypical)
},
onExportSettings = {
viewModel.exportSettingsToJson()
},
onImportSettings = { json ->
viewModel.importSettingsFromJson(json)
},
onResetExportImportState = {
viewModel.resetExportImportState()
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
}
)
}
@@ -192,40 +489,38 @@ fun CycleSettingsScreen(
}
}
/**
* Классы для передачи событий изменения настроек из UI в ViewModel
*/
sealed class BasicSettingChange {
data class BaselineCycleLength(val value: Int) : BasicSettingChange()
data class CycleVariability(val value: Int) : BasicSettingChange()
data class PeriodLength(val value: Int) : BasicSettingChange()
data class LutealPhase(val value: String) : BasicSettingChange()
data class LastPeriodStart(val value: java.time.LocalDate) : BasicSettingChange()
// ---------- Валидация ----------
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
}
sealed class StatusChange {
data class HormonalContraception(val value: kr.smartsoltech.wellshe.domain.models.HormonalContraceptionType) : StatusChange()
data class Pregnant(val value: Boolean) : StatusChange()
data class Postpartum(val value: Boolean) : StatusChange()
data class Lactating(val value: Boolean) : StatusChange()
data class Perimenopause(val value: Boolean) : StatusChange()
}
// ---------- Утилиты даты ----------
private fun LocalDate.formatRus(): String = format(DateTimeFormatter.ofPattern("d MMM yyyy"))
private fun LocalDate.toEpochMillis(): Long =
atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
sealed class HistorySetting {
data class WindowCycles(val value: Int) : HistorySetting()
data class ExcludeOutliers(val value: Boolean) : HistorySetting()
}
private fun Long.toLocalDate(): LocalDate =
java.time.Instant.ofEpochMilli(this).atZone(java.time.ZoneId.systemDefault()).toLocalDate()
sealed class SensorSetting {
data class TempUnit(val value: kr.smartsoltech.wellshe.domain.models.TemperatureUnit) : SensorSetting()
data class BbtTimeWindow(val value: String) : SensorSetting()
data class Timezone(val value: String) : SensorSetting()
// ---------- Превью ----------
@Preview(showBackground = true, widthDp = 380)
@Composable
private fun CycleSettingsScreenPreview() {
val demo = CycleSettings()
MaterialTheme {
CycleSettingsScreen(
initial = demo,
onClose = {},
onSave = {}
)
}
sealed class NotificationSetting {
data class PeriodReminder(val value: Int) : NotificationSetting()
data class OvulationReminder(val value: Int) : NotificationSetting()
data class PmsWindow(val value: Int) : NotificationSetting()
data class DeviationAlert(val value: Int) : NotificationSetting()
data class FertileWindowMode(val value: kr.smartsoltech.wellshe.domain.models.FertileWindowMode) : NotificationSetting()
}

View File

@@ -14,11 +14,7 @@ import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
import kr.smartsoltech.wellshe.data.repository.CycleRepository
import kr.smartsoltech.wellshe.domain.models.CycleSettings
import kr.smartsoltech.wellshe.domain.models.FertileWindowMode
import kr.smartsoltech.wellshe.domain.models.HormonalContraceptionType
import kr.smartsoltech.wellshe.domain.models.OvulationMethod
import kr.smartsoltech.wellshe.domain.models.TemperatureUnit
import kr.smartsoltech.wellshe.domain.models.*
import kr.smartsoltech.wellshe.domain.services.CycleSettingsExportService
import kr.smartsoltech.wellshe.workers.CycleNotificationManager
import java.time.LocalDate
@@ -39,6 +35,11 @@ class CycleSettingsViewModel @Inject constructor(
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()
@@ -516,6 +517,15 @@ class CycleSettingsViewModel @Inject constructor(
}
}
/**
* Сохраняет все настройки цикла
*/
suspend fun saveSettings(settings: CycleSettings) {
val entity = settings.toEntity()
cycleRepository.saveSettings(entity)
// Уведомления будут запланированы внутри метода saveSettings репозитория через recalculateForecasts
}
/**
* Модель состояния валидации полей
*/
@@ -567,5 +577,7 @@ class CycleSettingsViewModel @Inject constructor(
val actionLabel: String? = null,
val action: (() -> Unit)? = null
) : UiEvent()
object SettingsSaved : UiEvent()
}
}

View File

@@ -20,7 +20,20 @@ fun AppNavGraph(
startDestination = startDestination
) {
composable(BottomNavItem.Cycle.route) {
CycleScreen()
CycleScreen(
onNavigateToSettings = {
navController.navigate("cycle_settings")
}
)
}
// Маршрут к экрану настроек цикла
composable("cycle_settings") {
kr.smartsoltech.wellshe.ui.cycle.settings.CycleSettingsMainScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
composable(BottomNavItem.Body.route) {

View File

@@ -27,10 +27,11 @@ fun BottomNavigation(
NavigationBar(
modifier = modifier
.fillMaxWidth()
.height(80.dp), // Минимальная высота Material3
.height(64.dp)
.imePadding(), // Добавляем отступ для клавиатуры
containerColor = MaterialTheme.colorScheme.background,
tonalElevation = 8.dp,
windowInsets = WindowInsets(0.dp) // Убираем стандартные отступы
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) // Учитываем только горизонтальные системные отступы
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
@@ -81,7 +82,7 @@ fun BottomNavigation(
contentDescription = item.title,
modifier = Modifier
.padding(top = 4.dp)
.size(36.dp)
.size(32.dp) // Унифицируем размер иконок
.clip(RoundedCornerShape(6.dp))
.background(backgroundColor)
.padding(4.dp),
@@ -93,7 +94,7 @@ fun BottomNavigation(
contentDescription = item.title,
modifier = Modifier
.padding(top = 4.dp)
.size(32.dp),
.size(32.dp), // Размер не изменился
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}