main fucntions
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,13 @@ class CycleRepository @Inject constructor(
|
||||
recalculateForecasts()
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет настройки цикла
|
||||
*/
|
||||
suspend fun updateSettings(settings: CycleSettingsEntity) {
|
||||
settingsDao.insertOrUpdate(settings)
|
||||
}
|
||||
|
||||
// История циклов
|
||||
fun getAllHistoryFlow(): Flow<List<CycleHistoryEntity>> = historyDao.getAllFlow()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Этот файл больше не используется, все классы перенесены в CycleSettingsEvents.kt
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// Для полной реализации всех секций потребуется продолжить в новом файле
|
||||
// Из-за ограничений размера файла
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
* Секция настроек сенсоров и единиц измерения
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = "Опорная длина цикла (18–60)",
|
||||
value = state.baselineCycleLength,
|
||||
onValueChange = { state = state.copy(baselineCycleLength = it.coerceIn(18, 60)) }
|
||||
)
|
||||
NumberField(
|
||||
label = "Вариабельность (±0–10)",
|
||||
value = state.cycleVariabilityDays,
|
||||
onValueChange = { state = state.copy(cycleVariabilityDays = it.coerceIn(0, 10)) }
|
||||
)
|
||||
NumberField(
|
||||
label = "Длительность менструации (1–10)",
|
||||
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 = "Лютеиновая фаза (8–17, вручную)",
|
||||
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 = "Окно истории (циклов, 1–12)",
|
||||
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)
|
||||
}
|
||||
},
|
||||
onNotificationSettingChanged = {
|
||||
when (it) {
|
||||
is NotificationSetting.PeriodReminder ->
|
||||
viewModel.updatePeriodReminderDays(it.value)
|
||||
is NotificationSetting.OvulationReminder ->
|
||||
viewModel.updateOvulationReminderDays(it.value)
|
||||
is NotificationSetting.PmsWindow ->
|
||||
viewModel.updatePmsWindowDays(it.value)
|
||||
is NotificationSetting.DeviationAlert ->
|
||||
viewModel.updateDeviationAlertDays(it.value)
|
||||
is NotificationSetting.FertileWindowMode ->
|
||||
viewModel.updateFertileWindowMode(it.value)
|
||||
}
|
||||
},
|
||||
onCycleAtypicalToggled = { cycleId, atypical ->
|
||||
viewModel.toggleCycleAtypical(cycleId, atypical)
|
||||
},
|
||||
onExportSettings = {
|
||||
viewModel.exportSettingsToJson()
|
||||
},
|
||||
onImportSettings = { json ->
|
||||
viewModel.importSettingsFromJson(json)
|
||||
},
|
||||
onResetExportImportState = {
|
||||
viewModel.resetExportImportState()
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 "Длина цикла должна быть 18–60 дней"
|
||||
if (s.cycleVariabilityDays !in 0..10) return false to "Вариабельность 0–10"
|
||||
if (s.periodLengthDays !in 1..10) return false to "Менструация от 1 до 10 дней"
|
||||
if (s.lutealPhaseDays != null && s.lutealPhaseDays !in 8..17) return false to "Лютеиновая фаза 8–17 или Авто"
|
||||
if (s.historyWindowCycles !in 1..12) return false to "Окно истории 1–12 циклов"
|
||||
if (s.periodReminderDaysBefore !in 0..10) return false to "Напоминание о менструации 0–10 дней"
|
||||
if (s.ovulationReminderDaysBefore !in 0..10) return false to "Напоминание об овуляции 0–10 дней"
|
||||
if (s.pmsWindowDays !in 0..7) return false to "Окно ПМС 0–7 дней"
|
||||
if (s.deviationAlertDays !in 1..14) return false to "Оповещение об отклонении 1–14 дней"
|
||||
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()
|
||||
}
|
||||
|
||||
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()
|
||||
// ---------- Превью ----------
|
||||
@Preview(showBackground = true, widthDp = 380)
|
||||
@Composable
|
||||
private fun CycleSettingsScreenPreview() {
|
||||
val demo = CycleSettings()
|
||||
MaterialTheme {
|
||||
CycleSettingsScreen(
|
||||
initial = demo,
|
||||
onClose = {},
|
||||
onSave = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user