main commit
This commit is contained in:
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@@ -4,6 +4,14 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DropdownSelection timestamp="2025-10-12T11:23:35.923427438Z">
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=R3CT80VPBQZ" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</DropdownSelection>
|
||||||
|
<DialogSelection />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -17,6 +17,16 @@ android {
|
|||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
// Добавляем путь для экспорта схемы Room
|
||||||
|
javaCompileOptions {
|
||||||
|
annotationProcessorOptions {
|
||||||
|
arguments += mapOf(
|
||||||
|
"room.schemaLocation" to "$projectDir/schemas",
|
||||||
|
"room.incremental" to "true"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -67,6 +77,11 @@ dependencies {
|
|||||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
implementation("com.google.code.gson:gson:2.10.1")
|
implementation("com.google.code.gson:gson:2.10.1")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
|
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
|
||||||
|
implementation("com.squareup.moshi:moshi:1.15.0")
|
||||||
|
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||||
|
implementation("com.squareup.moshi:moshi-adapters:1.15.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation("io.mockk:mockk:1.13.8")
|
testImplementation("io.mockk:mockk:1.13.8")
|
||||||
|
|||||||
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/2.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/2.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,30 +5,79 @@ import androidx.room.RoomDatabase
|
|||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import kr.smartsoltech.wellshe.data.entity.*
|
import kr.smartsoltech.wellshe.data.entity.*
|
||||||
import kr.smartsoltech.wellshe.data.dao.*
|
import kr.smartsoltech.wellshe.data.dao.*
|
||||||
import kr.smartsoltech.wellshe.data.converter.DateConverters
|
import java.time.LocalDate
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
|
// Основные сущности
|
||||||
WaterLogEntity::class,
|
WaterLogEntity::class,
|
||||||
WorkoutEntity::class,
|
|
||||||
SleepLogEntity::class,
|
SleepLogEntity::class,
|
||||||
CyclePeriodEntity::class,
|
WorkoutEntity::class,
|
||||||
HealthRecordEntity::class,
|
|
||||||
CalorieEntity::class,
|
CalorieEntity::class,
|
||||||
StepsEntity::class,
|
StepsEntity::class,
|
||||||
UserProfileEntity::class
|
UserProfileEntity::class,
|
||||||
|
WorkoutSession::class,
|
||||||
|
WorkoutSessionParam::class,
|
||||||
|
WorkoutEvent::class,
|
||||||
|
CyclePeriodEntity::class,
|
||||||
|
HealthRecordEntity::class,
|
||||||
|
|
||||||
|
// Новые сущности модуля "Настройки цикла"
|
||||||
|
CycleSettingsEntity::class,
|
||||||
|
CycleHistoryEntity::class,
|
||||||
|
CycleForecastEntity::class,
|
||||||
|
|
||||||
|
// Дополнительные сущности из BodyEntities.kt
|
||||||
|
Nutrient::class,
|
||||||
|
Beverage::class,
|
||||||
|
BeverageServing::class,
|
||||||
|
BeverageServingNutrient::class,
|
||||||
|
WaterLog::class,
|
||||||
|
BeverageLog::class,
|
||||||
|
BeverageLogNutrient::class,
|
||||||
|
WeightLog::class,
|
||||||
|
Exercise::class,
|
||||||
|
ExerciseParam::class,
|
||||||
|
ExerciseFormula::class,
|
||||||
|
ExerciseFormulaVar::class,
|
||||||
|
CatalogVersion::class
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = false
|
exportSchema = true
|
||||||
)
|
)
|
||||||
@TypeConverters(DateConverters::class)
|
@TypeConverters(LocalDateConverter::class, InstantConverter::class, StringListConverter::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun waterLogDao(): WaterLogDao
|
abstract fun waterLogDao(): WaterLogDao
|
||||||
abstract fun workoutDao(): WorkoutDao
|
|
||||||
abstract fun sleepLogDao(): SleepLogDao
|
abstract fun sleepLogDao(): SleepLogDao
|
||||||
abstract fun cyclePeriodDao(): CyclePeriodDao
|
abstract fun workoutDao(): WorkoutDao
|
||||||
abstract fun healthRecordDao(): HealthRecordDao
|
|
||||||
abstract fun calorieDao(): CalorieDao
|
abstract fun calorieDao(): CalorieDao
|
||||||
abstract fun stepsDao(): StepsDao
|
abstract fun stepsDao(): StepsDao
|
||||||
abstract fun userProfileDao(): UserProfileDao
|
abstract fun userProfileDao(): UserProfileDao
|
||||||
|
abstract fun cyclePeriodDao(): CyclePeriodDao
|
||||||
|
abstract fun healthRecordDao(): HealthRecordDao
|
||||||
|
|
||||||
|
// Новые DAO для модуля "Настройки цикла"
|
||||||
|
abstract fun cycleSettingsDao(): CycleSettingsDao
|
||||||
|
abstract fun cycleHistoryDao(): CycleHistoryDao
|
||||||
|
abstract fun cycleForecastDao(): CycleForecastDao
|
||||||
|
|
||||||
|
// Дополнительные DAO для repo
|
||||||
|
abstract fun beverageLogDao(): BeverageLogDao
|
||||||
|
abstract fun beverageLogNutrientDao(): BeverageLogNutrientDao
|
||||||
|
abstract fun beverageServingNutrientDao(): BeverageServingNutrientDao
|
||||||
|
abstract fun weightLogDao(): WeightLogDao
|
||||||
|
abstract fun workoutSessionDao(): WorkoutSessionDao
|
||||||
|
abstract fun workoutSessionParamDao(): WorkoutSessionParamDao
|
||||||
|
abstract fun workoutEventDao(): WorkoutEventDao
|
||||||
|
abstract fun exerciseDao(): ExerciseDao
|
||||||
|
abstract fun exerciseFormulaDao(): ExerciseFormulaDao
|
||||||
|
abstract fun exerciseFormulaVarDao(): ExerciseFormulaVarDao
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalDateConverter {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromTimestamp(value: Long?): LocalDate? = value?.let { java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault()).toLocalDate() }
|
||||||
|
@TypeConverter
|
||||||
|
fun dateToTimestamp(date: LocalDate?): Long? = date?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||||
}
|
}
|
||||||
|
|||||||
21
app/src/main/java/kr/smartsoltech/wellshe/data/Converters.kt
Normal file
21
app/src/main/java/kr/smartsoltech/wellshe/data/Converters.kt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
class InstantConverter {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromTimestamp(value: Long?): Instant? = value?.let { Instant.ofEpochMilli(it) }
|
||||||
|
@TypeConverter
|
||||||
|
fun instantToTimestamp(instant: Instant?): Long? = instant?.toEpochMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
class StringListConverter {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromString(value: String?): List<String>? = value?.let {
|
||||||
|
if (it.isEmpty()) emptyList() else it.split("||")
|
||||||
|
}
|
||||||
|
@TypeConverter
|
||||||
|
fun listToString(list: List<String>?): String = list?.joinToString("||") ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция базы данных с версии 1 на версию 2.
|
||||||
|
* Добавляет таблицы для модуля "Настройки цикла".
|
||||||
|
*/
|
||||||
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// Создание таблицы cycle_settings
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `cycle_settings` (
|
||||||
|
`id` INTEGER NOT NULL,
|
||||||
|
`baselineCycleLength` INTEGER NOT NULL,
|
||||||
|
`cycleVariabilityDays` INTEGER NOT NULL,
|
||||||
|
`periodLengthDays` INTEGER NOT NULL,
|
||||||
|
`lutealPhaseDays` TEXT NOT NULL,
|
||||||
|
`lastPeriodStart` INTEGER,
|
||||||
|
`ovulationMethod` TEXT NOT NULL,
|
||||||
|
`allowManualOvulation` INTEGER NOT NULL,
|
||||||
|
`hormonalContraception` TEXT NOT NULL,
|
||||||
|
`isPregnant` INTEGER NOT NULL,
|
||||||
|
`isPostpartum` INTEGER NOT NULL,
|
||||||
|
`isLactating` INTEGER NOT NULL,
|
||||||
|
`perimenopause` INTEGER NOT NULL,
|
||||||
|
`historyWindowCycles` INTEGER NOT NULL,
|
||||||
|
`excludeOutliers` INTEGER NOT NULL,
|
||||||
|
`tempUnit` TEXT NOT NULL,
|
||||||
|
`bbtTimeWindow` TEXT NOT NULL,
|
||||||
|
`timezone` TEXT NOT NULL,
|
||||||
|
`periodReminderDaysBefore` INTEGER NOT NULL,
|
||||||
|
`ovulationReminderDaysBefore` INTEGER NOT NULL,
|
||||||
|
`pmsWindowDays` INTEGER NOT NULL,
|
||||||
|
`deviationAlertDays` INTEGER NOT NULL,
|
||||||
|
`fertileWindowMode` TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(`id`)
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Создание таблицы cycle_history
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `cycle_history` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`periodStart` INTEGER NOT NULL,
|
||||||
|
`periodEnd` INTEGER,
|
||||||
|
`ovulationDate` INTEGER,
|
||||||
|
`notes` TEXT NOT NULL,
|
||||||
|
`atypical` INTEGER NOT NULL,
|
||||||
|
`flow` TEXT NOT NULL DEFAULT '',
|
||||||
|
`symptoms` TEXT NOT NULL DEFAULT '',
|
||||||
|
`mood` TEXT NOT NULL DEFAULT '',
|
||||||
|
`cycleLength` INTEGER
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Индекс для cycle_history по периоду начала
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS `index_cycle_history_periodStart` ON `cycle_history` (`periodStart`)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Создание таблицы cycle_forecast
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `cycle_forecast` (
|
||||||
|
`id` INTEGER NOT NULL,
|
||||||
|
`nextPeriodStart` INTEGER,
|
||||||
|
`nextOvulation` INTEGER,
|
||||||
|
`fertileStart` INTEGER,
|
||||||
|
`fertileEnd` INTEGER,
|
||||||
|
`pmsStart` INTEGER,
|
||||||
|
`updatedAt` INTEGER NOT NULL,
|
||||||
|
`isReliable` INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`id`)
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Импорт существующих данных из таблицы cycle_periods в cycle_history
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO cycle_history (periodStart, periodEnd, notes, atypical)
|
||||||
|
SELECT startDate, endDate,
|
||||||
|
CASE WHEN flow != '' OR mood != '' OR symptoms != ''
|
||||||
|
THEN 'Flow: ' || flow || ', Mood: ' || mood
|
||||||
|
ELSE ''
|
||||||
|
END,
|
||||||
|
0
|
||||||
|
FROM cycle_periods
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
151
app/src/main/java/kr/smartsoltech/wellshe/data/dao/BodyDao.kt
Normal file
151
app/src/main/java/kr/smartsoltech/wellshe/data/dao/BodyDao.kt
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.*
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface NutrientDao {
|
||||||
|
@Query("SELECT * FROM Nutrient WHERE code = :code LIMIT 1")
|
||||||
|
suspend fun getByCode(code: String): Nutrient?
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(nutrient: Nutrient): Long
|
||||||
|
@Query("SELECT * FROM Nutrient")
|
||||||
|
suspend fun getAll(): List<Nutrient>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface BeverageDao {
|
||||||
|
@Query("SELECT * FROM Beverage WHERE id = :id")
|
||||||
|
suspend fun getById(id: Long): Beverage?
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(beverage: Beverage): Long
|
||||||
|
@Query("SELECT * FROM Beverage WHERE name LIKE :query LIMIT 20")
|
||||||
|
suspend fun search(query: String): List<Beverage>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface BeverageServingDao {
|
||||||
|
@Query("SELECT * FROM BeverageServing WHERE beverageId = :beverageId")
|
||||||
|
suspend fun getByBeverage(beverageId: Long): List<BeverageServing>
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(serving: BeverageServing): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface BeverageServingNutrientDao {
|
||||||
|
@Query("SELECT * FROM BeverageServingNutrient WHERE servingId = :servingId")
|
||||||
|
suspend fun getByServing(servingId: Long): List<BeverageServingNutrient>
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(nutrient: BeverageServingNutrient): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WaterLogDao {
|
||||||
|
@Query("SELECT * FROM water_logs WHERE date = :date ORDER BY timestamp DESC")
|
||||||
|
suspend fun getWaterLogsForDate(date: LocalDate): List<WaterLogEntity>
|
||||||
|
|
||||||
|
@Query("SELECT SUM(amount) FROM water_logs WHERE date = :date")
|
||||||
|
suspend fun getTotalWaterForDate(date: LocalDate): Int?
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
suspend fun insertWaterLog(waterLog: WaterLogEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteWaterLog(waterLog: WaterLogEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface BeverageLogDao {
|
||||||
|
@Query("SELECT * FROM BeverageLog WHERE ts BETWEEN :from AND :to ORDER BY ts DESC")
|
||||||
|
suspend fun getLogs(from: Instant, to: Instant): List<BeverageLog>
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(log: BeverageLog): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface BeverageLogNutrientDao {
|
||||||
|
@Query("SELECT * FROM BeverageLogNutrient WHERE beverageLogId = :beverageLogId")
|
||||||
|
suspend fun getByLog(beverageLogId: Long): List<BeverageLogNutrient>
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(nutrient: BeverageLogNutrient): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WeightLogDao {
|
||||||
|
@Query("SELECT * FROM WeightLog ORDER BY ts DESC LIMIT 1")
|
||||||
|
suspend fun getLatestWeightKg(): WeightLog?
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(log: WeightLog): Long
|
||||||
|
@Query("SELECT * FROM WeightLog WHERE ts BETWEEN :from AND :to ORDER BY ts DESC")
|
||||||
|
suspend fun getLogs(from: Instant, to: Instant): List<WeightLog>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ExerciseDao {
|
||||||
|
@Query("SELECT * FROM Exercise WHERE id = :id")
|
||||||
|
suspend fun getById(id: Long): Exercise?
|
||||||
|
@Query("SELECT * FROM Exercise WHERE name LIKE :query LIMIT 20")
|
||||||
|
suspend fun search(query: String): List<Exercise>
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(exercise: Exercise): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ExerciseParamDao {
|
||||||
|
@Query("SELECT * FROM ExerciseParam WHERE exerciseId = :exerciseId")
|
||||||
|
suspend fun getByExercise(exerciseId: Long): List<ExerciseParam>
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(param: ExerciseParam): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ExerciseFormulaDao {
|
||||||
|
@Query("SELECT * FROM ExerciseFormula WHERE exerciseId = :exerciseId")
|
||||||
|
suspend fun getByExercise(exerciseId: Long): List<ExerciseFormula>
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(formula: ExerciseFormula): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ExerciseFormulaVarDao {
|
||||||
|
@Query("SELECT * FROM ExerciseFormulaVar WHERE formulaId = :formulaId")
|
||||||
|
suspend fun getByFormula(formulaId: Long): List<ExerciseFormulaVar>
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(varDef: ExerciseFormulaVar): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WorkoutSessionDao {
|
||||||
|
@Query("SELECT * FROM WorkoutSession WHERE startedAt BETWEEN :from AND :to ORDER BY startedAt DESC")
|
||||||
|
suspend fun getSessions(from: Instant, to: Instant): List<WorkoutSession>
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(session: WorkoutSession): Long
|
||||||
|
@Query("SELECT * FROM WorkoutSession WHERE id = :id")
|
||||||
|
suspend fun getById(id: Long): WorkoutSession?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WorkoutSessionParamDao {
|
||||||
|
@Query("SELECT * FROM WorkoutSessionParam WHERE sessionId = :sessionId")
|
||||||
|
suspend fun getBySession(sessionId: Long): List<WorkoutSessionParam>
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(param: WorkoutSessionParam): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WorkoutEventDao {
|
||||||
|
@Query("SELECT * FROM WorkoutEvent WHERE sessionId = :sessionId ORDER BY ts ASC")
|
||||||
|
suspend fun getBySession(sessionId: Long): List<WorkoutEvent>
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(event: WorkoutEvent): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CatalogVersionDao {
|
||||||
|
@Query("SELECT * FROM CatalogVersion WHERE source = :source LIMIT 1")
|
||||||
|
suspend fun getBySource(source: String): CatalogVersion?
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(version: CatalogVersion): Long
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleForecastEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CycleForecastDao {
|
||||||
|
@Query("SELECT * FROM cycle_forecast WHERE id = 1")
|
||||||
|
fun getForecastFlow(): Flow<CycleForecastEntity?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_forecast WHERE id = 1")
|
||||||
|
suspend fun getForecast(): CycleForecastEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(forecast: CycleForecastEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(forecast: CycleForecastEntity)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun insertOrUpdate(forecast: CycleForecastEntity) {
|
||||||
|
val existing = getForecast()
|
||||||
|
if (existing == null) {
|
||||||
|
insert(forecast)
|
||||||
|
} else {
|
||||||
|
update(forecast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("DELETE FROM cycle_forecast")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CycleHistoryDao {
|
||||||
|
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC")
|
||||||
|
fun getAllFlow(): Flow<List<CycleHistoryEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC")
|
||||||
|
suspend fun getAll(): List<CycleHistoryEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC LIMIT :limit")
|
||||||
|
suspend fun getRecentCycles(limit: Int): List<CycleHistoryEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_history WHERE atypical = 0 ORDER BY periodStart DESC LIMIT :limit")
|
||||||
|
suspend fun getRecentTypicalCycles(limit: Int): List<CycleHistoryEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_history WHERE id = :id")
|
||||||
|
suspend fun getById(id: Long): CycleHistoryEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_history WHERE periodStart = :date")
|
||||||
|
suspend fun getByStartDate(date: LocalDate): CycleHistoryEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(cycle: CycleHistoryEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(cycle: CycleHistoryEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun delete(cycle: CycleHistoryEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM cycle_history")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_history WHERE periodStart BETWEEN :startDate AND :endDate")
|
||||||
|
suspend fun getCyclesInRange(startDate: LocalDate, endDate: LocalDate): List<CycleHistoryEntity>
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CyclePeriodDao {
|
||||||
|
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC")
|
||||||
|
suspend fun getAll(): List<CyclePeriodEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(period: CyclePeriodEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(period: CyclePeriodEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun delete(period: CyclePeriodEntity)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_periods WHERE startDate = :date LIMIT 1")
|
||||||
|
suspend fun getByStartDate(date: LocalDate): CyclePeriodEntity?
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CycleSettingsDao {
|
||||||
|
@Query("SELECT * FROM cycle_settings WHERE id = 1")
|
||||||
|
fun getSettingsFlow(): Flow<CycleSettingsEntity?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_settings WHERE id = 1")
|
||||||
|
suspend fun getSettings(): CycleSettingsEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(settings: CycleSettingsEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(settings: CycleSettingsEntity)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun insertOrUpdate(settings: CycleSettingsEntity) {
|
||||||
|
val existing = getSettings()
|
||||||
|
if (existing == null) {
|
||||||
|
insert(settings)
|
||||||
|
} else {
|
||||||
|
update(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("DELETE FROM cycle_settings")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
|
||||||
|
@Query("UPDATE cycle_settings SET lastPeriodStart = :date WHERE id = 1")
|
||||||
|
suspend fun updateLastPeriodStart(date: java.time.LocalDate)
|
||||||
|
}
|
||||||
@@ -5,51 +5,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kr.smartsoltech.wellshe.data.entity.*
|
import kr.smartsoltech.wellshe.data.entity.*
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface WaterLogDao {
|
|
||||||
@Query("SELECT * FROM water_logs WHERE date = :date ORDER BY timestamp DESC")
|
|
||||||
fun getWaterLogsForDate(date: LocalDate): Flow<List<WaterLogEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT SUM(amount) FROM water_logs WHERE date = :date")
|
|
||||||
suspend fun getTotalWaterForDate(date: LocalDate): Int?
|
|
||||||
|
|
||||||
@Insert
|
|
||||||
suspend fun insertWaterLog(waterLog: WaterLogEntity)
|
|
||||||
|
|
||||||
@Delete
|
|
||||||
suspend fun deleteWaterLog(waterLog: WaterLogEntity)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM water_logs WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
|
||||||
fun getWaterLogsForPeriod(startDate: LocalDate, endDate: LocalDate): Flow<List<WaterLogEntity>>
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface CyclePeriodDao {
|
|
||||||
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC")
|
|
||||||
fun getAllPeriods(): Flow<List<CyclePeriodEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT 1")
|
|
||||||
suspend fun getLastPeriod(): CyclePeriodEntity?
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT 1")
|
|
||||||
fun getCurrentPeriod(): Flow<CyclePeriodEntity?>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT :limit")
|
|
||||||
fun getRecentPeriods(limit: Int): Flow<List<CyclePeriodEntity>>
|
|
||||||
|
|
||||||
@Insert
|
|
||||||
suspend fun insertPeriod(period: CyclePeriodEntity)
|
|
||||||
|
|
||||||
@Update
|
|
||||||
suspend fun updatePeriod(period: CyclePeriodEntity)
|
|
||||||
|
|
||||||
@Delete
|
|
||||||
suspend fun deletePeriod(period: CyclePeriodEntity)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cycle_periods WHERE startDate BETWEEN :startDate AND :endDate")
|
|
||||||
fun getPeriodsInRange(startDate: LocalDate, endDate: LocalDate): Flow<List<CyclePeriodEntity>>
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface SleepLogDao {
|
interface SleepLogDao {
|
||||||
@Query("SELECT * FROM sleep_logs WHERE date = :date")
|
@Query("SELECT * FROM sleep_logs WHERE date = :date")
|
||||||
|
|||||||
@@ -1,36 +1,29 @@
|
|||||||
package kr.smartsoltech.wellshe.data.dao
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
|
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface HealthRecordDao {
|
interface HealthRecordDao {
|
||||||
@Query("SELECT * FROM health_records WHERE date = :date")
|
@Query("SELECT * FROM health_records ORDER BY date DESC")
|
||||||
suspend fun getHealthRecordForDate(date: LocalDate): HealthRecordEntity?
|
suspend fun getAll(): List<HealthRecordEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM health_records ORDER BY date DESC LIMIT :limit")
|
|
||||||
fun getRecentHealthRecords(limit: Int = 30): Flow<List<HealthRecordEntity>>
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertHealthRecord(record: HealthRecordEntity)
|
suspend fun insert(record: HealthRecordEntity): Long
|
||||||
|
|
||||||
@Update
|
@Update
|
||||||
suspend fun updateHealthRecord(record: HealthRecordEntity)
|
suspend fun update(record: HealthRecordEntity)
|
||||||
|
|
||||||
@Delete
|
@Delete
|
||||||
suspend fun deleteHealthRecord(record: HealthRecordEntity)
|
suspend fun delete(record: HealthRecordEntity)
|
||||||
|
|
||||||
@Query("DELETE FROM health_records WHERE id = :id")
|
@Query("SELECT * FROM health_records WHERE date = :date LIMIT 1")
|
||||||
suspend fun deleteHealthRecordById(id: Long)
|
suspend fun getByDate(date: LocalDate): HealthRecordEntity?
|
||||||
|
|
||||||
@Query("SELECT * FROM health_records WHERE date BETWEEN :startDate AND :endDate ORDER BY date")
|
@Query("SELECT * FROM health_records ORDER BY date DESC")
|
||||||
suspend fun getHealthRecordsInRange(startDate: LocalDate, endDate: LocalDate): List<HealthRecordEntity>
|
fun getAllFlow(): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>>
|
||||||
|
|
||||||
@Query("SELECT AVG(weight) FROM health_records WHERE weight IS NOT NULL AND date BETWEEN :startDate AND :endDate")
|
@Query("SELECT * FROM health_records WHERE date = :date LIMIT 1")
|
||||||
suspend fun getAverageWeight(startDate: LocalDate, endDate: LocalDate): Float?
|
fun getByDateFlow(date: LocalDate): kotlinx.coroutines.flow.Flow<HealthRecordEntity?>
|
||||||
|
|
||||||
@Query("SELECT AVG(heartRate) FROM health_records WHERE heartRate IS NOT NULL AND date BETWEEN :startDate AND :endDate")
|
|
||||||
suspend fun getAverageHeartRate(startDate: LocalDate, endDate: LocalDate): Float?
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@Entity(tableName = "Nutrient", indices = [Index("code", unique = true)])
|
||||||
|
data class Nutrient(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val code: String,
|
||||||
|
val name: String,
|
||||||
|
val unit: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "Beverage")
|
||||||
|
data class Beverage(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val name: String,
|
||||||
|
val brand: String?,
|
||||||
|
val category: String,
|
||||||
|
val source: String,
|
||||||
|
val sourceRef: String,
|
||||||
|
val isCaffeinated: Boolean,
|
||||||
|
val isSweetened: Boolean,
|
||||||
|
val createdAt: Instant
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "BeverageServing")
|
||||||
|
data class BeverageServing(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val beverageId: Long,
|
||||||
|
val label: String,
|
||||||
|
val volumeMl: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "BeverageServingNutrient")
|
||||||
|
data class BeverageServingNutrient(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val servingId: Long,
|
||||||
|
val nutrientId: Long,
|
||||||
|
val amountPerServing: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "WaterLog", indices = [Index("ts")])
|
||||||
|
data class WaterLog(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val ts: Instant,
|
||||||
|
val volumeMl: Int,
|
||||||
|
val source: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "BeverageLog", indices = [Index("ts")])
|
||||||
|
data class BeverageLog(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val ts: Instant,
|
||||||
|
val beverageId: Long,
|
||||||
|
val servingId: Long,
|
||||||
|
val servingsCount: Int,
|
||||||
|
val notes: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "BeverageLogNutrient")
|
||||||
|
data class BeverageLogNutrient(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val beverageLogId: Long,
|
||||||
|
val nutrientId: Long,
|
||||||
|
val amountTotal: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "WeightLog", indices = [Index("ts")])
|
||||||
|
data class WeightLog(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val ts: Instant,
|
||||||
|
val weightKg: Float,
|
||||||
|
val source: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "Exercise")
|
||||||
|
data class Exercise(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val name: String,
|
||||||
|
val category: String,
|
||||||
|
val description: String?,
|
||||||
|
val metValue: Float?,
|
||||||
|
val source: String,
|
||||||
|
val sourceRef: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "ExerciseParam")
|
||||||
|
data class ExerciseParam(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val exerciseId: Long,
|
||||||
|
val key: String,
|
||||||
|
val valueType: String,
|
||||||
|
val unit: String?,
|
||||||
|
val required: Boolean,
|
||||||
|
val defaultValue: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "ExerciseFormula")
|
||||||
|
data class ExerciseFormula(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val exerciseId: Long,
|
||||||
|
val name: String,
|
||||||
|
val exprKcal: String,
|
||||||
|
val notes: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "ExerciseFormulaVar")
|
||||||
|
data class ExerciseFormulaVar(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val formulaId: Long,
|
||||||
|
val varKey: String,
|
||||||
|
val required: Boolean,
|
||||||
|
val unit: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "WorkoutSession", indices = [Index("startedAt")])
|
||||||
|
data class WorkoutSession(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val startedAt: Instant,
|
||||||
|
val endedAt: Instant?,
|
||||||
|
val exerciseId: Long,
|
||||||
|
val kcalTotal: Float?,
|
||||||
|
val distanceKm: Float?,
|
||||||
|
val notes: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "WorkoutSessionParam")
|
||||||
|
data class WorkoutSessionParam(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val sessionId: Long,
|
||||||
|
val key: String,
|
||||||
|
val valueNum: Float?,
|
||||||
|
val valueText: String?,
|
||||||
|
val unit: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "WorkoutEvent")
|
||||||
|
data class WorkoutEvent(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val sessionId: Long,
|
||||||
|
val ts: Instant,
|
||||||
|
val eventType: String,
|
||||||
|
val payloadJson: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "CatalogVersion", indices = [Index("source", unique = true)])
|
||||||
|
data class CatalogVersion(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val localVersion: Int,
|
||||||
|
val source: String,
|
||||||
|
val lastSyncAt: Instant
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кэш прогнозов цикла для быстрого доступа в UI.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "cycle_forecast")
|
||||||
|
data class CycleForecastEntity(
|
||||||
|
@PrimaryKey val id: Int = 1, // Singleton
|
||||||
|
val nextPeriodStart: LocalDate? = null,
|
||||||
|
val nextOvulation: LocalDate? = null,
|
||||||
|
val fertileStart: LocalDate? = null,
|
||||||
|
val fertileEnd: LocalDate? = null,
|
||||||
|
val pmsStart: LocalDate? = null,
|
||||||
|
val updatedAt: Instant = Instant.now(),
|
||||||
|
val isReliable: Boolean = true // Flag для пониженной точности при определенных статусах
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* История циклов для расчета прогнозов и анализа.
|
||||||
|
*/
|
||||||
|
@Entity(
|
||||||
|
tableName = "cycle_history",
|
||||||
|
indices = [Index(value = ["periodStart"], unique = true)]
|
||||||
|
)
|
||||||
|
data class CycleHistoryEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val periodStart: LocalDate,
|
||||||
|
val periodEnd: LocalDate? = null,
|
||||||
|
val ovulationDate: LocalDate? = null,
|
||||||
|
val notes: String = "",
|
||||||
|
val atypical: Boolean = false,
|
||||||
|
// Добавляем поля для соответствия с CyclePeriodEntity
|
||||||
|
val flow: String = "",
|
||||||
|
val symptoms: List<String> = emptyList(),
|
||||||
|
val mood: String = "",
|
||||||
|
val cycleLength: Int? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Entity(tableName = "cycle_periods")
|
||||||
|
data class CyclePeriodEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val startDate: LocalDate,
|
||||||
|
val endDate: LocalDate?,
|
||||||
|
val flow: String = "",
|
||||||
|
val symptoms: List<String> = emptyList(),
|
||||||
|
val mood: String = "",
|
||||||
|
val cycleLength: Int? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основные настройки для модуля отслеживания менструального цикла.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "cycle_settings")
|
||||||
|
data class CycleSettingsEntity(
|
||||||
|
@PrimaryKey val id: Int = 1, // Singleton
|
||||||
|
|
||||||
|
// Основные параметры цикла
|
||||||
|
val baselineCycleLength: Int = 28,
|
||||||
|
val cycleVariabilityDays: Int = 3,
|
||||||
|
val periodLengthDays: Int = 5,
|
||||||
|
val lutealPhaseDays: String = "auto", // "auto" или число (8-17)
|
||||||
|
val lastPeriodStart: LocalDate? = null,
|
||||||
|
|
||||||
|
// Метод определения овуляции
|
||||||
|
val ovulationMethod: String = "auto", // auto, bbt, lh_test, cervical_mucus, medical
|
||||||
|
val allowManualOvulation: Boolean = false,
|
||||||
|
|
||||||
|
// Статусы влияющие на точность
|
||||||
|
val hormonalContraception: String = "none", // none, coc, iud, implant, other
|
||||||
|
val isPregnant: Boolean = false,
|
||||||
|
val isPostpartum: Boolean = false,
|
||||||
|
val isLactating: Boolean = false,
|
||||||
|
val perimenopause: Boolean = false,
|
||||||
|
|
||||||
|
// Настройки истории и исключения выбросов
|
||||||
|
val historyWindowCycles: Int = 6,
|
||||||
|
val excludeOutliers: Boolean = true,
|
||||||
|
|
||||||
|
// Сенсоры и единицы измерения
|
||||||
|
val tempUnit: String = "C", // C или F
|
||||||
|
val bbtTimeWindow: String = "06:00-10:00",
|
||||||
|
val timezone: String = "Asia/Seoul",
|
||||||
|
|
||||||
|
// Уведомления
|
||||||
|
val periodReminderDaysBefore: Int = 2,
|
||||||
|
val ovulationReminderDaysBefore: Int = 1,
|
||||||
|
val pmsWindowDays: Int = 3,
|
||||||
|
val deviationAlertDays: Int = 5,
|
||||||
|
val fertileWindowMode: String = "balanced" // conservative, balanced, broad
|
||||||
|
)
|
||||||
@@ -13,18 +13,6 @@ data class WaterLogEntity(
|
|||||||
val timestamp: Long = System.currentTimeMillis()
|
val timestamp: Long = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "cycle_periods")
|
|
||||||
data class CyclePeriodEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
|
||||||
val id: Long = 0,
|
|
||||||
val startDate: LocalDate,
|
|
||||||
val endDate: LocalDate?,
|
|
||||||
val cycleLength: Int = 28,
|
|
||||||
val flow: String = "medium", // light, medium, heavy
|
|
||||||
val symptoms: String = "", // JSON строка симптомов
|
|
||||||
val mood: String = "neutral"
|
|
||||||
)
|
|
||||||
|
|
||||||
@Entity(tableName = "sleep_logs")
|
@Entity(tableName = "sleep_logs")
|
||||||
data class SleepLogEntity(
|
data class SleepLogEntity(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
@@ -37,23 +25,6 @@ data class SleepLogEntity(
|
|||||||
val notes: String = ""
|
val notes: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "health_records")
|
|
||||||
data class HealthRecordEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
|
||||||
val id: Long = 0,
|
|
||||||
val date: LocalDate,
|
|
||||||
val weight: Float? = null,
|
|
||||||
val heartRate: Int? = null,
|
|
||||||
val bloodPressureS: Int? = null, // систолическое
|
|
||||||
val bloodPressureD: Int? = null, // диастолическое
|
|
||||||
val temperature: Float? = null,
|
|
||||||
val mood: String = "neutral",
|
|
||||||
val energyLevel: Int = 5, // 1-10
|
|
||||||
val stressLevel: Int = 5, // 1-10
|
|
||||||
val symptoms: String = "", // JSON строка симптомов
|
|
||||||
val notes: String = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
@Entity(tableName = "workouts")
|
@Entity(tableName = "workouts")
|
||||||
data class WorkoutEntity(
|
data class WorkoutEntity(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
|
||||||
|
class HealthRecordConverters {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromSymptomsList(list: List<String>?): String? {
|
||||||
|
return list?.joinToString(separator = "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toSymptomsList(data: String?): List<String>? {
|
||||||
|
return data?.split("|")?.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Entity(tableName = "health_records")
|
||||||
|
data class HealthRecordEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val date: LocalDate,
|
||||||
|
val weight: Float?,
|
||||||
|
val heartRate: Int?,
|
||||||
|
val bloodPressureS: Int?,
|
||||||
|
val bloodPressureD: Int?,
|
||||||
|
val temperature: Float?,
|
||||||
|
val mood: String?,
|
||||||
|
val energyLevel: Int?,
|
||||||
|
val stressLevel: Int?,
|
||||||
|
val symptoms: List<String>?,
|
||||||
|
val notes: String?
|
||||||
|
)
|
||||||
|
|
||||||
119
app/src/main/java/kr/smartsoltech/wellshe/data/repo/BodyRepo.kt
Normal file
119
app/src/main/java/kr/smartsoltech/wellshe/data/repo/BodyRepo.kt
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repo
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.*
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.*
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
class BeverageCatalogRepository(
|
||||||
|
private val beverageDao: BeverageDao,
|
||||||
|
private val servingDao: BeverageServingDao,
|
||||||
|
private val servingNutrientDao: BeverageServingNutrientDao
|
||||||
|
) {
|
||||||
|
suspend fun search(query: String): List<Beverage> = beverageDao.search("%$query%")
|
||||||
|
suspend fun getServings(beverageId: Long): List<BeverageServing> = servingDao.getByBeverage(beverageId)
|
||||||
|
suspend fun getNutrients(servingId: Long): List<BeverageServingNutrient> = servingNutrientDao.getByServing(servingId)
|
||||||
|
// Методы syncFromUsda(), syncFromOpenFoodFacts() будут реализованы отдельно
|
||||||
|
}
|
||||||
|
|
||||||
|
class DrinkLogger(
|
||||||
|
private val waterLogDao: WaterLogDao,
|
||||||
|
private val beverageLogDao: BeverageLogDao,
|
||||||
|
private val beverageLogNutrientDao: BeverageLogNutrientDao,
|
||||||
|
private val servingNutrientDao: BeverageServingNutrientDao
|
||||||
|
) {
|
||||||
|
suspend fun logWater(ts: Instant, volumeMl: Int, source: String = "manual") {
|
||||||
|
waterLogDao.insertWaterLog(WaterLogEntity(date = ts.atZone(java.time.ZoneId.systemDefault()).toLocalDate(), amount = volumeMl, timestamp = ts.toEpochMilli()))
|
||||||
|
}
|
||||||
|
suspend fun logBeverage(ts: Instant, beverageId: Long, servingId: Long, servingsCount: Int, notes: String? = null) {
|
||||||
|
val logId = beverageLogDao.insert(BeverageLog(ts = ts, beverageId = beverageId, servingId = servingId, servingsCount = servingsCount, notes = notes))
|
||||||
|
val nutrients = servingNutrientDao.getByServing(servingId)
|
||||||
|
nutrients.forEach { n ->
|
||||||
|
beverageLogNutrientDao.insert(
|
||||||
|
BeverageLogNutrient(
|
||||||
|
beverageLogId = logId,
|
||||||
|
nutrientId = n.nutrientId,
|
||||||
|
amountTotal = n.amountPerServing * servingsCount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suspend fun getWaterHistory(days: Int): List<Int> {
|
||||||
|
val today = java.time.LocalDate.now()
|
||||||
|
return (0 until days).map { offset ->
|
||||||
|
val date = today.minusDays(offset.toLong())
|
||||||
|
waterLogDao.getTotalWaterForDate(date) ?: 0
|
||||||
|
}.reversed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WeightRepository(private val weightLogDao: WeightLogDao) {
|
||||||
|
suspend fun addWeight(ts: Instant, kg: Float, source: String = "manual") {
|
||||||
|
weightLogDao.insert(WeightLog(ts = ts, weightKg = kg, source = source))
|
||||||
|
}
|
||||||
|
suspend fun getLatestWeightKg(): Float? = weightLogDao.getLatestWeightKg()?.weightKg
|
||||||
|
suspend fun getWeightHistory(days: Int): List<Pair<String, Float>> {
|
||||||
|
val today = java.time.LocalDate.now()
|
||||||
|
return (0 until days).map { offset ->
|
||||||
|
val date = today.minusDays(offset.toLong())
|
||||||
|
val logs = weightLogDao.getLogs(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant(), date.plusDays(1).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant())
|
||||||
|
val weight = logs.firstOrNull()?.weightKg ?: 0f
|
||||||
|
date.toString() to weight
|
||||||
|
}.reversed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExerciseCatalogRepository(
|
||||||
|
private val exerciseDao: ExerciseDao,
|
||||||
|
private val paramDao: ExerciseParamDao,
|
||||||
|
private val formulaDao: ExerciseFormulaDao
|
||||||
|
) {
|
||||||
|
suspend fun searchExercises(query: String): List<Exercise> = exerciseDao.search("%$query%")
|
||||||
|
suspend fun getParams(exerciseId: Long): List<ExerciseParam> = paramDao.getByExercise(exerciseId)
|
||||||
|
suspend fun getFormulas(exerciseId: Long): List<ExerciseFormula> = formulaDao.getByExercise(exerciseId)
|
||||||
|
// Методы syncFromWger(), syncMetTables() будут реализованы отдельно
|
||||||
|
}
|
||||||
|
|
||||||
|
class WorkoutService(
|
||||||
|
private val sessionDao: WorkoutSessionDao,
|
||||||
|
private val paramDao: WorkoutSessionParamDao,
|
||||||
|
private val eventDao: WorkoutEventDao,
|
||||||
|
private val weightRepo: WeightRepository,
|
||||||
|
private val formulaDao: ExerciseFormulaDao,
|
||||||
|
private val formulaVarDao: ExerciseFormulaVarDao,
|
||||||
|
private val exerciseDao: ExerciseDao
|
||||||
|
) {
|
||||||
|
suspend fun searchExercises(query: String): List<Exercise> = exerciseDao.search("%$query%")
|
||||||
|
suspend fun getSessions(days: Int): List<WorkoutSession> {
|
||||||
|
val now = java.time.Instant.now()
|
||||||
|
val start = now.minusSeconds(days * 24 * 3600L)
|
||||||
|
return sessionDao.getSessions(start, now)
|
||||||
|
}
|
||||||
|
suspend fun stopSession(sessionId: Long) {
|
||||||
|
val session = sessionDao.getById(sessionId)
|
||||||
|
if (session != null && session.endedAt == null) {
|
||||||
|
sessionDao.insert(session.copy(endedAt = java.time.Instant.now()))
|
||||||
|
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = java.time.Instant.now(), eventType = "stop", payloadJson = "{}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suspend fun startSession(exerciseId: Long): Long {
|
||||||
|
val baseWeightKg = weightRepo.getLatestWeightKg() ?: 70f
|
||||||
|
val sessionId = sessionDao.insert(
|
||||||
|
WorkoutSession(
|
||||||
|
startedAt = Instant.now(),
|
||||||
|
endedAt = null,
|
||||||
|
exerciseId = exerciseId,
|
||||||
|
kcalTotal = null,
|
||||||
|
distanceKm = null,
|
||||||
|
notes = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
paramDao.insert(WorkoutSessionParam(sessionId = sessionId, key = "baseWeightKg", valueNum = baseWeightKg, valueText = null, unit = "kg"))
|
||||||
|
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = Instant.now(), eventType = "start", payloadJson = "{}"))
|
||||||
|
return sessionId
|
||||||
|
}
|
||||||
|
suspend fun updateParam(sessionId: Long, key: String, valueNum: Float?, valueText: String?, unit: String?) {
|
||||||
|
paramDao.insert(WorkoutSessionParam(sessionId = sessionId, key = key, valueNum = valueNum, valueText = valueText, unit = unit))
|
||||||
|
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = Instant.now(), eventType = "param_change", payloadJson = "{\"key\":\"$key\"}"))
|
||||||
|
}
|
||||||
|
// tick(sessionId) и stopSession(sessionId) будут реализованы с расчетом калорий по формуле
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.CycleForecastDao
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.CycleHistoryDao
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.CycleSettingsDao
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleForecastEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.CycleForecast
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.CycleSettings
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий для работы с данными цикла, настройками и прогнозами.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class CycleRepository @Inject constructor(
|
||||||
|
private val settingsDao: CycleSettingsDao,
|
||||||
|
private val historyDao: CycleHistoryDao,
|
||||||
|
private val forecastDao: CycleForecastDao
|
||||||
|
) {
|
||||||
|
// Настройки цикла
|
||||||
|
fun getSettingsFlow(): Flow<CycleSettingsEntity?> = settingsDao.getSettingsFlow()
|
||||||
|
|
||||||
|
suspend fun getSettings(): CycleSettingsEntity? = settingsDao.getSettings()
|
||||||
|
|
||||||
|
suspend fun saveSettings(settings: CycleSettingsEntity) {
|
||||||
|
settingsDao.insertOrUpdate(settings)
|
||||||
|
recalculateForecasts() // Пересчитываем прогнозы при изменении настроек
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateLastPeriodStart(date: LocalDate) {
|
||||||
|
val settings = settingsDao.getSettings() ?: createDefaultSettings()
|
||||||
|
settingsDao.insertOrUpdate(settings.copy(lastPeriodStart = date))
|
||||||
|
recalculateForecasts()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createDefaultSettings(): CycleSettingsEntity {
|
||||||
|
return CycleSettingsEntity()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resetToDefaults() {
|
||||||
|
settingsDao.insertOrUpdate(CycleSettingsEntity())
|
||||||
|
recalculateForecasts()
|
||||||
|
}
|
||||||
|
|
||||||
|
// История циклов
|
||||||
|
fun getAllHistoryFlow(): Flow<List<CycleHistoryEntity>> = historyDao.getAllFlow()
|
||||||
|
|
||||||
|
suspend fun getAllHistory(): List<CycleHistoryEntity> = historyDao.getAll()
|
||||||
|
|
||||||
|
suspend fun getRecentCycles(limit: Int): List<CycleHistoryEntity> = historyDao.getRecentCycles(limit)
|
||||||
|
|
||||||
|
suspend fun getRecentTypicalCycles(limit: Int): List<CycleHistoryEntity> =
|
||||||
|
historyDao.getRecentTypicalCycles(limit)
|
||||||
|
|
||||||
|
suspend fun addCycleToHistory(cycle: CycleHistoryEntity): Long {
|
||||||
|
val id = historyDao.insert(cycle)
|
||||||
|
recalculateForecasts()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateCycleInHistory(cycle: CycleHistoryEntity) {
|
||||||
|
historyDao.update(cycle)
|
||||||
|
recalculateForecasts()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteCycleFromHistory(cycle: CycleHistoryEntity) {
|
||||||
|
historyDao.delete(cycle)
|
||||||
|
recalculateForecasts()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markCycleAsAtypical(id: Long, atypical: Boolean) {
|
||||||
|
historyDao.getById(id)?.let { cycle ->
|
||||||
|
historyDao.update(cycle.copy(atypical = atypical))
|
||||||
|
recalculateForecasts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прогнозы
|
||||||
|
fun getForecastFlow(): Flow<CycleForecastEntity?> = forecastDao.getForecastFlow()
|
||||||
|
|
||||||
|
suspend fun getForecast(): CycleForecastEntity? = forecastDao.getForecast()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пересчитывает прогнозы на основе текущих настроек и истории циклов.
|
||||||
|
* Вызывается автоматически при изменении настроек или истории.
|
||||||
|
*/
|
||||||
|
suspend fun recalculateForecasts() {
|
||||||
|
val settings = settingsDao.getSettings() ?: return
|
||||||
|
val history = if (settings.excludeOutliers) {
|
||||||
|
historyDao.getRecentTypicalCycles(settings.historyWindowCycles)
|
||||||
|
} else {
|
||||||
|
historyDao.getRecentCycles(settings.historyWindowCycles)
|
||||||
|
}
|
||||||
|
|
||||||
|
val forecast = calculateForecast(settings, history)
|
||||||
|
forecastDao.insertOrUpdate(forecast)
|
||||||
|
|
||||||
|
// Здесь также можно вызвать планирование уведомлений на основе новых прогнозов
|
||||||
|
scheduleNotifications(forecast)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расчет прогноза цикла на основе настроек и истории.
|
||||||
|
*/
|
||||||
|
private fun calculateForecast(
|
||||||
|
settings: CycleSettingsEntity,
|
||||||
|
history: List<CycleHistoryEntity>
|
||||||
|
): CycleForecastEntity {
|
||||||
|
// Определяем надежность прогноза
|
||||||
|
val isReliable = !(settings.isPregnant || settings.isPostpartum ||
|
||||||
|
settings.isLactating || settings.perimenopause ||
|
||||||
|
settings.hormonalContraception != "none")
|
||||||
|
|
||||||
|
// Если нет истории и нет даты последней менструации, не можем сделать прогноз
|
||||||
|
if (history.isEmpty() && settings.lastPeriodStart == null) {
|
||||||
|
return CycleForecastEntity(
|
||||||
|
isReliable = isReliable,
|
||||||
|
updatedAt = Instant.now()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим дату последней менструации
|
||||||
|
val lastPeriodStart = settings.lastPeriodStart ?: history.firstOrNull()?.periodStart
|
||||||
|
|
||||||
|
if (lastPeriodStart == null) {
|
||||||
|
return CycleForecastEntity(
|
||||||
|
isReliable = isReliable,
|
||||||
|
updatedAt = Instant.now()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рассчитываем средний цикл на основе истории или используем базовые настройки
|
||||||
|
val cycleLength = if (history.size >= 2) {
|
||||||
|
calculateAverageCycleLength(history)
|
||||||
|
} else {
|
||||||
|
settings.baselineCycleLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рассчитываем л<><D0BB>теиновую фазу
|
||||||
|
val lutealPhase = if (settings.lutealPhaseDays == "auto") {
|
||||||
|
14 // Стандартная длина лютеиновой фазы
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
settings.lutealPhaseDays.toInt()
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рассчитываем даты
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val daysSinceLastPeriod = today.toEpochDay() - lastPeriodStart.toEpochDay()
|
||||||
|
val nextPeriodStart = if (daysSinceLastPeriod >= cycleLength) {
|
||||||
|
lastPeriodStart.plusDays(cycleLength.toLong() * (daysSinceLastPeriod / cycleLength + 1))
|
||||||
|
} else {
|
||||||
|
lastPeriodStart.plusDays(cycleLength.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
val ovulationDate = nextPeriodStart.minusDays(lutealPhase.toLong())
|
||||||
|
|
||||||
|
// Рассчитываем фертильное окно в зависимости от режима
|
||||||
|
val fertileWindowStart = when (settings.fertileWindowMode) {
|
||||||
|
"conservative" -> ovulationDate.minusDays(3)
|
||||||
|
"balanced" -> ovulationDate.minusDays(5)
|
||||||
|
"broad" -> ovulationDate.minusDays(7)
|
||||||
|
else -> ovulationDate.minusDays(5) // По умолчанию сбалансированный режим
|
||||||
|
}
|
||||||
|
|
||||||
|
val fertileWindowEnd = ovulationDate // День овуляции - последний фертильный день
|
||||||
|
|
||||||
|
// Рассчитываем начало ПМС
|
||||||
|
val pmsStart = nextPeriodStart.minusDays(settings.pmsWindowDays.toLong())
|
||||||
|
|
||||||
|
return CycleForecastEntity(
|
||||||
|
nextPeriodStart = nextPeriodStart,
|
||||||
|
nextOvulation = ovulationDate,
|
||||||
|
fertileStart = fertileWindowStart,
|
||||||
|
fertileEnd = fertileWindowEnd,
|
||||||
|
pmsStart = pmsStart,
|
||||||
|
isReliable = isReliable,
|
||||||
|
updatedAt = Instant.now()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рассчитывает среднюю длину цикла на основе истории.
|
||||||
|
*/
|
||||||
|
private fun calculateAverageCycleLength(history: List<CycleHistoryEntity>): Int {
|
||||||
|
if (history.size < 2) return 28 // Стандартный цикл если недостаточно данных
|
||||||
|
|
||||||
|
// Сортируем по дате начала
|
||||||
|
val sortedHistory = history.sortedBy { it.periodStart }
|
||||||
|
|
||||||
|
// Рассчитываем <20><>лину между началом каждого цикла
|
||||||
|
val cycleLengths = mutableListOf<Int>()
|
||||||
|
for (i in 0 until sortedHistory.size - 1) {
|
||||||
|
val current = sortedHistory[i]
|
||||||
|
val next = sortedHistory[i + 1]
|
||||||
|
val length = (next.periodStart.toEpochDay() - current.periodStart.toEpochDay()).toInt()
|
||||||
|
|
||||||
|
// Исключаем выбросы (слишком короткие или длинные циклы)
|
||||||
|
if (length >= 18 && length <= 60) {
|
||||||
|
cycleLengths.add(length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если после фильтрации нет данных, возвращаем стандартный цикл
|
||||||
|
if (cycleLengths.isEmpty()) return 28
|
||||||
|
|
||||||
|
// Возвращаем среднюю длину цикла, округленную до целого
|
||||||
|
return cycleLengths.average().toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Планирует уведомления на основе рассчитанных прогнозов.
|
||||||
|
*/
|
||||||
|
private suspend fun scheduleNotifications(forecast: CycleForecastEntity) {
|
||||||
|
// Это заглушка для метода планирования уведомлений
|
||||||
|
// Реальная реализация будет добавлена позже в классе NotificationManager
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экспортирует настройки цикла в JSON строку.
|
||||||
|
*/
|
||||||
|
suspend fun exportSettingsToJson(): String {
|
||||||
|
// В реальной реализации здесь будет использоваться библиотека для сериализации в JSON
|
||||||
|
// Например, Gson или Moshi
|
||||||
|
return "{}" // Заглушка
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Импортирует настройки цикла из JSON строки.
|
||||||
|
*/
|
||||||
|
suspend fun importSettingsFromJson(json: String): Boolean {
|
||||||
|
// В реальной реализации здесь будет использоваться библиотека для десериализации из JSON
|
||||||
|
return true // Заглушка
|
||||||
|
}
|
||||||
|
|
||||||
|
// Методы для работы с периодами (CyclePeriodEntity)
|
||||||
|
suspend fun getAllPeriods(): List<CyclePeriodEntity> {
|
||||||
|
// Получаем все периоды из истории и преобразуем их в CyclePeriodEntity
|
||||||
|
val history = historyDao.getAll()
|
||||||
|
return history.map { historyEntity ->
|
||||||
|
CyclePeriodEntity(
|
||||||
|
id = historyEntity.id,
|
||||||
|
startDate = historyEntity.periodStart,
|
||||||
|
endDate = historyEntity.periodEnd,
|
||||||
|
flow = historyEntity.flow,
|
||||||
|
symptoms = historyEntity.symptoms,
|
||||||
|
mood = historyEntity.mood,
|
||||||
|
cycleLength = historyEntity.cycleLength
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun insertPeriod(period: CyclePeriodEntity): Long {
|
||||||
|
// Преобразуем CyclePeriodEntity в CycleHistoryEntity для сохранения
|
||||||
|
val historyEntity = CycleHistoryEntity(
|
||||||
|
id = period.id,
|
||||||
|
periodStart = period.startDate,
|
||||||
|
periodEnd = period.endDate,
|
||||||
|
flow = period.flow,
|
||||||
|
symptoms = period.symptoms,
|
||||||
|
mood = period.mood,
|
||||||
|
cycleLength = period.cycleLength,
|
||||||
|
atypical = false // по умолчанию не отмечаем как нетипичный
|
||||||
|
)
|
||||||
|
return addCycleToHistory(historyEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updatePeriod(period: CyclePeriodEntity) {
|
||||||
|
// Преобразуем CyclePeriodEntity в CycleHistoryEntity для обновления
|
||||||
|
val historyEntity = CycleHistoryEntity(
|
||||||
|
id = period.id,
|
||||||
|
periodStart = period.startDate,
|
||||||
|
periodEnd = period.endDate,
|
||||||
|
flow = period.flow,
|
||||||
|
symptoms = period.symptoms,
|
||||||
|
mood = period.mood,
|
||||||
|
cycleLength = period.cycleLength,
|
||||||
|
atypical = false // сохраняем существующее значение, если возможно
|
||||||
|
)
|
||||||
|
updateCycleInHistory(historyEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deletePeriod(period: CyclePeriodEntity) {
|
||||||
|
val historyEntity = CycleHistoryEntity(
|
||||||
|
id = period.id,
|
||||||
|
periodStart = period.startDate,
|
||||||
|
periodEnd = period.endDate,
|
||||||
|
flow = period.flow,
|
||||||
|
symptoms = period.symptoms,
|
||||||
|
mood = period.mood,
|
||||||
|
cycleLength = period.cycleLength,
|
||||||
|
atypical = false
|
||||||
|
)
|
||||||
|
deleteCycleFromHistory(historyEntity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repository
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.HealthRecordDao
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class HealthRepository @Inject constructor(private val dao: HealthRecordDao) {
|
||||||
|
suspend fun getAllRecords(): List<HealthRecordEntity> = dao.getAll()
|
||||||
|
suspend fun insertRecord(record: HealthRecordEntity): Long = dao.insert(record)
|
||||||
|
suspend fun updateRecord(record: HealthRecordEntity) = dao.update(record)
|
||||||
|
suspend fun deleteRecord(record: HealthRecordEntity) = dao.delete(record)
|
||||||
|
suspend fun getRecordByDate(date: java.time.LocalDate): HealthRecordEntity? = dao.getByDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
package kr.smartsoltech.wellshe.data.repository
|
package kr.smartsoltech.wellshe.data.repository
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kr.smartsoltech.wellshe.data.dao.*
|
import kr.smartsoltech.wellshe.data.dao.*
|
||||||
import kr.smartsoltech.wellshe.data.entity.*
|
import kr.smartsoltech.wellshe.data.entity.*
|
||||||
import kr.smartsoltech.wellshe.domain.model.*
|
import kr.smartsoltech.wellshe.domain.model.AppSettings
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.FitnessData
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.User
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.WaterIntake
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.WorkoutSession
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
@@ -68,18 +73,19 @@ class WellSheRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getWaterIntakeForDate(date: LocalDate): Flow<List<WaterIntake>> {
|
fun getWaterIntakeForDate(date: LocalDate): Flow<List<WaterIntake>> {
|
||||||
return waterLogDao.getWaterLogsForDate(date).map { entities ->
|
return flow {
|
||||||
entities.map { entity ->
|
val entities = waterLogDao.getWaterLogsForDate(date)
|
||||||
|
emit(entities.map { entity ->
|
||||||
WaterIntake(
|
WaterIntake(
|
||||||
id = entity.id,
|
id = entity.id,
|
||||||
date = entity.date,
|
date = entity.date,
|
||||||
time = LocalTime.ofInstant(
|
time = LocalTime.of(
|
||||||
java.time.Instant.ofEpochMilli(entity.timestamp),
|
(entity.timestamp % (24 * 60 * 60 * 1000) / (60 * 60 * 1000)).toInt(),
|
||||||
java.time.ZoneId.systemDefault()
|
((entity.timestamp % (60 * 60 * 1000)) / (60 * 1000)).toInt()
|
||||||
),
|
),
|
||||||
amount = entity.amount / 1000f // конвертируем в литры
|
amount = entity.amount / 1000f // конвертируем в литры
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,23 +195,32 @@ class WellSheRepository @Inject constructor(
|
|||||||
// =================
|
// =================
|
||||||
|
|
||||||
suspend fun addPeriod(startDate: LocalDate, endDate: LocalDate?, flow: String, symptoms: List<String>, mood: String) {
|
suspend fun addPeriod(startDate: LocalDate, endDate: LocalDate?, flow: String, symptoms: List<String>, mood: String) {
|
||||||
cyclePeriodDao.insertPeriod(
|
val period = CyclePeriodEntity(
|
||||||
CyclePeriodEntity(
|
|
||||||
startDate = startDate,
|
startDate = startDate,
|
||||||
endDate = endDate,
|
endDate = endDate,
|
||||||
flow = flow,
|
flow = flow,
|
||||||
symptoms = symptoms.joinToString(","),
|
symptoms = symptoms,
|
||||||
mood = mood
|
mood = mood
|
||||||
)
|
)
|
||||||
|
cyclePeriodDao.insert(period)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updatePeriod(periodId: Long, endDate: LocalDate?, flow: String, symptoms: List<String>, mood: String) {
|
||||||
|
val periods = cyclePeriodDao.getAll()
|
||||||
|
val existingPeriod = periods.firstOrNull { it.id == periodId }
|
||||||
|
if (existingPeriod != null) {
|
||||||
|
val updatedPeriod = existingPeriod.copy(
|
||||||
|
endDate = endDate,
|
||||||
|
flow = flow,
|
||||||
|
symptoms = symptoms,
|
||||||
|
mood = mood
|
||||||
)
|
)
|
||||||
|
cyclePeriodDao.update(updatedPeriod)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentCyclePeriod(): Flow<CyclePeriodEntity?> {
|
suspend fun getRecentPeriods(): List<CyclePeriodEntity> {
|
||||||
return cyclePeriodDao.getCurrentPeriod()
|
return cyclePeriodDao.getAll().take(6)
|
||||||
}
|
|
||||||
|
|
||||||
fun getRecentPeriods(): Flow<List<CyclePeriodEntity>> {
|
|
||||||
return cyclePeriodDao.getRecentPeriods(6)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
@@ -281,13 +296,34 @@ class WellSheRepository @Inject constructor(
|
|||||||
// ЗДОРОВЬЕ
|
// ЗДОРОВЬЕ
|
||||||
// =================
|
// =================
|
||||||
|
|
||||||
fun getTodayHealthData(): Flow<HealthRecordEntity?> {
|
fun getTodayHealthData(): kotlinx.coroutines.flow.Flow<HealthRecordEntity?> {
|
||||||
// TODO: Реализовать получение данных о здоровье за сегодня
|
val today = LocalDate.now()
|
||||||
return flowOf(null)
|
return healthRecordDao.getByDateFlow(today)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateHealthRecord(record: HealthRecord) {
|
fun getAllHealthRecords(): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>> {
|
||||||
// TODO: Реализовать обновление записи о здоровье
|
return healthRecordDao.getAllFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRecentHealthRecords(limit: Int = 10): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>> {
|
||||||
|
return healthRecordDao.getAllFlow().map { records: List<HealthRecordEntity> ->
|
||||||
|
records.sortedByDescending { r -> r.date }.take(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveHealthRecord(record: HealthRecordEntity) {
|
||||||
|
if (record.id != 0L) {
|
||||||
|
healthRecordDao.update(record)
|
||||||
|
} else {
|
||||||
|
healthRecordDao.insert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteHealthRecord(recordId: Long) {
|
||||||
|
val record = healthRecordDao.getAll().firstOrNull { it.id == recordId }
|
||||||
|
if (record != null) {
|
||||||
|
healthRecordDao.delete(record)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
@@ -322,7 +358,9 @@ class WellSheRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getWaterLogsForDate(date: LocalDate): Flow<List<WaterLogEntity>> {
|
fun getWaterLogsForDate(date: LocalDate): Flow<List<WaterLogEntity>> {
|
||||||
return waterLogDao.getWaterLogsForDate(date)
|
return flow {
|
||||||
|
emit(waterLogDao.getWaterLogsForDate(date))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import dagger.hilt.components.SingletonComponent
|
|||||||
import kr.smartsoltech.wellshe.data.AppDatabase
|
import kr.smartsoltech.wellshe.data.AppDatabase
|
||||||
import kr.smartsoltech.wellshe.data.datastore.DataStoreManager
|
import kr.smartsoltech.wellshe.data.datastore.DataStoreManager
|
||||||
import kr.smartsoltech.wellshe.data.dao.*
|
import kr.smartsoltech.wellshe.data.dao.*
|
||||||
|
import kr.smartsoltech.wellshe.data.repo.DrinkLogger
|
||||||
|
import kr.smartsoltech.wellshe.data.repo.WeightRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.repo.WorkoutService
|
||||||
|
import kr.smartsoltech.wellshe.data.MIGRATION_1_2
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -28,7 +32,10 @@ object AppModule {
|
|||||||
context,
|
context,
|
||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"well_she_db"
|
"well_she_db"
|
||||||
).fallbackToDestructiveMigration().build()
|
)
|
||||||
|
.addMigrations(MIGRATION_1_2)
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
|
||||||
// DAO providers
|
// DAO providers
|
||||||
@Provides
|
@Provides
|
||||||
@@ -55,6 +62,64 @@ object AppModule {
|
|||||||
@Provides
|
@Provides
|
||||||
fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao()
|
fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao()
|
||||||
|
|
||||||
|
// DAO для BodyRepo
|
||||||
|
@Provides
|
||||||
|
fun provideBeverageLogDao(database: AppDatabase): BeverageLogDao = database.beverageLogDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideBeverageLogNutrientDao(database: AppDatabase): BeverageLogNutrientDao = database.beverageLogNutrientDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideBeverageServingNutrientDao(database: AppDatabase): BeverageServingNutrientDao = database.beverageServingNutrientDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideWeightLogDao(database: AppDatabase): WeightLogDao = database.weightLogDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideWorkoutSessionDao(database: AppDatabase): WorkoutSessionDao = database.workoutSessionDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideWorkoutSessionParamDao(database: AppDatabase): WorkoutSessionParamDao = database.workoutSessionParamDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideWorkoutEventDao(database: AppDatabase): WorkoutEventDao = database.workoutEventDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideExerciseDao(database: AppDatabase): ExerciseDao = database.exerciseDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideExerciseFormulaDao(database: AppDatabase): ExerciseFormulaDao = database.exerciseFormulaDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideExerciseFormulaVarDao(database: AppDatabase): ExerciseFormulaVarDao = database.exerciseFormulaVarDao()
|
||||||
|
|
||||||
|
// Repo providers
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDrinkLogger(
|
||||||
|
waterLogDao: WaterLogDao,
|
||||||
|
beverageLogDao: BeverageLogDao,
|
||||||
|
beverageLogNutrientDao: BeverageLogNutrientDao,
|
||||||
|
servingNutrientDao: BeverageServingNutrientDao
|
||||||
|
): DrinkLogger = DrinkLogger(waterLogDao, beverageLogDao, beverageLogNutrientDao, servingNutrientDao)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideWeightRepository(weightLogDao: WeightLogDao): WeightRepository =
|
||||||
|
WeightRepository(weightLogDao)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideWorkoutService(
|
||||||
|
sessionDao: WorkoutSessionDao,
|
||||||
|
paramDao: WorkoutSessionParamDao,
|
||||||
|
eventDao: WorkoutEventDao,
|
||||||
|
weightRepo: WeightRepository,
|
||||||
|
formulaDao: ExerciseFormulaDao,
|
||||||
|
formulaVarDao: ExerciseFormulaVarDao,
|
||||||
|
exerciseDao: ExerciseDao
|
||||||
|
): WorkoutService = WorkoutService(sessionDao, paramDao, eventDao, weightRepo, formulaDao, formulaVarDao, exerciseDao)
|
||||||
|
|
||||||
// Repository
|
// Repository
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
59
app/src/main/java/kr/smartsoltech/wellshe/di/CycleModule.kt
Normal file
59
app/src/main/java/kr/smartsoltech/wellshe/di/CycleModule.kt
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package kr.smartsoltech.wellshe.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.CycleForecastDao
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.CycleHistoryDao
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.CycleSettingsDao
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.CycleRepository
|
||||||
|
import kr.smartsoltech.wellshe.domain.services.CycleSettingsExportService
|
||||||
|
import kr.smartsoltech.wellshe.workers.CycleNotificationManager
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object CycleModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideCycleRepository(
|
||||||
|
settingsDao: CycleSettingsDao,
|
||||||
|
historyDao: CycleHistoryDao,
|
||||||
|
forecastDao: CycleForecastDao
|
||||||
|
): CycleRepository = CycleRepository(settingsDao, historyDao, forecastDao)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideCycleSettingsExportService(): CycleSettingsExportService =
|
||||||
|
CycleSettingsExportService()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideWorkManager(@ApplicationContext context: Context): WorkManager =
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideCycleNotificationManager(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
workManager: WorkManager
|
||||||
|
): CycleNotificationManager = CycleNotificationManager(context, workManager)
|
||||||
|
|
||||||
|
// DAO providers
|
||||||
|
@Provides
|
||||||
|
fun provideCycleSettingsDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleSettingsDao =
|
||||||
|
database.cycleSettingsDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideCycleHistoryDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleHistoryDao =
|
||||||
|
database.cycleHistoryDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideCycleForecastDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleForecastDao =
|
||||||
|
database.cycleForecastDao()
|
||||||
|
}
|
||||||
@@ -1,19 +1,34 @@
|
|||||||
package kr.smartsoltech.wellshe.domain.analytics
|
package kr.smartsoltech.wellshe.domain.analytics
|
||||||
|
|
||||||
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleStatsEntity
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
data class CycleForecast(
|
||||||
|
val nextStart: Long?,
|
||||||
|
val fertileWindow: Pair<Long, Long>?,
|
||||||
|
val confidence: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CycleStats(
|
||||||
|
val avgCycle: Int,
|
||||||
|
val variance: Double,
|
||||||
|
val lutealLen: Int
|
||||||
|
)
|
||||||
|
|
||||||
object CycleAnalytics {
|
object CycleAnalytics {
|
||||||
/**
|
/**
|
||||||
* Прогноз следующей менструации и фертильного окна
|
* Прогноз следующей менструации и фертильного окна
|
||||||
* @param periods список последних периодов
|
* @param periods список последних периодов
|
||||||
* @param stats статистика цикла (вычисляется автоматически)
|
* @param statsEntity статистика цикла из базы (опционально)
|
||||||
* @return прогноз: дата, фертильное окно, доверие
|
* @return прогноз: дата, фертильное окно, доверие
|
||||||
*/
|
*/
|
||||||
fun forecast(periods: List<CyclePeriodEntity>, stats: CycleStats? = null): CycleForecast {
|
fun forecast(periods: List<CyclePeriodEntity>, statsEntity: CycleStatsEntity? = null): CycleForecast {
|
||||||
if (periods.isEmpty()) return CycleForecast(null, null, "низкая")
|
if (periods.isEmpty()) return CycleForecast(null, null, "низкая")
|
||||||
|
|
||||||
val calculatedStats = stats ?: calculateStats(periods)
|
val calculatedStats = calculateStats(periods)
|
||||||
|
|
||||||
val lastPeriod = periods.first()
|
val lastPeriod = periods.first()
|
||||||
val lastStartDate = lastPeriod.startDate
|
val lastStartDate = lastPeriod.startDate
|
||||||
val lastStartTs = lastStartDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000
|
val lastStartTs = lastStartDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000
|
||||||
@@ -49,27 +64,53 @@ object CycleAnalytics {
|
|||||||
val cycleLengths = periods.take(periods.size - 1).mapIndexed { index, period ->
|
val cycleLengths = periods.take(periods.size - 1).mapIndexed { index, period ->
|
||||||
val nextPeriod = periods[index + 1]
|
val nextPeriod = periods[index + 1]
|
||||||
java.time.temporal.ChronoUnit.DAYS.between(nextPeriod.startDate, period.startDate).toInt()
|
java.time.temporal.ChronoUnit.DAYS.between(nextPeriod.startDate, period.startDate).toInt()
|
||||||
|
}.filter { it > 0 }
|
||||||
|
|
||||||
|
if (cycleLengths.isEmpty()) {
|
||||||
|
return CycleStats(avgCycle = 28, variance = 5.0, lutealLen = 14)
|
||||||
}
|
}
|
||||||
|
|
||||||
val avgCycle = cycleLengths.average().toInt()
|
val avgCycle = cycleLengths.average().toInt()
|
||||||
val variance = cycleLengths.map { (it - avgCycle) * (it - avgCycle) }.average()
|
val variance = if (cycleLengths.size > 1) {
|
||||||
|
cycleLengths.map { (it - avgCycle) * (it - avgCycle) }.average()
|
||||||
|
} else {
|
||||||
|
5.0
|
||||||
|
}
|
||||||
|
|
||||||
return CycleStats(
|
// Примерная лютеиновая фаза (обычно 14 дней)
|
||||||
avgCycle = avgCycle,
|
val lutealLen = 14
|
||||||
variance = variance,
|
|
||||||
lutealLen = 14 // стандартная лютеиновая фаза
|
return CycleStats(avgCycle, variance, lutealLen)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Анализ регулярности цикла
|
||||||
|
*/
|
||||||
|
fun analyzeRegularity(periods: List<CyclePeriodEntity>): String {
|
||||||
|
val stats = calculateStats(periods)
|
||||||
|
return when {
|
||||||
|
stats.variance < 2 -> "Очень регулярный"
|
||||||
|
stats.variance < 5 -> "Регулярный"
|
||||||
|
stats.variance < 10 -> "Умеренно регулярный"
|
||||||
|
else -> "Нерегулярный"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class CycleForecast(
|
/**
|
||||||
val nextStart: Long?,
|
* Предсказание следующих дат
|
||||||
val fertileWindow: Pair<Long, Long>?,
|
*/
|
||||||
val confidence: String
|
fun predictNextPeriods(periods: List<CyclePeriodEntity>, count: Int = 3): List<LocalDate> {
|
||||||
)
|
if (periods.isEmpty()) return emptyList()
|
||||||
|
|
||||||
data class CycleStats(
|
val stats = calculateStats(periods)
|
||||||
val avgCycle: Int,
|
val lastPeriod = periods.first()
|
||||||
val variance: Double,
|
val predictions = mutableListOf<LocalDate>()
|
||||||
val lutealLen: Int
|
|
||||||
)
|
for (i in 1..count) {
|
||||||
|
val nextDate = lastPeriod.startDate.plusDays((stats.avgCycle * i).toLong())
|
||||||
|
predictions.add(nextDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return predictions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.models
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доменная модель для истории циклов.
|
||||||
|
*/
|
||||||
|
data class CycleHistory(
|
||||||
|
val id: Long = 0,
|
||||||
|
val periodStart: LocalDate,
|
||||||
|
val periodEnd: LocalDate? = null,
|
||||||
|
val ovulationDate: LocalDate? = null,
|
||||||
|
val notes: String = "",
|
||||||
|
val atypical: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доменная модель для прогнозов цикла.
|
||||||
|
*/
|
||||||
|
data class CycleForecast(
|
||||||
|
val nextPeriodStart: LocalDate? = null,
|
||||||
|
val nextOvulation: LocalDate? = null,
|
||||||
|
val fertileStart: LocalDate? = null,
|
||||||
|
val fertileEnd: LocalDate? = null,
|
||||||
|
val pmsStart: LocalDate? = null,
|
||||||
|
val isReliable: Boolean = true, // Флаг для пониженной точности
|
||||||
|
val currentCyclePhase: CyclePhase = CyclePhase.UNKNOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фаза менструального цикла.
|
||||||
|
*/
|
||||||
|
enum class CyclePhase {
|
||||||
|
MENSTRUATION, // Менструация
|
||||||
|
FOLLICULAR, // Фолликулярная фаза (после менструации до фертильного окна)
|
||||||
|
FERTILE, // Фертильное окно
|
||||||
|
OVULATION, // День овуляции
|
||||||
|
LUTEAL, // Лютеиновая фаза (после овуляции)
|
||||||
|
PMS, // ПМС (последние дни перед менструацией)
|
||||||
|
UNKNOWN; // Неизвестная фаза (например, при недостатке данных)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Определяет текущую фазу цикла на основе прогноза и текущей даты.
|
||||||
|
*/
|
||||||
|
fun determinePhase(
|
||||||
|
today: LocalDate = LocalDate.now(),
|
||||||
|
nextPeriodStart: LocalDate?,
|
||||||
|
lastPeriodStart: LocalDate?,
|
||||||
|
fertileStart: LocalDate?,
|
||||||
|
fertileEnd: LocalDate?,
|
||||||
|
ovulationDate: LocalDate?,
|
||||||
|
pmsStart: LocalDate?,
|
||||||
|
periodLengthDays: Int = 5
|
||||||
|
): CyclePhase {
|
||||||
|
if (lastPeriodStart == null || nextPeriodStart == null) return UNKNOWN
|
||||||
|
|
||||||
|
// Определяем конец последней менструации
|
||||||
|
val lastPeriodEnd = lastPeriodStart.plusDays(periodLengthDays.toLong() - 1)
|
||||||
|
|
||||||
|
return when {
|
||||||
|
// Период менструации
|
||||||
|
(today.isEqual(lastPeriodStart) || today.isAfter(lastPeriodStart)) &&
|
||||||
|
(today.isEqual(lastPeriodEnd) || today.isBefore(lastPeriodEnd)) -> MENSTRUATION
|
||||||
|
|
||||||
|
// День овуляции
|
||||||
|
ovulationDate != null && today.isEqual(ovulationDate) -> OVULATION
|
||||||
|
|
||||||
|
// Фертильное окно
|
||||||
|
fertileStart != null && fertileEnd != null &&
|
||||||
|
(today.isEqual(fertileStart) || today.isAfter(fertileStart)) &&
|
||||||
|
(today.isEqual(fertileEnd) || today.isBefore(fertileEnd)) &&
|
||||||
|
(ovulationDate == null || !today.isEqual(ovulationDate)) -> FERTILE
|
||||||
|
|
||||||
|
// ПМС
|
||||||
|
pmsStart != null &&
|
||||||
|
(today.isEqual(pmsStart) || today.isAfter(pmsStart)) &&
|
||||||
|
today.isBefore(nextPeriodStart) -> PMS
|
||||||
|
|
||||||
|
// Лютеиновая фаза (после овуляции/фертильного окна до ПМС)
|
||||||
|
ovulationDate != null && fertileEnd != null && pmsStart != null &&
|
||||||
|
today.isAfter(fertileEnd) &&
|
||||||
|
today.isBefore(pmsStart) -> LUTEAL
|
||||||
|
|
||||||
|
// Фолликулярная фаза (после менструации до фертильного окна)
|
||||||
|
lastPeriodEnd != null && fertileStart != null &&
|
||||||
|
today.isAfter(lastPeriodEnd) &&
|
||||||
|
today.isBefore(fertileStart) -> FOLLICULAR
|
||||||
|
|
||||||
|
// Если не удалось определить фазу
|
||||||
|
else -> UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.models
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доменная модель для настроек цикла.
|
||||||
|
*/
|
||||||
|
data class CycleSettings(
|
||||||
|
// Основные параметры цикла
|
||||||
|
val baselineCycleLength: Int = 28,
|
||||||
|
val cycleVariabilityDays: Int = 3,
|
||||||
|
val periodLengthDays: Int = 5,
|
||||||
|
val lutealPhaseDays: String = "auto", // "auto" или число (8-17)
|
||||||
|
val lastPeriodStart: LocalDate? = null,
|
||||||
|
|
||||||
|
// Метод определения овуляции
|
||||||
|
val ovulationMethod: OvulationMethod = OvulationMethod.AUTO,
|
||||||
|
val allowManualOvulation: Boolean = false,
|
||||||
|
|
||||||
|
// Статусы влияющие на точность
|
||||||
|
val hormonalContraception: HormonalContraceptionType = HormonalContraceptionType.NONE,
|
||||||
|
val isPregnant: Boolean = false,
|
||||||
|
val isPostpartum: Boolean = false,
|
||||||
|
val isLactating: Boolean = false,
|
||||||
|
val perimenopause: Boolean = false,
|
||||||
|
|
||||||
|
// Настройки истории и исключения выбросов
|
||||||
|
val historyWindowCycles: Int = 6,
|
||||||
|
val excludeOutliers: Boolean = true,
|
||||||
|
|
||||||
|
// Сенсоры и единицы измерения
|
||||||
|
val tempUnit: TemperatureUnit = TemperatureUnit.CELSIUS,
|
||||||
|
val bbtTimeWindow: String = "06:00-10:00",
|
||||||
|
val timezone: String = "Asia/Seoul",
|
||||||
|
|
||||||
|
// Уведомления
|
||||||
|
val periodReminderDaysBefore: Int = 2,
|
||||||
|
val ovulationReminderDaysBefore: Int = 1,
|
||||||
|
val pmsWindowDays: Int = 3,
|
||||||
|
val deviationAlertDays: Int = 5,
|
||||||
|
val fertileWindowMode: FertileWindowMode = FertileWindowMode.BALANCED
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Метод определения овуляции
|
||||||
|
*/
|
||||||
|
enum class OvulationMethod {
|
||||||
|
AUTO, BBT, LH_TEST, CERVICAL_MUCUS, MEDICAL;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): OvulationMethod = when (value.lowercase()) {
|
||||||
|
"bbt" -> BBT
|
||||||
|
"lh_test" -> LH_TEST
|
||||||
|
"cervical_mucus" -> CERVICAL_MUCUS
|
||||||
|
"medical" -> MEDICAL
|
||||||
|
else -> AUTO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toStorageString(): String = this.name.lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип гормональной контрацепции
|
||||||
|
*/
|
||||||
|
enum class HormonalContraceptionType {
|
||||||
|
NONE, COC, IUD, IMPLANT, OTHER;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): HormonalContraceptionType = when (value.lowercase()) {
|
||||||
|
"coc" -> COC
|
||||||
|
"iud" -> IUD
|
||||||
|
"implant" -> IMPLANT
|
||||||
|
"other" -> OTHER
|
||||||
|
else -> NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toStorageString(): String = this.name.lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единицы измерения температуры
|
||||||
|
*/
|
||||||
|
enum class TemperatureUnit {
|
||||||
|
CELSIUS, FAHRENHEIT;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): TemperatureUnit = when (value.uppercase()) {
|
||||||
|
"F" -> FAHRENHEIT
|
||||||
|
else -> CELSIUS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toStorageString(): String = when (this) {
|
||||||
|
CELSIUS -> "C"
|
||||||
|
FAHRENHEIT -> "F"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Режим определения фертильного окна
|
||||||
|
*/
|
||||||
|
enum class FertileWindowMode {
|
||||||
|
CONSERVATIVE, BALANCED, BROAD;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): FertileWindowMode = when (value.lowercase()) {
|
||||||
|
"conservative" -> CONSERVATIVE
|
||||||
|
"broad" -> BROAD
|
||||||
|
else -> BALANCED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toStorageString(): String = this.name.lowercase()
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.services
|
||||||
|
|
||||||
|
import com.squareup.moshi.JsonAdapter
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
|
||||||
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.*
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для импорта и экспорта настроек цикла в JSON формате
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class CycleSettingsExportService @Inject constructor() {
|
||||||
|
|
||||||
|
// Создаем адаптер для LocalDate вне класса LocalDateAdapter
|
||||||
|
private val localDateAdapter = object : JsonAdapter<LocalDate>() {
|
||||||
|
override fun fromJson(reader: com.squareup.moshi.JsonReader): LocalDate? {
|
||||||
|
return try {
|
||||||
|
val dateString = reader.nextString()
|
||||||
|
LocalDate.parse(dateString)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toJson(writer: com.squareup.moshi.JsonWriter, value: LocalDate?) {
|
||||||
|
if (value == null) {
|
||||||
|
writer.nullValue()
|
||||||
|
} else {
|
||||||
|
writer.value(value.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настройка Moshi для сериализации/десериализации
|
||||||
|
private val moshi: Moshi = Moshi.Builder()
|
||||||
|
.add(KotlinJsonAdapterFactory())
|
||||||
|
.add(Date::class.java, Rfc3339DateJsonAdapter())
|
||||||
|
.add(LocalDate::class.java, localDateAdapter)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Адаптер для сериализации/десериализации настроек цикла
|
||||||
|
private val settingsAdapter: JsonAdapter<CycleSettingsJsonDto> = moshi.adapter(CycleSettingsJsonDto::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экспортирует настройки в формат JSON
|
||||||
|
*/
|
||||||
|
fun exportSettingsToJson(settings: CycleSettingsEntity): String {
|
||||||
|
val jsonDto = convertToJsonDto(settings)
|
||||||
|
return settingsAdapter.toJson(jsonDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Импортирует настройки из JSON
|
||||||
|
* @return Импортированные настройки или null в случае ошибки
|
||||||
|
*/
|
||||||
|
fun importSettingsFromJson(json: String): CycleSettingsEntity? {
|
||||||
|
return try {
|
||||||
|
val jsonDto = settingsAdapter.fromJson(json)
|
||||||
|
jsonDto?.let { convertToEntity(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует Entity в DTO для экспорта в JSON
|
||||||
|
*/
|
||||||
|
private fun convertToJsonDto(entity: CycleSettingsEntity): CycleSettingsJsonDto {
|
||||||
|
return CycleSettingsJsonDto(
|
||||||
|
baselineCycleLength = entity.baselineCycleLength,
|
||||||
|
cycleVariabilityDays = entity.cycleVariabilityDays,
|
||||||
|
periodLengthDays = entity.periodLengthDays,
|
||||||
|
lutealPhaseDays = entity.lutealPhaseDays,
|
||||||
|
lastPeriodStart = entity.lastPeriodStart,
|
||||||
|
ovulationMethod = entity.ovulationMethod,
|
||||||
|
allowManualOvulation = entity.allowManualOvulation,
|
||||||
|
hormonalContraception = entity.hormonalContraception,
|
||||||
|
isPregnant = entity.isPregnant,
|
||||||
|
isPostpartum = entity.isPostpartum,
|
||||||
|
isLactating = entity.isLactating,
|
||||||
|
perimenopause = entity.perimenopause,
|
||||||
|
historyWindowCycles = entity.historyWindowCycles,
|
||||||
|
excludeOutliers = entity.excludeOutliers,
|
||||||
|
tempUnit = entity.tempUnit,
|
||||||
|
bbtTimeWindow = entity.bbtTimeWindow,
|
||||||
|
timezone = entity.timezone,
|
||||||
|
periodReminderDaysBefore = entity.periodReminderDaysBefore,
|
||||||
|
ovulationReminderDaysBefore = entity.ovulationReminderDaysBefore,
|
||||||
|
pmsWindowDays = entity.pmsWindowDays,
|
||||||
|
deviationAlertDays = entity.deviationAlertDays,
|
||||||
|
fertileWindowMode = entity.fertileWindowMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует DTO в Entity
|
||||||
|
*/
|
||||||
|
private fun convertToEntity(dto: CycleSettingsJsonDto): CycleSettingsEntity {
|
||||||
|
return CycleSettingsEntity(
|
||||||
|
id = 1, // Singleton ID
|
||||||
|
baselineCycleLength = dto.baselineCycleLength.coerceIn(18, 60),
|
||||||
|
cycleVariabilityDays = dto.cycleVariabilityDays.coerceIn(0, 10),
|
||||||
|
periodLengthDays = dto.periodLengthDays.coerceIn(1, 10),
|
||||||
|
lutealPhaseDays = dto.lutealPhaseDays, // Валидация будет в ViewModel
|
||||||
|
lastPeriodStart = dto.lastPeriodStart,
|
||||||
|
ovulationMethod = dto.ovulationMethod,
|
||||||
|
allowManualOvulation = dto.allowManualOvulation,
|
||||||
|
hormonalContraception = dto.hormonalContraception,
|
||||||
|
isPregnant = dto.isPregnant,
|
||||||
|
isPostpartum = dto.isPostpartum,
|
||||||
|
isLactating = dto.isLactating,
|
||||||
|
perimenopause = dto.perimenopause,
|
||||||
|
historyWindowCycles = dto.historyWindowCycles,
|
||||||
|
excludeOutliers = dto.excludeOutliers,
|
||||||
|
tempUnit = dto.tempUnit,
|
||||||
|
bbtTimeWindow = dto.bbtTimeWindow,
|
||||||
|
timezone = dto.timezone,
|
||||||
|
periodReminderDaysBefore = dto.periodReminderDaysBefore.coerceIn(0, 7),
|
||||||
|
ovulationReminderDaysBefore = dto.ovulationReminderDaysBefore.coerceIn(0, 7),
|
||||||
|
pmsWindowDays = dto.pmsWindowDays.coerceIn(1, 7),
|
||||||
|
deviationAlertDays = dto.deviationAlertDays.coerceIn(1, 14),
|
||||||
|
fertileWindowMode = dto.fertileWindowMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO для сериализации/десериализации настроек цикла в JSON
|
||||||
|
*/
|
||||||
|
data class CycleSettingsJsonDto(
|
||||||
|
// Основные параметры цикла
|
||||||
|
val baselineCycleLength: Int = 28,
|
||||||
|
val cycleVariabilityDays: Int = 3,
|
||||||
|
val periodLengthDays: Int = 5,
|
||||||
|
val lutealPhaseDays: String = "auto",
|
||||||
|
val lastPeriodStart: LocalDate? = null,
|
||||||
|
|
||||||
|
// Метод определения овуляции
|
||||||
|
val ovulationMethod: String = "auto",
|
||||||
|
val allowManualOvulation: Boolean = false,
|
||||||
|
|
||||||
|
// Статусы влияющие на точность
|
||||||
|
val hormonalContraception: String = "none",
|
||||||
|
val isPregnant: Boolean = false,
|
||||||
|
val isPostpartum: Boolean = false,
|
||||||
|
val isLactating: Boolean = false,
|
||||||
|
val perimenopause: Boolean = false,
|
||||||
|
|
||||||
|
// Настройки истории и исключения выбросов
|
||||||
|
val historyWindowCycles: Int = 6,
|
||||||
|
val excludeOutliers: Boolean = true,
|
||||||
|
|
||||||
|
// Сенсоры и единицы измерения
|
||||||
|
val tempUnit: String = "C",
|
||||||
|
val bbtTimeWindow: String = "06:00-10:00",
|
||||||
|
val timezone: String = "Asia/Seoul",
|
||||||
|
|
||||||
|
// Уведомления
|
||||||
|
val periodReminderDaysBefore: Int = 2,
|
||||||
|
val ovulationReminderDaysBefore: Int = 1,
|
||||||
|
val pmsWindowDays: Int = 3,
|
||||||
|
val deviationAlertDays: Int = 5,
|
||||||
|
val fertileWindowMode: String = "balanced"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель данных для прогноза менструального цикла
|
||||||
|
*/
|
||||||
|
data class CycleForecast(
|
||||||
|
val nextPeriodStart: LocalDate,
|
||||||
|
val nextOvulation: LocalDate,
|
||||||
|
val fertileStart: LocalDate,
|
||||||
|
val fertileEnd: LocalDate,
|
||||||
|
val pmsStart: LocalDate,
|
||||||
|
val periodLengthDays: Int
|
||||||
|
) {
|
||||||
|
val periodEnd: LocalDate get() = nextPeriodStart.plusDays(periodLengthDays.toLong() - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расчет прогноза цикла на основе настроек
|
||||||
|
*/
|
||||||
|
fun computeForecast(settings: CycleSettings): CycleForecast {
|
||||||
|
val nextPeriod = settings.lastPeriodStart.plusDays(settings.baselineLength.toLong())
|
||||||
|
val ovulation = nextPeriod.minusDays(settings.lutealDays.toLong())
|
||||||
|
val fertileStart = ovulation.minusDays(5)
|
||||||
|
val fertileEnd = ovulation
|
||||||
|
val pmsStart = nextPeriod.minusDays(3)
|
||||||
|
|
||||||
|
return CycleForecast(
|
||||||
|
nextPeriodStart = nextPeriod,
|
||||||
|
nextOvulation = ovulation,
|
||||||
|
fertileStart = fertileStart,
|
||||||
|
fertileEnd = fertileEnd,
|
||||||
|
pmsStart = pmsStart,
|
||||||
|
periodLengthDays = settings.periodLength
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует дату в формате "DD MMM"
|
||||||
|
*/
|
||||||
|
fun fmt(date: LocalDate): String {
|
||||||
|
return date.format(DateTimeFormatter.ofPattern("dd MMM"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
data class CycleSettings(
|
||||||
|
val baselineLength: Int = 28,
|
||||||
|
val periodLength: Int = 5,
|
||||||
|
val lutealDays: Int = 14,
|
||||||
|
val lastPeriodStart: LocalDate
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
class JournalConverters {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromLocalDate(date: LocalDate): String = date.toString()
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toLocalDate(dateString: String): LocalDate = LocalDate.parse(dateString)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromMediaList(media: List<JournalMedia>): String = Gson().toJson(media)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toMediaList(mediaString: String): List<JournalMedia> {
|
||||||
|
val type = object : TypeToken<List<JournalMedia>>() {}.type
|
||||||
|
return Gson().fromJson(mediaString, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
|
||||||
|
@Database(entities = [JournalEntryEntity::class], version = 1, exportSchema = false)
|
||||||
|
@TypeConverters(JournalConverters::class)
|
||||||
|
abstract class JournalDatabase : RoomDatabase() {
|
||||||
|
abstract fun journalEntryDao(): JournalEntryDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: JournalDatabase? = null
|
||||||
|
|
||||||
|
fun getDatabase(context: Context): JournalDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
JournalDatabase::class.java,
|
||||||
|
"journal_database"
|
||||||
|
).build()
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
// Модель записи дневника
|
||||||
|
data class JournalEntry(
|
||||||
|
val id: Long = 0L, // уникальный идентификатор
|
||||||
|
val date: LocalDate,
|
||||||
|
val text: String,
|
||||||
|
val media: List<JournalMedia> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Модель медиафайла (картинка, видео, музыка)
|
||||||
|
data class JournalMedia(
|
||||||
|
val uri: String,
|
||||||
|
val type: MediaType
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class MediaType {
|
||||||
|
IMAGE, VIDEO, AUDIO
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface JournalEntryDao {
|
||||||
|
@Query("SELECT * FROM journal_entries WHERE date = :date")
|
||||||
|
suspend fun getEntriesByDate(date: LocalDate): List<JournalEntryEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertEntry(entry: JournalEntryEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateEntry(entry: JournalEntryEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteEntry(entry: JournalEntryEntity)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Entity(tableName = "journal_entries")
|
||||||
|
data class JournalEntryEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0L,
|
||||||
|
val date: LocalDate,
|
||||||
|
val text: String,
|
||||||
|
val media: String // сериализованный список медиа (например, JSON)
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
class JournalRepository(private val dao: JournalEntryDao) {
|
||||||
|
suspend fun getEntriesByDate(date: LocalDate): List<JournalEntry> = withContext(Dispatchers.IO) {
|
||||||
|
dao.getEntriesByDate(date).map { entity ->
|
||||||
|
JournalEntry(
|
||||||
|
id = entity.id,
|
||||||
|
date = entity.date,
|
||||||
|
text = entity.text,
|
||||||
|
media = JournalConverters().toMediaList(entity.media)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addOrUpdateEntry(entry: JournalEntry) = withContext(Dispatchers.IO) {
|
||||||
|
val entity = JournalEntryEntity(
|
||||||
|
id = entry.id,
|
||||||
|
date = entry.date,
|
||||||
|
text = entry.text,
|
||||||
|
media = JournalConverters().fromMediaList(entry.media)
|
||||||
|
)
|
||||||
|
dao.insertEntry(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteEntry(entry: JournalEntry) = withContext(Dispatchers.IO) {
|
||||||
|
val entity = JournalEntryEntity(
|
||||||
|
id = entry.id,
|
||||||
|
date = entry.date,
|
||||||
|
text = entry.text,
|
||||||
|
media = JournalConverters().fromMediaList(entry.media)
|
||||||
|
)
|
||||||
|
dao.deleteEntry(entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.analytics
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.InfoCard
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.KPI
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран "Аналитика" с данными и графиками
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AnalyticsScreen(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
// Состояния для выбранного временного диапазона
|
||||||
|
var selectedRange by remember { mutableStateOf("7d") }
|
||||||
|
val rangeLabels = mapOf(
|
||||||
|
"7d" to "7 дней",
|
||||||
|
"30d" to "30 дней",
|
||||||
|
"90d" to "90 дней"
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок и селектор периода
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Аналитика",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Выпадающее меню для выбора периода
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Период:",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { expanded = true },
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(rangeLabels[selectedRange] ?: "7 дней")
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = "Выбрать период"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
rangeLabels.forEach { (key, value) ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(value) },
|
||||||
|
onClick = {
|
||||||
|
selectedRange = key
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KPI блоки
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
KPI(
|
||||||
|
title = "Стабильность цикла",
|
||||||
|
value = "Высокая",
|
||||||
|
tone = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
KPI(
|
||||||
|
title = "Гидратация",
|
||||||
|
value = "~85% нормы",
|
||||||
|
tone = Color(0xFF2196F3),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
KPI(
|
||||||
|
title = "Вес (${rangeLabels[selectedRange]})",
|
||||||
|
value = "−0.6 кг",
|
||||||
|
tone = Color(0xFFE91E63),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
KPI(
|
||||||
|
title = "Калории (спорт)",
|
||||||
|
value = "+2.9k",
|
||||||
|
tone = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Графики
|
||||||
|
AnalyticsChart(
|
||||||
|
title = "Динамика веса (${rangeLabels[selectedRange]})",
|
||||||
|
subtitle = "Тренд: медленное снижение веса, без резких скачков."
|
||||||
|
)
|
||||||
|
|
||||||
|
AnalyticsChart(
|
||||||
|
title = "Гидратация (${rangeLabels[selectedRange]})",
|
||||||
|
subtitle = "Среднее за период ~1950 мл/день."
|
||||||
|
)
|
||||||
|
|
||||||
|
AnalyticsChart(
|
||||||
|
title = "Сожжённые калории (${rangeLabels[selectedRange]})",
|
||||||
|
subtitle = "Пики в выходные — неплохая стратегия, добавь лёгкую сессию в среду."
|
||||||
|
)
|
||||||
|
|
||||||
|
// Корреляции
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = AnalyticsTabColor.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Корреляции с фазами",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Корреляционные линии
|
||||||
|
CorrelationItem("Гидратация ↔ энергия", 0.42f)
|
||||||
|
CorrelationItem("Сон ↔ настроение", 0.58f)
|
||||||
|
CorrelationItem("ПМС ↔ боль", 0.66f)
|
||||||
|
CorrelationItem("Вес ↔ вода", -0.18f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инсайт недели
|
||||||
|
InfoCard(
|
||||||
|
title = "Инсайт недели",
|
||||||
|
content = "Лучшая тренировка — суббота (600 ккал). Пиковая продуктивность совпадает с фолликулярной фазой. Вес ↓ на 0.6 кг — отличный тренд."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка с графиком для аналитики
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AnalyticsChart(
|
||||||
|
title: String,
|
||||||
|
subtitle: String
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Заглушка для графика (в реальном приложении использовать библиотеку графиков)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(160.dp)
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
// В реальном приложении здесь будет график
|
||||||
|
Text(
|
||||||
|
text = "Графическое представление данных",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Элемент для отображения корреляции с визуализацией
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CorrelationItem(
|
||||||
|
title: String,
|
||||||
|
value: Float
|
||||||
|
) {
|
||||||
|
val color = when {
|
||||||
|
value > 0.3f -> Color(0xFF4CAF50) // Зеленый для положительных
|
||||||
|
value < -0.3f -> Color(0xFFE91E63) // Красный для отрицательных
|
||||||
|
else -> Color(0xFF9E9E9E) // Серый для нейтральных
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Полоса корреляции
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(80.dp)
|
||||||
|
.height(8.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width((80 * kotlin.math.abs(value)).dp)
|
||||||
|
.height(8.dp)
|
||||||
|
.background(color)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Числовое значение
|
||||||
|
Text(
|
||||||
|
text = (if (value > 0) "+" else "") + String.format("%.2f", value),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun AnalyticsScreenPreview() {
|
||||||
|
WellSheTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
AnalyticsScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.analytics
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AnalyticsViewModel @Inject constructor() : ViewModel() {
|
||||||
|
// Данные для экрана аналитики
|
||||||
|
private val _cycleStability = MutableStateFlow("Высокая")
|
||||||
|
val cycleStability: StateFlow<String> = _cycleStability.asStateFlow()
|
||||||
|
|
||||||
|
private val _hydration = MutableStateFlow(85)
|
||||||
|
val hydration: StateFlow<Int> = _hydration.asStateFlow()
|
||||||
|
|
||||||
|
private val _weightChange = MutableStateFlow(-0.6f)
|
||||||
|
val weightChange: StateFlow<Float> = _weightChange.asStateFlow()
|
||||||
|
|
||||||
|
private val _caloriesBurned = MutableStateFlow(2900)
|
||||||
|
val caloriesBurned: StateFlow<Int> = _caloriesBurned.asStateFlow()
|
||||||
|
|
||||||
|
private val _weeklyInsight = MutableStateFlow("Лучшие тренировки приходятся на выходные; добавь лёгкое кардио в среду.")
|
||||||
|
val weeklyInsight: StateFlow<String> = _weeklyInsight.asStateFlow()
|
||||||
|
}
|
||||||
110
app/src/main/java/kr/smartsoltech/wellshe/ui/body/BodyScreen.kt
Normal file
110
app/src/main/java/kr/smartsoltech/wellshe/ui/body/BodyScreen.kt
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.body
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kr.smartsoltech.wellshe.ui.body.tabs.ActivityTab
|
||||||
|
import kr.smartsoltech.wellshe.ui.body.tabs.WaterTab
|
||||||
|
import kr.smartsoltech.wellshe.ui.body.tabs.WeightTab
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основной экран "Тело" с вкладками для воды, веса и активности
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BodyScreen(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var selectedTabIndex by remember { mutableStateOf(0) }
|
||||||
|
val tabs = listOf("Вода", "Вес", "Активность")
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Вкладки
|
||||||
|
TabRow(
|
||||||
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(MaterialTheme.shapes.large),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
divider = {},
|
||||||
|
indicator = {}
|
||||||
|
) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
val selected = selectedTabIndex == index
|
||||||
|
val tabColor = when(index) {
|
||||||
|
0 -> if (selected) BodyTabColor else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
1 -> if (selected) Color(0xFFFCE4EC) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
2 -> if (selected) Color(0xFFE0F2F1) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
}
|
||||||
|
|
||||||
|
Tab(
|
||||||
|
selected = selected,
|
||||||
|
onClick = { selectedTabIndex = index },
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.clip(MaterialTheme.shapes.large)
|
||||||
|
.background(tabColor)
|
||||||
|
) {
|
||||||
|
val emoji = when(index) {
|
||||||
|
0 -> "💧"
|
||||||
|
1 -> "⚖️"
|
||||||
|
2 -> "🏃♀️"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "$emoji $title",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Содержимое вкладки
|
||||||
|
when (selectedTabIndex) {
|
||||||
|
0 -> WaterTab()
|
||||||
|
1 -> WeightTab()
|
||||||
|
2 -> ActivityTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun BodyScreenPreview() {
|
||||||
|
WellSheTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
BodyScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
fun BodyScreenDarkPreview() {
|
||||||
|
WellSheTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
BodyScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.body
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class BodyViewModel @Inject constructor(
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// Данные для вкладки "Вода"
|
||||||
|
private val _waterData = MutableStateFlow<List<Int>>(listOf(1800, 1950, 2050, 1900, 2100, 2300, 2000))
|
||||||
|
val waterData: StateFlow<List<Int>> = _waterData.asStateFlow()
|
||||||
|
|
||||||
|
private val _todayWaterAmount = MutableStateFlow(1800)
|
||||||
|
val todayWaterAmount: StateFlow<Int> = _todayWaterAmount.asStateFlow()
|
||||||
|
|
||||||
|
private val _waterGoal = MutableStateFlow(2000) // 2л по умолчанию в мл
|
||||||
|
val waterGoal: StateFlow<Int> = _waterGoal.asStateFlow()
|
||||||
|
|
||||||
|
// Данные для вкладки "Вес"
|
||||||
|
private val _weightHistory = MutableStateFlow<List<Pair<String, Float>>>(
|
||||||
|
listOf(
|
||||||
|
"Пн" to 58.9f,
|
||||||
|
"Вт" to 58.7f,
|
||||||
|
"Ср" to 58.6f,
|
||||||
|
"Чт" to 58.5f,
|
||||||
|
"Пт" to 58.4f,
|
||||||
|
"Сб" to 58.4f,
|
||||||
|
"Вс" to 58.3f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val weightHistory: StateFlow<List<Pair<String, Float>>> = _weightHistory.asStateFlow()
|
||||||
|
|
||||||
|
private val _currentWeight = MutableStateFlow<Float?>(58.4f)
|
||||||
|
val currentWeight: StateFlow<Float?> = _currentWeight.asStateFlow()
|
||||||
|
|
||||||
|
private val _weeklyWeightDifference = MutableStateFlow(-0.6f)
|
||||||
|
val weeklyWeightDifference: StateFlow<Float> = _weeklyWeightDifference.asStateFlow()
|
||||||
|
|
||||||
|
// Данные для вкладки "Активность"
|
||||||
|
private val _activityData = MutableStateFlow<List<WorkoutData>>(
|
||||||
|
listOf(
|
||||||
|
WorkoutData("Пн", 250),
|
||||||
|
WorkoutData("Вт", 350),
|
||||||
|
WorkoutData("Ср", 410),
|
||||||
|
WorkoutData("Чт", 280),
|
||||||
|
WorkoutData("Пт", 500),
|
||||||
|
WorkoutData("Сб", 600),
|
||||||
|
WorkoutData("Вс", 420)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val activityData: StateFlow<List<WorkoutData>> = _activityData.asStateFlow()
|
||||||
|
|
||||||
|
private val _todayCaloriesBurned = MutableStateFlow(312)
|
||||||
|
val todayCaloriesBurned: StateFlow<Int> = _todayCaloriesBurned.asStateFlow()
|
||||||
|
|
||||||
|
private val _bestWorkoutDay = MutableStateFlow<Pair<String, Int>?>(Pair("Сб", 600))
|
||||||
|
val bestWorkoutDay: StateFlow<Pair<String, Int>?> = _bestWorkoutDay.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadWaterData()
|
||||||
|
loadWeightData()
|
||||||
|
loadActivityData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadWaterData() {
|
||||||
|
// Здесь будет логика загрузки данных о воде
|
||||||
|
// Сейчас используем тестовые данные
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadWeightData() {
|
||||||
|
// Здесь будет логика загрузки данных о весе
|
||||||
|
// Сейчас используем тестовые данные
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadActivityData() {
|
||||||
|
// Здесь будет логика загрузки данных об активности
|
||||||
|
// Сейчас используем тестовые данные
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addWater(amount: Int) {
|
||||||
|
val currentAmount = _todayWaterAmount.value
|
||||||
|
_todayWaterAmount.value = currentAmount + amount
|
||||||
|
|
||||||
|
// Обновляем данные в истории
|
||||||
|
val updatedWaterData = _waterData.value.toMutableList()
|
||||||
|
if (updatedWaterData.isNotEmpty()) {
|
||||||
|
updatedWaterData[updatedWaterData.size - 1] = _todayWaterAmount.value
|
||||||
|
_waterData.value = updatedWaterData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logWeight(weightKg: Float) {
|
||||||
|
_currentWeight.value = weightKg
|
||||||
|
|
||||||
|
// Обновляем историю веса
|
||||||
|
val updatedWeightHistory = _weightHistory.value.toMutableList()
|
||||||
|
if (updatedWeightHistory.isNotEmpty()) {
|
||||||
|
val lastDay = updatedWeightHistory.last().first
|
||||||
|
updatedWeightHistory[updatedWeightHistory.size - 1] = lastDay to weightKg
|
||||||
|
_weightHistory.value = updatedWeightHistory
|
||||||
|
|
||||||
|
// Пересчитываем разницу за неделю
|
||||||
|
val firstWeight = updatedWeightHistory.first().second
|
||||||
|
_weeklyWeightDifference.value = weightKg - firstWeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startWorkout(exerciseId: Long) {
|
||||||
|
// Здесь будет логика начала тренировки
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WorkoutData(
|
||||||
|
val date: String,
|
||||||
|
val caloriesBurned: Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.body.tabs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.DirectionsRun
|
||||||
|
import androidx.compose.material.icons.filled.LocalFireDepartment
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.InfoCard
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Содержимое вкладки "Активность" экрана "Тело"
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ActivityTab(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
// Данные для отображения (в реальном приложении должны поступать из ViewModel)
|
||||||
|
val caloriesBurned = "+312 ккал"
|
||||||
|
val activityDescription = "Бег 8 км/ч · 35 мин"
|
||||||
|
val activityColor = Color(0xFF4CAF50) // Зеленый для активности
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Основная карточка с активностью
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color(0xFFE0F2F1).copy(alpha = 0.3f) // Светло-зеленый фон
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.DirectionsRun,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = activityColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Активность",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = activityColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { /*TODO*/ },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = activityColor
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Начать активность")
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Начать")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение калорий
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = caloriesBurned,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = activityDescription,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// График сожженных калорий
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.LocalFireDepartment,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFFFF9800)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Сожжённые калории (7 дней)",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// График (заглушка, в реальном приложении использовать библиотеку графиков)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp)
|
||||||
|
.padding(vertical = 16.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"График сожженных калорий",
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Аналитика
|
||||||
|
Text(
|
||||||
|
text = "Средний расход 416 ккал/день, лучший день — 600 ккал (сб)",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// История активностей
|
||||||
|
InfoCard(
|
||||||
|
title = "Недавние активности",
|
||||||
|
content = "Бег 35 мин, Йога 40 мин, Плавание 30 мин, Велосипед 45 мин, Ходьба 60 мин"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Рекомендации
|
||||||
|
InfoCard(
|
||||||
|
title = "Рекомендация",
|
||||||
|
content = "Добавь 1 лёгкую кардио-сессию в среду для более равномерного распределения нагрузки в течение недели."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun ActivityTabPreview() {
|
||||||
|
WellSheTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
ActivityTab(Modifier.padding(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.body.tabs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.TrendingUp
|
||||||
|
import androidx.compose.material.icons.filled.WaterDrop
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.InfoCard
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.ProgressWithLabel
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.WaterColor
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Содержимое вкладки "Вода" экрана "Тело"
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WaterTab(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
// Данные для отображения (в реальном приложении должны поступать из ViewModel)
|
||||||
|
val currentWater = 1800
|
||||||
|
val targetWater = 2000
|
||||||
|
val progress = currentWater.toFloat() / targetWater
|
||||||
|
val percentageText = "${(progress * 100).toInt()}% дневной нормы"
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Основная карточка с водой
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.WaterDrop,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = WaterColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Вода",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = WaterColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = { /*TODO*/ }) {
|
||||||
|
Icon(Icons.Default.Search, contentDescription = "Поиск напитка")
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Поиск")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(onClick = { /*TODO*/ }) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Добавить воду")
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Выпито")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прогресс воды
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$currentWater / $targetWater мл",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
ProgressWithLabel(
|
||||||
|
progress = progress,
|
||||||
|
label = percentageText,
|
||||||
|
color = WaterColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тренд
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.TrendingUp,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = WaterColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Тренд за неделю",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// График (заглушка, в реальном приложении использовать библиотеку графиков)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp)
|
||||||
|
.padding(vertical = 16.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"График пот<D0BE><D182>ебления воды",
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Статистика
|
||||||
|
Text(
|
||||||
|
text = "Энергия из напитков: 139 ккал • Сахара: 35 г • Вода: ~2.0 л",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Недавно добавленные напитки
|
||||||
|
InfoCard(
|
||||||
|
title = "Недавно добавленные напитки",
|
||||||
|
content = "Вода 250 мл, Чай зеленый 200 мл, Кофе 150 мл, Сок яблочный 200 мл"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Рекомендации
|
||||||
|
InfoCard(
|
||||||
|
title = "Рекомендация",
|
||||||
|
content = "Старайтесь пить больше воды утром. Это поможет ускорить метаболизм и увеличить энергию на весь день."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun WaterTabPreview() {
|
||||||
|
WellSheTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
WaterTab(Modifier.padding(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.body.tabs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Scale
|
||||||
|
import androidx.compose.material.icons.filled.Spa
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.InfoCard
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Содержимое вкладки "Вес" экрана "Тело"
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WeightTab(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
// Данные для отображения (в реальном приложении должны поступать из ViewModel)
|
||||||
|
val currentWeight = "58.4 кг"
|
||||||
|
val weightChange = "−0.6 кг за неделю"
|
||||||
|
val weightColor = Color(0xFFEC407A) // Розовый для веса
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Основная карточка с весом
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color(0xFFFCE4EC).copy(alpha = 0.3f) // Светло-розовый фон
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Scale,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = weightColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Вес",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = weightColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { /*TODO*/ },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = weightColor
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Добавить запись веса")
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Добавить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение веса
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = currentWeight,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = weightChange,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// График динамики веса (заглушка)
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Spa,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = weightColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Динамика веса",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// График (заглушка, в реальном приложении использовать библиотеку графиков)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp)
|
||||||
|
.padding(vertical = 16.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"График изменения веса",
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Прогноз
|
||||||
|
Text(
|
||||||
|
text = "Прогноз: при сохранении режима целевой вес 57.5 кг через 3 недели",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// История записей
|
||||||
|
InfoCard(
|
||||||
|
title = "История записей",
|
||||||
|
content = "Пн: 58.9 кг, Вт: 58.7 кг, Ср: 58.6 кг, Чт: 58.5 кг, Пт: 58.4 кг, Сб: 58.4 кг, Вс: 58.3 кг"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Рекомендации
|
||||||
|
InfoCard(
|
||||||
|
title = "Анализ",
|
||||||
|
content = "Стабильное снижение веса показывает хороший прогресс. Для оптимального результата рекомендуется пить больше воды и добавить умеренные кардио-нагрузки."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun WeightTabPreview() {
|
||||||
|
WellSheTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
WeightTab(Modifier.padding(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка с информацией
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun StatCard(
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
tone: Color? = null,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val backgroundColor = tone?.copy(alpha = 0.15f) ?: MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
color = backgroundColor
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.Start
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Информационная карточка
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InfoCard(
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f))
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пилюля для отображения фазы цикла
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PhasePill(
|
||||||
|
label: String,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = color.copy(alpha = 0.2f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = color.copy(alpha = 0.8f),
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индикатор прогресса с процентами
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ProgressWithLabel(
|
||||||
|
progress: Float,
|
||||||
|
label: String,
|
||||||
|
color: Color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = progress,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(8.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
|
color = color,
|
||||||
|
trackColor = color.copy(alpha = 0.2f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ключевой показатель эффективности (KPI)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun KPI(
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
tone: Color? = null,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val backgroundColor = tone?.copy(alpha = 0.15f) ?: MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
color = backgroundColor
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключатель с подписью
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ToggleRow(
|
||||||
|
label: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||||
|
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
uncheckedThumbColor = MaterialTheme.colorScheme.outline,
|
||||||
|
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.LocalDrink
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WaterIntakeDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
var waterAmount by remember { mutableStateOf("200") }
|
||||||
|
var isError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.LocalDrink,
|
||||||
|
contentDescription = "Добавить воду",
|
||||||
|
tint = Color(0xFF3B82F6),
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Сколько воды вы выпили?",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = waterAmount,
|
||||||
|
onValueChange = { value ->
|
||||||
|
waterAmount = value.filter { it.isDigit() }
|
||||||
|
isError = waterAmount.isEmpty() || waterAmount.toIntOrNull() == null
|
||||||
|
},
|
||||||
|
label = { Text("Количество (мл)") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
isError = isError,
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
Text(
|
||||||
|
text = "Введите корректное количество",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val amount = waterAmount.toIntOrNull() ?: return@Button
|
||||||
|
onConfirm(amount)
|
||||||
|
},
|
||||||
|
enabled = !isError && waterAmount.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Text("Добавить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Scale
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WeightInputDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (Float) -> Unit,
|
||||||
|
initialWeight: Float = 60f
|
||||||
|
) {
|
||||||
|
var weightInput by remember { mutableStateOf(initialWeight.toString()) }
|
||||||
|
var isError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Scale,
|
||||||
|
contentDescription = "Добавить вес",
|
||||||
|
tint = Color(0xFFEC4899),
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Запись веса",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = weightInput,
|
||||||
|
onValueChange = { value ->
|
||||||
|
// Разрешаем только цифры и одну точку
|
||||||
|
val filtered = value.filter { it.isDigit() || it == '.' }
|
||||||
|
// Проверяем, что точка только одна
|
||||||
|
val dotCount = filtered.count { it == '.' }
|
||||||
|
weightInput = if (dotCount > 1) {
|
||||||
|
filtered.substring(0, filtered.lastIndexOf('.'))
|
||||||
|
} else {
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка валидности
|
||||||
|
isError = try {
|
||||||
|
val weight = weightInput.toFloat()
|
||||||
|
weight <= 0f || weight > 300f // Разумные ограничения на вес
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Вес (кг)") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
isError = isError,
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
Text(
|
||||||
|
text = "Введите корректное значение веса (1-300 кг)",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
try {
|
||||||
|
val weight = weightInput.toFloat()
|
||||||
|
if (weight > 0f && weight <= 300f) {
|
||||||
|
onConfirm(weight)
|
||||||
|
}
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
// Ошибка уже отображается через isError
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !isError && weightInput.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Text("Сохранить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.FitnessCenter
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WorkoutSelectionDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (Long) -> Unit
|
||||||
|
) {
|
||||||
|
// Тестовый список тренировок
|
||||||
|
val workouts = listOf(
|
||||||
|
Workout(1L, "Бег", "Кардио", 8.5),
|
||||||
|
Workout(2L, "Ходьба", "Кардио", 4.2),
|
||||||
|
Workout(3L, "Йога", "Растяжка", 3.0),
|
||||||
|
Workout(4L, "Силовая тренировка", "Силовая", 6.0),
|
||||||
|
Workout(5L, "Плавание", "Кардио", 7.0),
|
||||||
|
Workout(6L, "Велосипед", "Кардио", 7.5)
|
||||||
|
)
|
||||||
|
|
||||||
|
var selectedWorkout by remember { mutableStateOf<Workout?>(null) }
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FitnessCenter,
|
||||||
|
contentDescription = "Выбор тренировки",
|
||||||
|
tint = Color(0xFF10B981), // Зеленый цвет
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Выберите тренировку",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(300.dp)
|
||||||
|
) {
|
||||||
|
items(workouts) { workout ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = workout == selectedWorkout,
|
||||||
|
onClick = { selectedWorkout = workout }
|
||||||
|
)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = workout == selectedWorkout,
|
||||||
|
onClick = { selectedWorkout = workout }
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = workout.name,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${workout.type} • ~${workout.caloriesPerMinute} ккал/мин",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
selectedWorkout?.let { onConfirm(it.id) }
|
||||||
|
},
|
||||||
|
enabled = selectedWorkout != null
|
||||||
|
) {
|
||||||
|
Text("Начать")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Workout(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val type: String,
|
||||||
|
val caloriesPerMinute: Double
|
||||||
|
)
|
||||||
@@ -1,824 +1,192 @@
|
|||||||
package kr.smartsoltech.wellshe.ui.cycle
|
package kr.smartsoltech.wellshe.ui.cycle
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
import kr.smartsoltech.wellshe.model.CycleForecast
|
||||||
import kr.smartsoltech.wellshe.ui.theme.*
|
import kr.smartsoltech.wellshe.model.CycleSettings
|
||||||
|
import kr.smartsoltech.wellshe.model.computeForecast
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.InfoCard
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.StatCard
|
||||||
|
import kr.smartsoltech.wellshe.ui.cycle.components.CycleCalendarCard
|
||||||
|
import kr.smartsoltech.wellshe.ui.cycle.components.QuickActionsCard
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.WaterColor
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
import java.time.YearMonth
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.temporal.ChronoUnit
|
import java.util.Locale
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sin
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CycleScreen(
|
fun CycleScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: CycleViewModel = hiltViewModel(),
|
viewModel: CycleViewModel = hiltViewModel()
|
||||||
onNavigateBack: () -> Boolean
|
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
// Загружаем данные при первом запуске
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadCycleData()
|
viewModel.loadCycleData()
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
Scaffold { paddingValues ->
|
||||||
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(
|
.padding(paddingValues)
|
||||||
Brush.verticalGradient(
|
.padding(16.dp)
|
||||||
colors = listOf(
|
.verticalScroll(scrollState),
|
||||||
PrimaryPinkLight.copy(alpha = 0.3f),
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
NeutralWhite
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
) {
|
||||||
item {
|
// Календарь цикла
|
||||||
CycleOverviewCard(
|
CycleCalendarCard(
|
||||||
currentPhase = uiState.currentPhase,
|
month = uiState.month,
|
||||||
daysUntilNext = uiState.daysUntilNextPeriod,
|
onPrev = { viewModel.prevMonth() },
|
||||||
cycleDay = uiState.currentCycleDay,
|
onNext = { viewModel.nextMonth() },
|
||||||
cycleLength = uiState.cycleLength
|
forecast = uiState.forecast
|
||||||
|
)
|
||||||
|
|
||||||
|
// Карточки с прогнозом
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
StatCard(
|
||||||
|
title = "След. менструация",
|
||||||
|
value = uiState.forecast?.nextPeriodStart?.format(
|
||||||
|
DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
|
||||||
|
) ?: "—",
|
||||||
|
tone = Color(0xFF2196F3),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
StatCard(
|
||||||
|
title = "Овуляция",
|
||||||
|
value = uiState.forecast?.nextOvulation?.format(
|
||||||
|
DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
|
||||||
|
) ?: "—",
|
||||||
|
tone = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
// Быстрые действия
|
||||||
CycleTrackerCard(
|
QuickActionsCard(
|
||||||
isPeriodActive = uiState.isPeriodActive,
|
onMarkStart = { viewModel.markPeriodStart() },
|
||||||
onStartPeriod = viewModel::startPeriod,
|
onMarkEnd = { viewModel.markPeriodEnd() },
|
||||||
onEndPeriod = viewModel::endPeriod,
|
onAddSymptom = { viewModel.addSymptom() },
|
||||||
onLogSymptoms = { viewModel.toggleSymptomsEdit() }
|
onAddNote = { viewModel.addNote() }
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
// Информационные карточки
|
||||||
if (uiState.showSymptomsEdit) {
|
InfoCard(
|
||||||
SymptomsTrackingCard(
|
title = "Симптомы сегодня",
|
||||||
selectedSymptoms = uiState.todaySymptoms,
|
content = uiState.todaySymptoms
|
||||||
selectedMood = uiState.todayMood,
|
)
|
||||||
onSymptomsUpdate = viewModel::updateSymptoms,
|
|
||||||
onMoodUpdate = viewModel::updateMood,
|
InfoCard(
|
||||||
onSave = viewModel::saveTodayData
|
title = "Прогноз недели",
|
||||||
|
content = uiState.weekInsight
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
|
||||||
CyclePredictionCard(
|
|
||||||
nextPeriodDate = uiState.nextPeriodDate,
|
|
||||||
ovulationDate = uiState.ovulationDate,
|
|
||||||
fertilityWindow = uiState.fertilityWindow
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
CycleInsightsCard(
|
|
||||||
insights = uiState.insights,
|
|
||||||
averageCycleLength = uiState.averageCycleLength
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
PeriodHistoryCard(
|
|
||||||
recentPeriods = uiState.recentPeriods,
|
|
||||||
onPeriodClick = { /* TODO: Navigate to period details */ }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
Spacer(modifier = Modifier.height(80.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.error != null) {
|
|
||||||
LaunchedEffect(uiState.error) {
|
|
||||||
viewModel.clearError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun CycleOverviewCard(
|
fun CycleScreenPreview() {
|
||||||
currentPhase: String,
|
val previewSettings = CycleSettings(
|
||||||
daysUntilNext: Int,
|
baselineLength = 28,
|
||||||
cycleDay: Int,
|
periodLength = 5,
|
||||||
cycleLength: Int,
|
lutealDays = 14,
|
||||||
modifier: Modifier = Modifier
|
lastPeriodStart = LocalDate.of(2025, 10, 1)
|
||||||
) {
|
)
|
||||||
val progress by animateFloatAsState(
|
val previewForecast = computeForecast(previewSettings)
|
||||||
targetValue = if (cycleLength > 0) (cycleDay.toFloat() / cycleLength).coerceIn(0f, 1f) else 0f,
|
|
||||||
animationSpec = tween(durationMillis = 1000)
|
val previewState = CycleViewModel.UiState(
|
||||||
|
month = YearMonth.now(),
|
||||||
|
forecast = previewForecast,
|
||||||
|
todaySymptoms = "Лёгкая усталость, аппетит выше обычного",
|
||||||
|
weekInsight = "ПМС с 13 окт; окно фертильности: 29 сен–04 окт",
|
||||||
|
cycleSettings = previewSettings
|
||||||
)
|
)
|
||||||
|
|
||||||
Card(
|
WellSheTheme {
|
||||||
modifier = modifier.fillMaxWidth(),
|
Surface {
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
|
||||||
shape = RoundedCornerShape(20.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Текущий цикл",
|
|
||||||
style = MaterialTheme.typography.headlineSmall.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = TextPrimary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.size(200.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CycleProgressIndicator(
|
|
||||||
progress = progress,
|
|
||||||
currentPhase = currentPhase,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "День $cycleDay",
|
|
||||||
style = MaterialTheme.typography.headlineLarge.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = PrimaryPink
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "из $cycleLength дней",
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
color = TextSecondary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = currentPhase,
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = PrimaryPink
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = PrimaryPinkLight.copy(alpha = 0.1f)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
CycleCalendarCard(
|
||||||
text = if (daysUntilNext > 0) {
|
month = previewState.month,
|
||||||
"До следующих месячных"
|
onPrev = { },
|
||||||
} else {
|
onNext = { },
|
||||||
"Месячные уже начались"
|
forecast = previewState.forecast
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
color = TextSecondary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = if (daysUntilNext > 0) {
|
|
||||||
"$daysUntilNext дней"
|
|
||||||
} else {
|
|
||||||
"Отметьте начало"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.headlineSmall.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = PrimaryPink
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CycleProgressIndicator(
|
|
||||||
progress: Float,
|
|
||||||
currentPhase: String,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Canvas(modifier = modifier) {
|
|
||||||
val center = this.center
|
|
||||||
val radius = size.minDimension / 2 - 20.dp.toPx()
|
|
||||||
val strokeWidth = 12.dp.toPx()
|
|
||||||
|
|
||||||
// Фон круга
|
|
||||||
drawCircle(
|
|
||||||
color = PrimaryPinkLight.copy(alpha = 0.3f),
|
|
||||||
radius = radius,
|
|
||||||
center = center,
|
|
||||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = strokeWidth)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Прогресс-дуга
|
|
||||||
val sweepAngle = 360f * progress
|
|
||||||
drawArc(
|
|
||||||
brush = Brush.sweepGradient(
|
|
||||||
colors = listOf(
|
|
||||||
PrimaryPink,
|
|
||||||
PrimaryPinkDark
|
|
||||||
)
|
|
||||||
),
|
|
||||||
startAngle = -90f,
|
|
||||||
sweepAngle = sweepAngle,
|
|
||||||
useCenter = false,
|
|
||||||
style = androidx.compose.ui.graphics.drawscope.Stroke(
|
|
||||||
width = strokeWidth,
|
|
||||||
cap = androidx.compose.ui.graphics.StrokeCap.Round
|
|
||||||
),
|
|
||||||
topLeft = Offset(center.x - radius, center.y - radius),
|
|
||||||
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Индикаторы фаз цикла
|
|
||||||
drawPhaseIndicators(center, radius, strokeWidth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DrawScope.drawPhaseIndicators(center: Offset, radius: Float, strokeWidth: Float) {
|
|
||||||
val phases = listOf(
|
|
||||||
Triple(0f, "М", Color(0xFFE91E63)), // Менструация
|
|
||||||
Triple(90f, "Ф", Color(0xFF9C27B0)), // Фолликулярная
|
|
||||||
Triple(180f, "О", Color(0xFF673AB7)), // Овуляция
|
|
||||||
Triple(270f, "Л", Color(0xFF3F51B5)) // Лютеиновая
|
|
||||||
)
|
|
||||||
|
|
||||||
phases.forEach { (angle, label, color) ->
|
|
||||||
val angleRad = Math.toRadians(angle.toDouble())
|
|
||||||
val x = center.x + (radius + strokeWidth / 2) * cos(angleRad).toFloat()
|
|
||||||
val y = center.y + (radius + strokeWidth / 2) * sin(angleRad).toFloat()
|
|
||||||
|
|
||||||
drawCircle(
|
|
||||||
color = color,
|
|
||||||
radius = 8.dp.toPx(),
|
|
||||||
center = Offset(x, y)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CycleTrackerCard(
|
|
||||||
isPeriodActive: Boolean,
|
|
||||||
onStartPeriod: () -> Unit,
|
|
||||||
onEndPeriod: () -> Unit,
|
|
||||||
onLogSymptoms: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(20.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Отслеживание",
|
|
||||||
style = MaterialTheme.typography.titleLarge.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = TextPrimary
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
if (isPeriodActive) {
|
StatCard(
|
||||||
Button(
|
title = "След. менструация",
|
||||||
onClick = onEndPeriod,
|
value = previewState.forecast?.nextPeriodStart?.format(
|
||||||
modifier = Modifier.weight(1f),
|
DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
|
||||||
colors = ButtonDefaults.buttonColors(
|
) ?: "—",
|
||||||
containerColor = Color(0xFFFF5722)
|
tone = Color(0xFF2196F3),
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Stop,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text("Завершить")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button(
|
|
||||||
onClick = onStartPeriod,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = PrimaryPink
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.PlayArrow,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text("Начать месячные")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onLogSymptoms,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Assignment,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text("Симптомы")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SymptomsTrackingCard(
|
|
||||||
selectedSymptoms: List<String>,
|
|
||||||
selectedMood: String,
|
|
||||||
onSymptomsUpdate: (List<String>) -> Unit,
|
|
||||||
onMoodUpdate: (String) -> Unit,
|
|
||||||
onSave: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(20.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Симптомы и настроение",
|
|
||||||
style = MaterialTheme.typography.titleLarge.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = TextPrimary
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Симптомы
|
|
||||||
Text(
|
|
||||||
text = "Симптомы",
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = TextPrimary
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
val symptoms = listOf(
|
|
||||||
"Боли в животе", "Головная боль", "Тошнота", "Вздутие",
|
|
||||||
"Усталость", "Раздражительность", "Боли в спине", "Акне"
|
|
||||||
)
|
|
||||||
|
|
||||||
LazyRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
) {
|
|
||||||
items(symptoms) { symptom ->
|
|
||||||
FilterChip(
|
|
||||||
onClick = {
|
|
||||||
val newSymptoms = if (selectedSymptoms.contains(symptom)) {
|
|
||||||
selectedSymptoms - symptom
|
|
||||||
} else {
|
|
||||||
selectedSymptoms + symptom
|
|
||||||
}
|
|
||||||
onSymptomsUpdate(newSymptoms)
|
|
||||||
},
|
|
||||||
label = { Text(symptom, style = MaterialTheme.typography.bodySmall) },
|
|
||||||
selected = selectedSymptoms.contains(symptom),
|
|
||||||
colors = FilterChipDefaults.filterChipColors(
|
|
||||||
selectedContainerColor = PrimaryPink,
|
|
||||||
selectedLabelColor = NeutralWhite
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Настроение
|
|
||||||
Text(
|
|
||||||
text = "Настроение",
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = TextPrimary
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
val moods = listOf("Отличное", "Хорошее", "Нейтральное", "Плохое", "Ужасное")
|
|
||||||
|
|
||||||
LazyRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
) {
|
|
||||||
items(moods) { mood ->
|
|
||||||
FilterChip(
|
|
||||||
onClick = { onMoodUpdate(mood) },
|
|
||||||
label = { Text(mood) },
|
|
||||||
selected = selectedMood == mood,
|
|
||||||
colors = FilterChipDefaults.filterChipColors(
|
|
||||||
selectedContainerColor = PrimaryPink,
|
|
||||||
selectedLabelColor = NeutralWhite
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = onSave,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = PrimaryPink
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
|
||||||
Text("Сохранить")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CyclePredictionCard(
|
|
||||||
nextPeriodDate: LocalDate?,
|
|
||||||
ovulationDate: LocalDate?,
|
|
||||||
fertilityWindow: Pair<LocalDate, LocalDate>?,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(20.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Прогнозы",
|
|
||||||
style = MaterialTheme.typography.titleLarge.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = TextPrimary
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
PredictionItem(
|
|
||||||
icon = Icons.Default.CalendarMonth,
|
|
||||||
title = "Следующие месячные",
|
|
||||||
date = nextPeriodDate,
|
|
||||||
color = PrimaryPink
|
|
||||||
)
|
|
||||||
|
|
||||||
PredictionItem(
|
|
||||||
icon = Icons.Default.Favorite,
|
|
||||||
title = "Овуляция",
|
|
||||||
date = ovulationDate,
|
|
||||||
color = Color(0xFF9C27B0)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (fertilityWindow != null) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Spa,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color(0xFF4CAF50),
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = "Период фертильности",
|
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = TextPrimary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${fertilityWindow.first.format(DateTimeFormatter.ofPattern("dd.MM"))} - ${fertilityWindow.second.format(DateTimeFormatter.ofPattern("dd.MM"))}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
color = Color(0xFF4CAF50),
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PredictionItem(
|
|
||||||
icon: ImageVector,
|
|
||||||
title: String,
|
|
||||||
date: LocalDate?,
|
|
||||||
color: Color,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = color,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = TextPrimary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = date?.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) ?: "Недостаточно данных",
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
color = if (date != null) color else TextSecondary,
|
|
||||||
fontWeight = if (date != null) FontWeight.Bold else FontWeight.Normal
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CycleInsightsCard(
|
|
||||||
insights: List<String>,
|
|
||||||
averageCycleLength: Float,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = PrimaryPinkLight.copy(alpha = 0.1f)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(20.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Analytics,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = PrimaryPink,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Анализ цикла",
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = TextPrimary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (averageCycleLength > 0) {
|
|
||||||
Text(
|
|
||||||
text = "Средняя длина цикла: %.1f дней".format(averageCycleLength),
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
color = TextPrimary,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(bottom = 12.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (insights.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "Отслеживайте цикл несколько месяцев для получения персональных рекомендаций.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
color = TextPrimary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
insights.forEach { insight ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Circle,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = PrimaryPink,
|
|
||||||
modifier = Modifier.size(8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = insight,
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
color = TextPrimary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PeriodHistoryCard(
|
|
||||||
recentPeriods: List<CyclePeriodEntity>,
|
|
||||||
onPeriodClick: (CyclePeriodEntity) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(20.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "История циклов",
|
|
||||||
style = MaterialTheme.typography.titleLarge.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = TextPrimary
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (recentPeriods.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "Пока нет записей о циклах",
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
color = TextSecondary
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
recentPeriods.take(3).forEach { period ->
|
|
||||||
PeriodHistoryItem(
|
|
||||||
period = period,
|
|
||||||
onClick = { onPeriodClick(period) }
|
|
||||||
)
|
|
||||||
if (period != recentPeriods.last()) {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PeriodHistoryItem(
|
|
||||||
period: CyclePeriodEntity,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onClick() }
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.CalendarMonth,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = PrimaryPink,
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = period.startDate.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")),
|
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = TextPrimary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val duration = if (period.endDate != null) {
|
|
||||||
ChronoUnit.DAYS.between(period.startDate, period.endDate) + 1
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = if (duration != null) {
|
|
||||||
"Продолжительность: $duration дней"
|
|
||||||
} else {
|
|
||||||
"В процессе"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
|
||||||
color = TextSecondary
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
StatCard(
|
||||||
|
title = "Овуляция",
|
||||||
|
value = previewState.forecast?.nextOvulation?.format(
|
||||||
|
DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
|
||||||
|
) ?: "—",
|
||||||
|
tone = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
QuickActionsCard(
|
||||||
text = period.flow,
|
onMarkStart = { },
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
onMarkEnd = { },
|
||||||
color = TextSecondary
|
onAddSymptom = { },
|
||||||
)
|
onAddNote = { }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
InfoCard(
|
||||||
|
title = "Симптомы сегодня",
|
||||||
|
content = previewState.todaySymptoms
|
||||||
|
)
|
||||||
|
|
||||||
Icon(
|
InfoCard(
|
||||||
imageVector = Icons.Default.ChevronRight,
|
title = "Прогноз недели",
|
||||||
contentDescription = "Просмотреть",
|
content = previewState.weekInsight
|
||||||
tint = TextSecondary,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, locale = "ru")
|
||||||
|
@Composable
|
||||||
|
fun CycleScreenPreviewRussian() {
|
||||||
|
CycleScreenPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
fun CycleScreenPreviewDark() {
|
||||||
|
CycleScreenPreview()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kr.smartsoltech.wellshe.model.CycleSettings
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CycleSettingsDialog(
|
||||||
|
initialSettings: CycleSettings,
|
||||||
|
onSave: (CycleSettings) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
var baselineLength by remember { mutableStateOf(initialSettings.baselineLength.toString()) }
|
||||||
|
var periodLength by remember { mutableStateOf(initialSettings.periodLength.toString()) }
|
||||||
|
var lutealDays by remember { mutableStateOf(initialSettings.lutealDays.toString()) }
|
||||||
|
|
||||||
|
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
|
var lastPeriodStartStr by remember {
|
||||||
|
mutableStateOf(initialSettings.lastPeriodStart.format(formatter))
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = {
|
||||||
|
try {
|
||||||
|
val newSettings = CycleSettings(
|
||||||
|
baselineLength = baselineLength.toIntOrNull() ?: initialSettings.baselineLength,
|
||||||
|
periodLength = periodLength.toIntOrNull() ?: initialSettings.periodLength,
|
||||||
|
lutealDays = lutealDays.toIntOrNull() ?: initialSettings.lutealDays,
|
||||||
|
lastPeriodStart = try {
|
||||||
|
LocalDate.parse(lastPeriodStartStr, formatter)
|
||||||
|
} catch (e: DateTimeParseException) {
|
||||||
|
initialSettings.lastPeriodStart
|
||||||
|
}
|
||||||
|
)
|
||||||
|
onSave(newSettings)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// В случае ошибки сохраняем исходные настройки
|
||||||
|
onSave(initialSettings)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("Сохранить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Button(onClick = onDismiss) { Text("Отмена") }
|
||||||
|
},
|
||||||
|
title = { Text("Настройки цикла") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = baselineLength,
|
||||||
|
onValueChange = { baselineLength = it },
|
||||||
|
label = { Text("Длина цикла (дней)") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = periodLength,
|
||||||
|
onValueChange = { periodLength = it },
|
||||||
|
label = { Text("Длина месячных (дней)") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lutealDays,
|
||||||
|
onValueChange = { lutealDays = it },
|
||||||
|
label = { Text("Лютеиновая фаза (дней)") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lastPeriodStartStr,
|
||||||
|
onValueChange = { lastPeriodStartStr = it },
|
||||||
|
label = { Text("Первый день последних месячных (гггг-мм-дд)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
|
import kr.smartsoltech.wellshe.model.CycleSettings
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
data class CycleUiState(
|
||||||
|
// Основные параметры цикла
|
||||||
|
val currentPhase: String = "Фолликулярная",
|
||||||
|
val currentCycleDay: Int = 1,
|
||||||
|
val cycleLength: Int = 28,
|
||||||
|
val daysUntilNextPeriod: Int = 0,
|
||||||
|
val isPeriodActive: Boolean = false,
|
||||||
|
|
||||||
|
// Прогнозы
|
||||||
|
val nextPeriodDate: LocalDate? = null,
|
||||||
|
val ovulationDate: LocalDate? = null,
|
||||||
|
val fertilityWindow: Pair<LocalDate, LocalDate>? = null,
|
||||||
|
|
||||||
|
// Симптомы и настроение сегодня
|
||||||
|
val todaySymptoms: List<String> = emptyList(),
|
||||||
|
val todayMood: String = "",
|
||||||
|
val showSymptomsEdit: Boolean = false,
|
||||||
|
|
||||||
|
// Аналитика
|
||||||
|
val averageCycleLength: Float = 0f,
|
||||||
|
val insights: List<String> = emptyList(),
|
||||||
|
val recentPeriods: List<CyclePeriodEntity> = emptyList(),
|
||||||
|
|
||||||
|
// Параметры цикла пользователя
|
||||||
|
val cycleSettings: CycleSettings = CycleSettings(
|
||||||
|
baselineLength = 28,
|
||||||
|
periodLength = 5,
|
||||||
|
lutealDays = 14,
|
||||||
|
lastPeriodStart = LocalDate.now().minusDays(14)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Состояние загрузки и ошибки
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
@@ -3,301 +3,222 @@ package kr.smartsoltech.wellshe.ui.cycle
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
import kr.smartsoltech.wellshe.data.repository.WellSheRepository
|
import kr.smartsoltech.wellshe.data.repository.CycleRepository
|
||||||
|
import kr.smartsoltech.wellshe.model.CycleSettings
|
||||||
|
import kr.smartsoltech.wellshe.model.CycleForecast
|
||||||
|
import kr.smartsoltech.wellshe.model.computeForecast
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.YearMonth
|
||||||
import javax.inject.Inject
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
data class CycleUiState(
|
@HiltViewModel
|
||||||
val currentPhase: String = "Фолликулярная",
|
class CycleViewModel @Inject constructor(
|
||||||
val currentCycleDay: Int = 1,
|
private val cycleRepository: CycleRepository
|
||||||
val cycleLength: Int = 28,
|
) : ViewModel() {
|
||||||
val daysUntilNextPeriod: Int = 0,
|
// События навигации
|
||||||
val isPeriodActive: Boolean = false,
|
sealed class NavigationEvent {
|
||||||
val nextPeriodDate: LocalDate? = null,
|
object NavigateToCycleSettings : NavigationEvent()
|
||||||
val ovulationDate: LocalDate? = null,
|
}
|
||||||
val fertilityWindow: Pair<LocalDate, LocalDate>? = null,
|
|
||||||
|
data class UiState(
|
||||||
|
val month: YearMonth = YearMonth.now(),
|
||||||
|
val forecast: CycleForecast? = null,
|
||||||
|
val todaySymptoms: String = "Лёгкая усталость, аппетит выше обычного",
|
||||||
|
val weekInsight: String = "",
|
||||||
|
val cycleSettings: CycleSettings = CycleSettings(
|
||||||
|
baselineLength = 28,
|
||||||
|
periodLength = 5,
|
||||||
|
lutealDays = 14,
|
||||||
|
lastPeriodStart = LocalDate.of(2025, 9, 18)
|
||||||
|
),
|
||||||
val recentPeriods: List<CyclePeriodEntity> = emptyList(),
|
val recentPeriods: List<CyclePeriodEntity> = emptyList(),
|
||||||
val averageCycleLength: Float = 0f,
|
|
||||||
val insights: List<String> = emptyList(),
|
|
||||||
val showSymptomsEdit: Boolean = false,
|
|
||||||
val todaySymptoms: List<String> = emptyList(),
|
|
||||||
val todayMood: String = "",
|
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
private val _uiState = MutableStateFlow(UiState())
|
||||||
class CycleViewModel @Inject constructor(
|
val uiState: StateFlow<UiState> = _uiState
|
||||||
private val repository: WellSheRepository
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(CycleUiState())
|
// Состояние для отображения диалога настроек
|
||||||
val uiState: StateFlow<CycleUiState> = _uiState.asStateFlow()
|
private val _showSettingsDialog = MutableStateFlow(false)
|
||||||
|
val showSettingsDialog: StateFlow<Boolean> = _showSettingsDialog
|
||||||
|
|
||||||
|
// События навигации
|
||||||
|
private val _navigationEvents = MutableStateFlow<NavigationEvent?>(null)
|
||||||
|
val navigationEvents: StateFlow<NavigationEvent?> = _navigationEvents
|
||||||
|
|
||||||
|
// История веса
|
||||||
|
private val _weightHistory = MutableStateFlow<Map<LocalDate, List<Float>>>(emptyMap())
|
||||||
|
val weightHistory: StateFlow<Map<LocalDate, List<Float>>> = _weightHistory
|
||||||
|
|
||||||
|
// История спорта
|
||||||
|
private val _sportHistory = MutableStateFlow<Map<LocalDate, List<String>>>(emptyMap())
|
||||||
|
val sportHistory: StateFlow<Map<LocalDate, List<String>>> = _sportHistory
|
||||||
|
|
||||||
|
// История воды
|
||||||
|
private val _waterHistory = MutableStateFlow<Map<LocalDate, List<Int>>>(emptyMap())
|
||||||
|
val waterHistory: StateFlow<Map<LocalDate, List<Int>>> = _waterHistory
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadCycleData()
|
||||||
|
updateForecast()
|
||||||
|
updateWeekInsight()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSettingsDialog() {
|
||||||
|
_showSettingsDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeSettingsDialog() {
|
||||||
|
_showSettingsDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearNavigationEvent() {
|
||||||
|
_navigationEvents.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCycleSettings(newSettings: CycleSettings) {
|
||||||
|
_uiState.update { it.copy(cycleSettings = newSettings) }
|
||||||
|
closeSettingsDialog()
|
||||||
|
updateForecast()
|
||||||
|
updateWeekInsight()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCycleSettings(newSettings: CycleSettings) {
|
||||||
|
_uiState.update { it.copy(cycleSettings = newSettings) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateForecast() {
|
||||||
|
val forecast = computeForecast(_uiState.value.cycleSettings)
|
||||||
|
_uiState.update { it.copy(forecast = forecast) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateWeekInsight() {
|
||||||
|
val forecast = _uiState.value.forecast ?: return
|
||||||
|
val now = LocalDate.now()
|
||||||
|
val insight = "ПМС с ${fmt(forecast.pmsStart)}; окно фертильности: ${fmt(forecast.fertileStart)}–${fmt(forecast.fertileEnd)}"
|
||||||
|
_uiState.update { it.copy(weekInsight = insight) }
|
||||||
|
}
|
||||||
|
|
||||||
fun loadCycleData() {
|
fun loadCycleData() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Загружаем текущий период
|
val periods = cycleRepository.getAllPeriods()
|
||||||
repository.getCurrentCyclePeriod().collect { currentPeriod ->
|
_uiState.update { it.copy(recentPeriods = periods, isLoading = false) }
|
||||||
val isPeriodActive = currentPeriod != null && currentPeriod.endDate == null
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isPeriodActive = isPeriodActive,
|
|
||||||
isLoading = false
|
|
||||||
)
|
|
||||||
|
|
||||||
// Вычисляем текущий день цикла и фазу
|
|
||||||
calculateCycleInfo(currentPeriod)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем историю периодов
|
|
||||||
repository.getRecentPeriods().collect { periods ->
|
|
||||||
val averageLength = calculateAverageCycleLength(periods)
|
|
||||||
val insights = generateCycleInsights(periods)
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
recentPeriods = periods,
|
|
||||||
averageCycleLength = averageLength,
|
|
||||||
insights = insights
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем настройки цикла пользователя
|
|
||||||
repository.getUserProfile().collect { user ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
cycleLength = user.cycleLength
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||||
isLoading = false,
|
|
||||||
error = e.message
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateCycleInfo(currentPeriod: CyclePeriodEntity?) {
|
fun prevMonth() {
|
||||||
val today = LocalDate.now()
|
_uiState.update { it.copy(month = it.month.minusMonths(1)) }
|
||||||
val cycleLength = _uiState.value.cycleLength
|
|
||||||
|
|
||||||
if (currentPeriod != null) {
|
|
||||||
val daysSinceStart = ChronoUnit.DAYS.between(currentPeriod.startDate, today).toInt() + 1
|
|
||||||
val currentCycleDay = if (daysSinceStart > cycleLength) {
|
|
||||||
// Если прошло больше дней чем длина цикла, начинаем новый цикл
|
|
||||||
(daysSinceStart - 1) % cycleLength + 1
|
|
||||||
} else {
|
|
||||||
daysSinceStart
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val phase = calculatePhase(currentCycleDay, cycleLength)
|
fun nextMonth() {
|
||||||
val daysUntilNext = cycleLength - currentCycleDay
|
_uiState.update { it.copy(month = it.month.plusMonths(1)) }
|
||||||
|
|
||||||
// Прогнозы
|
|
||||||
val nextPeriodDate = currentPeriod.startDate.plusDays(cycleLength.toLong())
|
|
||||||
val ovulationDay = cycleLength / 2 // Примерно в середине цикла
|
|
||||||
val ovulationDate = currentPeriod.startDate.plusDays(ovulationDay.toLong())
|
|
||||||
val fertilityStart = ovulationDate.minusDays(5)
|
|
||||||
val fertilityEnd = ovulationDate.plusDays(1)
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
currentCycleDay = currentCycleDay,
|
|
||||||
currentPhase = phase,
|
|
||||||
daysUntilNextPeriod = daysUntilNext.coerceAtLeast(0),
|
|
||||||
nextPeriodDate = nextPeriodDate,
|
|
||||||
ovulationDate = ovulationDate,
|
|
||||||
fertilityWindow = Pair(fertilityStart, fertilityEnd)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Нет данных о текущем цикле
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
currentCycleDay = 1,
|
|
||||||
currentPhase = "Нет данных",
|
|
||||||
daysUntilNextPeriod = 0,
|
|
||||||
nextPeriodDate = null,
|
|
||||||
ovulationDate = null,
|
|
||||||
fertilityWindow = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculatePhase(cycleDay: Int, cycleLength: Int): String {
|
fun markPeriodStart() {
|
||||||
return when {
|
|
||||||
cycleDay <= 5 -> "Менструация"
|
|
||||||
cycleDay <= cycleLength / 2 - 2 -> "Фолликулярная"
|
|
||||||
cycleDay <= cycleLength / 2 + 2 -> "Овуляция"
|
|
||||||
else -> "Лютеиновая"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateAverageCycleLength(periods: List<CyclePeriodEntity>): Float {
|
|
||||||
if (periods.size < 2) return 0f
|
|
||||||
|
|
||||||
val cycleLengths = mutableListOf<Int>()
|
|
||||||
for (i in 0 until periods.size - 1) {
|
|
||||||
val currentPeriod = periods[i]
|
|
||||||
val nextPeriod = periods[i + 1]
|
|
||||||
val length = ChronoUnit.DAYS.between(nextPeriod.startDate, currentPeriod.startDate).toInt()
|
|
||||||
if (length > 0) {
|
|
||||||
cycleLengths.add(length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return if (cycleLengths.isNotEmpty()) {
|
|
||||||
cycleLengths.average().toFloat()
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateCycleInsights(periods: List<CyclePeriodEntity>): List<String> {
|
|
||||||
val insights = mutableListOf<String>()
|
|
||||||
|
|
||||||
if (periods.size >= 3) {
|
|
||||||
val averageLength = calculateAverageCycleLength(periods)
|
|
||||||
|
|
||||||
when {
|
|
||||||
averageLength < 21 -> {
|
|
||||||
insights.add("Ваши циклы короче обычного. Рекомендуем консультацию с врачом.")
|
|
||||||
}
|
|
||||||
averageLength > 35 -> {
|
|
||||||
insights.add("Ваши циклы длиннее обычного. Стоит обратиться к специалисту.")
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
insights.add("Длина ваших циклов в пределах нормы.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Анализ регулярности
|
|
||||||
val cycleLengths = mutableListOf<Int>()
|
|
||||||
for (i in 0 until periods.size - 1) {
|
|
||||||
val length = ChronoUnit.DAYS.between(periods[i + 1].startDate, periods[i].startDate).toInt()
|
|
||||||
if (length > 0) cycleLengths.add(length)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cycleLengths.size >= 2) {
|
|
||||||
val deviation = cycleLengths.map { kotlin.math.abs(it - averageLength) }.average()
|
|
||||||
|
|
||||||
if (deviation <= 3) {
|
|
||||||
insights.add("У вас очень регулярный цикл.")
|
|
||||||
} else if (deviation <= 7) {
|
|
||||||
insights.add("Ваш цикл достаточно регулярный.")
|
|
||||||
} else {
|
|
||||||
insights.add("Циклы нерегулярные. Рекомендуем отслеживать факторы, влияющие на цикл.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Анализ симптомов
|
|
||||||
val symptomsData = periods.mapNotNull { period ->
|
|
||||||
period.symptoms.split(",").filter { it.isNotBlank() }
|
|
||||||
}.flatten()
|
|
||||||
|
|
||||||
if (symptomsData.isNotEmpty()) {
|
|
||||||
val commonSymptoms = symptomsData.groupBy { it }.maxByOrNull { it.value.size }?.key
|
|
||||||
if (commonSymptoms != null) {
|
|
||||||
insights.add("Наиболее частый симптом: $commonSymptoms")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return insights
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startPeriod() {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
|
||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
|
val newPeriod = CyclePeriodEntity(startDate = today, endDate = null)
|
||||||
|
cycleRepository.insertPeriod(newPeriod)
|
||||||
|
loadCycleData()
|
||||||
|
|
||||||
repository.addPeriod(
|
// Обновляем настройки цикла с новой датой начала
|
||||||
startDate = today,
|
val newSettings = _uiState.value.cycleSettings.copy(lastPeriodStart = today)
|
||||||
endDate = null,
|
_uiState.update { it.copy(cycleSettings = newSettings) }
|
||||||
flow = "Средний",
|
updateForecast()
|
||||||
symptoms = emptyList(),
|
updateWeekInsight()
|
||||||
mood = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(isPeriodActive = true)
|
|
||||||
loadCycleData() // Перезагружаем данные
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun endPeriod() {
|
fun markPeriodEnd() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
|
||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
val currentPeriod = _uiState.value.recentPeriods.firstOrNull { it.endDate == null }
|
val latestPeriod = _uiState.value.recentPeriods.firstOrNull { it.endDate == null }
|
||||||
|
latestPeriod?.let {
|
||||||
if (currentPeriod != null) {
|
val updatedPeriod = it.copy(endDate = today)
|
||||||
repository.addPeriod(
|
cycleRepository.updatePeriod(updatedPeriod)
|
||||||
startDate = currentPeriod.startDate,
|
loadCycleData()
|
||||||
endDate = today,
|
|
||||||
flow = currentPeriod.flow,
|
|
||||||
symptoms = currentPeriod.symptoms.split(","),
|
|
||||||
mood = currentPeriod.mood
|
|
||||||
)
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(isPeriodActive = false)
|
|
||||||
loadCycleData() // Перезагружаем данные
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleSymptomsEdit() {
|
fun addSymptom() {
|
||||||
_uiState.value = _uiState.value.copy(
|
// Заглушка для демонстрации
|
||||||
showSymptomsEdit = !_uiState.value.showSymptomsEdit
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSymptoms(symptoms: List<String>) {
|
fun addNote() {
|
||||||
_uiState.value = _uiState.value.copy(todaySymptoms = symptoms)
|
// Заглушка для демонстрации
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateMood(mood: String) {
|
fun addSportActivity(date: LocalDate, activity: String) {
|
||||||
_uiState.value = _uiState.value.copy(todayMood = mood)
|
if (activity.isNotBlank()) {
|
||||||
}
|
_sportHistory.update { currentHistory ->
|
||||||
|
val updatedActivities = (currentHistory[date] ?: emptyList()) + activity
|
||||||
fun saveTodayData() {
|
currentHistory.toMutableMap().apply { put(date, updatedActivities) }
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val today = LocalDate.now()
|
|
||||||
val symptoms = _uiState.value.todaySymptoms
|
|
||||||
val mood = _uiState.value.todayMood
|
|
||||||
|
|
||||||
// TODO: Сохранить симптомы и настроение за сегодня
|
|
||||||
// Это может быть отдельная таблица или обновление текущего периода
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
showSymptomsEdit = false,
|
|
||||||
todaySymptoms = emptyList(),
|
|
||||||
todayMood = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
loadCycleData() // Перезагружаем данные
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun removeSportActivity(date: LocalDate, activity: String) {
|
||||||
_uiState.value = _uiState.value.copy(error = null)
|
_sportHistory.update { currentHistory ->
|
||||||
|
val updatedActivities = (currentHistory[date] ?: emptyList()).filter { it != activity }
|
||||||
|
val updatedMap = currentHistory.toMutableMap()
|
||||||
|
if (updatedActivities.isEmpty()) {
|
||||||
|
updatedMap.remove(date)
|
||||||
|
} else {
|
||||||
|
updatedMap[date] = updatedActivities
|
||||||
|
}
|
||||||
|
updatedMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addWaterRecord(date: LocalDate, amount: Int) {
|
||||||
|
if (amount > 0) {
|
||||||
|
_waterHistory.update { currentHistory ->
|
||||||
|
val updatedAmounts = (currentHistory[date] ?: emptyList()) + amount
|
||||||
|
currentHistory.toMutableMap().apply { put(date, updatedAmounts) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeWaterRecord(date: LocalDate, index: Int) {
|
||||||
|
_waterHistory.update { currentHistory ->
|
||||||
|
val current = currentHistory[date] ?: return@update currentHistory
|
||||||
|
if (index < 0 || index >= current.size) return@update currentHistory
|
||||||
|
|
||||||
|
val updated = current.toMutableList().apply { removeAt(index) }
|
||||||
|
val updatedMap = currentHistory.toMutableMap()
|
||||||
|
if (updated.isEmpty()) {
|
||||||
|
updatedMap.remove(date)
|
||||||
|
} else {
|
||||||
|
updatedMap[date] = updated
|
||||||
|
}
|
||||||
|
updatedMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addOrUpdateWeight(date: LocalDate, weight: Float) {
|
||||||
|
if (weight > 0) {
|
||||||
|
_weightHistory.update { currentHistory ->
|
||||||
|
val updatedWeights = (currentHistory[date] ?: emptyList()) + weight
|
||||||
|
currentHistory.toMutableMap().apply { put(date, updatedWeights) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fmt(date: LocalDate): String {
|
||||||
|
return date.format(DateTimeFormatter.ofPattern("dd MMM"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kr.smartsoltech.wellshe.model.JournalEntry
|
||||||
|
import kr.smartsoltech.wellshe.model.JournalMedia
|
||||||
|
import kr.smartsoltech.wellshe.model.MediaType
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun JournalEditorDialog(
|
||||||
|
initialEntry: JournalEntry?,
|
||||||
|
onSave: (JournalEntry) -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
var text by remember { mutableStateOf(initialEntry?.text ?: "") }
|
||||||
|
var media by remember { mutableStateOf(initialEntry?.media ?: emptyList()) }
|
||||||
|
var showMediaPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = {
|
||||||
|
onSave(
|
||||||
|
initialEntry?.copy(text = text, media = media) ?: JournalEntry(
|
||||||
|
id = 0L,
|
||||||
|
date = initialEntry?.date ?: java.time.LocalDate.now(),
|
||||||
|
text = text,
|
||||||
|
media = media
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Text("Сохранить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
if (initialEntry != null) {
|
||||||
|
Button(onClick = onDelete, colors = ButtonDefaults.buttonColors(containerColor = Color.Red)) {
|
||||||
|
Text("Удалить", color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text(if (initialEntry == null) "Новая запись" else "Редактировать запись") },
|
||||||
|
text = {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// Форматирование текста (минимум: жирный, курсив)
|
||||||
|
Row(modifier = Modifier.padding(bottom = 8.dp)) {
|
||||||
|
IconButton(onClick = { text += "**жирный**" }) {
|
||||||
|
Icon(Icons.Default.FormatBold, contentDescription = "Жирный")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { text += "*курсив*" }) {
|
||||||
|
Icon(Icons.Default.FormatItalic, contentDescription = "Курсив")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { text += "- элемент списка\n" }) {
|
||||||
|
Icon(Icons.Default.FormatListBulleted, contentDescription = "Список")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BasicTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp)
|
||||||
|
.background(Color(0xFFF8F9FA), shape = MaterialTheme.shapes.small)
|
||||||
|
.padding(8.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// Медиа
|
||||||
|
Row {
|
||||||
|
Button(onClick = { showMediaPicker = true }) {
|
||||||
|
Icon(Icons.Default.AddPhotoAlternate, contentDescription = "Добавить медиа")
|
||||||
|
Text("Медиа")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (media.isNotEmpty()) {
|
||||||
|
Column {
|
||||||
|
media.forEach { m ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
when (m.type) {
|
||||||
|
MediaType.IMAGE -> Icons.Default.Image
|
||||||
|
MediaType.VIDEO -> Icons.Default.Videocam
|
||||||
|
MediaType.AUDIO -> Icons.Default.MusicNote
|
||||||
|
},
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Text(m.uri)
|
||||||
|
IconButton(onClick = { media = media - m }) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = "Удалить медиа")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Примитивный медиа-пикер (заглушка)
|
||||||
|
if (showMediaPicker) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showMediaPicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = {
|
||||||
|
// Пример добавления картинки (заглушка)
|
||||||
|
media = media + JournalMedia(uri = "media_uri_example", type = MediaType.IMAGE)
|
||||||
|
showMediaPicker = false
|
||||||
|
}) {
|
||||||
|
Text("Добавить картинку")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text("Добавить медиа") },
|
||||||
|
text = { Text("Здесь будет выбор медиафайла") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ModernDatePickerDialog(
|
||||||
|
onDateSelected: (LocalDate) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
var selectedDate by remember { mutableStateOf(LocalDate.now()) }
|
||||||
|
val daysInMonth = selectedDate.lengthOfMonth()
|
||||||
|
val monthDates = (1..daysInMonth).map { selectedDate.withDayOfMonth(it) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = { onDateSelected(selectedDate) }) { Text("Выбрать") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Button(onClick = onDismiss) { Text("Отмена") }
|
||||||
|
},
|
||||||
|
title = { Text("Выбор даты") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
// Горизонтальный календарь
|
||||||
|
LazyRow(Modifier.fillMaxWidth()) {
|
||||||
|
items(monthDates) { date ->
|
||||||
|
val isSelected = date == selectedDate
|
||||||
|
Button(
|
||||||
|
onClick = { selectedDate = date },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(2.dp)
|
||||||
|
) {
|
||||||
|
Text("${date.dayOfMonth}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// Ручной ввод
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedDate.toString(),
|
||||||
|
onValueChange = {
|
||||||
|
runCatching { selectedDate = LocalDate.parse(it) }
|
||||||
|
},
|
||||||
|
label = { Text("Дата (ГГГГ-ММ-ДД)") },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.LocalDate
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SportContent(viewModel: CycleViewModel) {
|
||||||
|
val sportHistory = viewModel.sportHistory.collectAsState()
|
||||||
|
var selectedDate by remember { mutableStateOf(LocalDate.now()) }
|
||||||
|
var activityInput by remember { mutableStateOf("") }
|
||||||
|
val activities = sportHistory.value[selectedDate] ?: emptyList()
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
Text("Спорт", style = MaterialTheme.typography.titleLarge)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = activityInput,
|
||||||
|
onValueChange = { activityInput = it },
|
||||||
|
label = { Text("Активность") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Button(onClick = {
|
||||||
|
viewModel.addSportActivity(selectedDate, activityInput)
|
||||||
|
activityInput = ""
|
||||||
|
}, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Добавить")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Активности за выбранный день:", style = MaterialTheme.typography.titleMedium)
|
||||||
|
LazyColumn {
|
||||||
|
items(activities) { act ->
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(act, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
IconButton(onClick = { viewModel.removeSportActivity(selectedDate, act) }) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = "Удалить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.LocalDate
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WaterContent(viewModel: CycleViewModel) {
|
||||||
|
val waterHistory by viewModel.waterHistory.collectAsState()
|
||||||
|
var selectedDate by remember { mutableStateOf<LocalDate>(LocalDate.now()) }
|
||||||
|
var waterInput by remember { mutableStateOf<String>("") }
|
||||||
|
val records = waterHistory[selectedDate] ?: emptyList<Int>()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
Text("Вода", style = MaterialTheme.typography.titleLarge)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = waterInput,
|
||||||
|
onValueChange = { waterInput = it },
|
||||||
|
label = { Text("Выпито (мл)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Button(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.addWaterRecord(selectedDate, waterInput.toIntOrNull() ?: 0)
|
||||||
|
}
|
||||||
|
waterInput = ""
|
||||||
|
}, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Добавить")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Записи за выбранный день:", style = MaterialTheme.typography.titleMedium)
|
||||||
|
LazyColumn {
|
||||||
|
items(records) { rec ->
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text("${rec} мл", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.removeWaterRecord(selectedDate, rec)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = "Удалить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WeightContent(viewModel: CycleViewModel) {
|
||||||
|
val weightHistory by viewModel.weightHistory.collectAsState()
|
||||||
|
var selectedDate by remember { mutableStateOf<LocalDate>(LocalDate.now()) }
|
||||||
|
var weightInput by remember { mutableStateOf<String>("") }
|
||||||
|
val daysInMonth = selectedDate.lengthOfMonth()
|
||||||
|
val monthDates = (1..daysInMonth).map { selectedDate.withDayOfMonth(it) }
|
||||||
|
val weights = weightHistory[selectedDate] ?: emptyList<Float>()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
Text("Вес", style = MaterialTheme.typography.titleLarge)
|
||||||
|
LazyRow(Modifier.fillMaxWidth()) {
|
||||||
|
items(monthDates) { date ->
|
||||||
|
val isSelected = date == selectedDate
|
||||||
|
Button(
|
||||||
|
onClick = { selectedDate = date },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(2.dp)
|
||||||
|
) {
|
||||||
|
Text("${date.dayOfMonth}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = weightInput,
|
||||||
|
onValueChange = { weightInput = it },
|
||||||
|
label = { Text("Вес (кг)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Button(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.addOrUpdateWeight(selectedDate, weightInput.toFloatOrNull() ?: 0f)
|
||||||
|
}
|
||||||
|
weightInput = ""
|
||||||
|
}, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Сохранить")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("История за выбранный день:", style = MaterialTheme.typography.titleMedium)
|
||||||
|
weights.forEach { w: Float ->
|
||||||
|
Text("${w} кг", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text("График веса за месяц:", style = MaterialTheme.typography.titleMedium)
|
||||||
|
// Примитивный график
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
monthDates.forEach { date ->
|
||||||
|
val ws = weightHistory[date]
|
||||||
|
val weight = ws?.lastOrNull() ?: 0f
|
||||||
|
Box(Modifier.height((weight * 2).dp).width(8.dp).background(MaterialTheme.colorScheme.primary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowLeft
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.WbSunny
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kr.smartsoltech.wellshe.model.CycleForecast
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.PhasePill
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка календаря цикла
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CycleCalendarCard(
|
||||||
|
month: YearMonth,
|
||||||
|
onPrev: () -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
forecast: CycleForecast?,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val daysOfWeek = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
|
||||||
|
val monthFormat = DateTimeFormatter.ofPattern("LLLL yyyy", Locale("ru"))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = CycleTabColor.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок карточки с иконкой
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.WbSunny,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFFF9A825)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Календарь цикла",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заголовок месяца с кнопками навигации
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onPrev) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.KeyboardArrowLeft,
|
||||||
|
contentDescription = "Предыдущий месяц"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = month.format(monthFormat).replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(onClick = onNext) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.KeyboardArrowRight,
|
||||||
|
contentDescription = "Следующий месяц"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дни недели (заголовки)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
daysOfWeek.forEach { day ->
|
||||||
|
Text(
|
||||||
|
text = day,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// Сетка дней календаря
|
||||||
|
CalendarGrid(month, forecast)
|
||||||
|
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 16.dp))
|
||||||
|
|
||||||
|
// Легенда фаз
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
PhasePill(label = "Менструация", color = Color(0xFFE57373))
|
||||||
|
PhasePill(label = "Фертильное окно", color = Color(0xFF4CAF50))
|
||||||
|
PhasePill(label = "Овуляция", color = Color(0xFF3F51B5))
|
||||||
|
PhasePill(label = "ПМС", color = Color(0xFFFFA726))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сетка календаря с днями месяца
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarGrid(
|
||||||
|
month: YearMonth,
|
||||||
|
forecast: CycleForecast?
|
||||||
|
) {
|
||||||
|
// Получаем текущую дату
|
||||||
|
val today = LocalDate.now()
|
||||||
|
|
||||||
|
// Получаем первый день месяца и последний день месяца
|
||||||
|
val firstDay = month.atDay(1)
|
||||||
|
val lastDay = month.atEndOfMonth()
|
||||||
|
|
||||||
|
// Вычисляем смещение первого дня месяца
|
||||||
|
val dayOfWeekValue = firstDay.dayOfWeek.value // 1 для понедельника, 7 для воскресенья
|
||||||
|
val firstDayOffset = if (dayOfWeekValue == 7) 0 else dayOfWeekValue
|
||||||
|
|
||||||
|
// Создаем полную сетку календаря (6 недель по 7 дней)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
for (week in 0 until 6) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
for (dayOfWeek in 0 until 7) {
|
||||||
|
val dayNumber = week * 7 + dayOfWeek - firstDayOffset + 1
|
||||||
|
val isValidDay = dayNumber in 1..lastDay.dayOfMonth
|
||||||
|
|
||||||
|
if (isValidDay) {
|
||||||
|
val date = month.atDay(dayNumber)
|
||||||
|
CalendarDay(date = date, today = today, forecast = forecast)
|
||||||
|
} else {
|
||||||
|
// Пустая ячейка для выравнивания
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отображение одного дня календаря
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarDay(
|
||||||
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
forecast: CycleForecast?
|
||||||
|
) {
|
||||||
|
// Определяем, в какой фазе цикла находится день
|
||||||
|
val isPeriod = forecast?.let { date in it.nextPeriodStart..it.periodEnd } ?: false
|
||||||
|
val isFertile = forecast?.let { date in it.fertileStart..it.fertileEnd } ?: false
|
||||||
|
val isPms = forecast?.let { date in it.pmsStart..it.nextPeriodStart.minusDays(1) } ?: false
|
||||||
|
val isOvulation = forecast?.let { date == it.nextOvulation } ?: false
|
||||||
|
val isToday = date == today
|
||||||
|
|
||||||
|
// Определяем цвет фона
|
||||||
|
val backgroundColor = when {
|
||||||
|
isPeriod -> PeriodColor
|
||||||
|
isFertile -> FertileColor
|
||||||
|
isPms -> PmsColor
|
||||||
|
else -> Color.Transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем модификаторы в зависимости от фазы
|
||||||
|
val baseModifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(backgroundColor)
|
||||||
|
|
||||||
|
// Особое выделение для овуляции
|
||||||
|
val finalModifier = if (isOvulation) {
|
||||||
|
baseModifier.border(BorderStroke(2.dp, OvulationBorder), CircleShape)
|
||||||
|
} else {
|
||||||
|
baseModifier
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = finalModifier,
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date.dayOfMonth.toString(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import kr.smartsoltech.wellshe.model.CycleForecast
|
||||||
|
import java.time.YearMonth
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Этот файл содержал дубликаты функций, определённых в CycleCalendar.kt
|
||||||
|
* Для устранения конфликтов при компиляции, функции CycleCalendarCard и CalendarGrid были удалены
|
||||||
|
* Используйте функции из CycleCalendar.kt вместо них
|
||||||
|
*/
|
||||||
|
@Deprecated("Используйте CycleCalendar.kt вместо этого файла")
|
||||||
|
object CycleComponentsDeprecated {
|
||||||
|
// Пустой объект для предотвращения ошибок при компиляции
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Healing
|
||||||
|
import androidx.compose.material.icons.filled.Note
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка с быстрыми действиями
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun QuickActionsCard(
|
||||||
|
onMarkStart: () -> Unit,
|
||||||
|
onMarkEnd: () -> Unit,
|
||||||
|
onAddSymptom: () -> Unit,
|
||||||
|
onAddNote: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Быстрые действия",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
QuickActionButton(
|
||||||
|
text = "Отметить начало",
|
||||||
|
onClick = onMarkStart,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
QuickActionButton(
|
||||||
|
text = "Отметить окончание",
|
||||||
|
onClick = onMarkEnd,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedQuickActionButton(
|
||||||
|
text = "Симптом",
|
||||||
|
onClick = onAddSymptom,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
icon = Icons.Default.Healing
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedQuickActionButton(
|
||||||
|
text = "Заметка",
|
||||||
|
onClick = onAddNote,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
icon = Icons.Default.Note
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопка для быстрых действий (заполненная)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun QuickActionButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
colors: ButtonColors = ButtonDefaults.buttonColors()
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = colors,
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопка для быстрых действий (контурная)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OutlinedQuickActionButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector? = null
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основное содержимое экрана настроек цикла
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CycleSettingsContent(
|
||||||
|
settings: CycleSettingsEntity,
|
||||||
|
validationState: CycleSettingsViewModel.ValidationState,
|
||||||
|
cycleHistory: List<CycleHistoryEntity>,
|
||||||
|
exportImportState: CycleSettingsViewModel.ExportImportState,
|
||||||
|
onBasicSettingChanged: (BasicSettingChange) -> Unit,
|
||||||
|
onOvulationMethodChanged: (OvulationMethod) -> Unit,
|
||||||
|
onAllowManualOvulationChanged: (Boolean) -> Unit,
|
||||||
|
onStatusChanged: (StatusChange) -> Unit,
|
||||||
|
onHistorySettingChanged: (HistorySetting) -> Unit,
|
||||||
|
onSensorSettingChanged: (SensorSetting) -> Unit,
|
||||||
|
onNotificationSettingChanged: (NotificationSetting) -> Unit,
|
||||||
|
onCycleAtypicalToggled: (Long, Boolean) -> Unit,
|
||||||
|
onExportSettings: () -> Unit,
|
||||||
|
onImportSettings: (String) -> Unit,
|
||||||
|
onResetExportImportState: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Секция основных настроек
|
||||||
|
BasicSettingsSection(
|
||||||
|
settings = settings,
|
||||||
|
validationState = validationState,
|
||||||
|
onSettingChanged = onBasicSettingChanged
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Секция метода определения овуляции
|
||||||
|
OvulationMethodSection(
|
||||||
|
currentMethod = OvulationMethod.fromString(settings.ovulationMethod),
|
||||||
|
allowManualOvulation = settings.allowManualOvulation,
|
||||||
|
onMethodChanged = onOvulationMethodChanged,
|
||||||
|
onAllowManualChanged = onAllowManualOvulationChanged
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Секция статусов
|
||||||
|
StatusSection(
|
||||||
|
hormonalContraception = HormonalContraceptionType.fromString(settings.hormonalContraception),
|
||||||
|
isPregnant = settings.isPregnant,
|
||||||
|
isPostpartum = settings.isPostpartum,
|
||||||
|
isLactating = settings.isLactating,
|
||||||
|
perimenopause = settings.perimenopause,
|
||||||
|
onStatusChanged = onStatusChanged
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Секция настроек сенсоров и единиц измерения
|
||||||
|
SensorSettingsSection(
|
||||||
|
tempUnit = TemperatureUnit.fromString(settings.tempUnit),
|
||||||
|
bbtTimeWindow = settings.bbtTimeWindow,
|
||||||
|
timezone = settings.timezone,
|
||||||
|
validationState = validationState,
|
||||||
|
onSettingChanged = onSensorSettingChanged
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Секция настроек уведомлений
|
||||||
|
NotificationSettingsSection(
|
||||||
|
periodReminderDays = settings.periodReminderDaysBefore,
|
||||||
|
ovulationReminderDays = settings.ovulationReminderDaysBefore,
|
||||||
|
pmsWindowDays = settings.pmsWindowDays,
|
||||||
|
deviationAlertDays = settings.deviationAlertDays,
|
||||||
|
fertileWindowMode = FertileWindowMode.fromString(settings.fertileWindowMode),
|
||||||
|
onSettingChanged = onNotificationSettingChanged
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Секция настроек истории
|
||||||
|
HistorySettingsSection(
|
||||||
|
historyWindowCycles = settings.historyWindowCycles,
|
||||||
|
excludeOutliers = settings.excludeOutliers,
|
||||||
|
onSettingChanged = onHistorySettingChanged
|
||||||
|
)
|
||||||
|
|
||||||
|
// История циклов
|
||||||
|
CycleHistorySection(
|
||||||
|
cycleHistory = cycleHistory,
|
||||||
|
onCycleAtypicalToggled = onCycleAtypicalToggled
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Секция экспорта/импорта
|
||||||
|
ExportImportSection(
|
||||||
|
exportImportState = exportImportState,
|
||||||
|
onExportSettings = onExportSettings,
|
||||||
|
onImportSettings = onImportSettings,
|
||||||
|
onResetExportImportState = onResetExportImportState
|
||||||
|
)
|
||||||
|
|
||||||
|
// Отступ внизу для скролла
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Секция основных настроек цикла
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BasicSettingsSection(
|
||||||
|
settings: CycleSettingsEntity,
|
||||||
|
validationState: CycleSettingsViewModel.ValidationState,
|
||||||
|
onSettingChanged: (BasicSettingChange) -> Unit
|
||||||
|
) {
|
||||||
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Основные параметры цикла",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Базовая длина цикла
|
||||||
|
OutlinedTextField(
|
||||||
|
value = settings.baselineCycleLength.toString(),
|
||||||
|
onValueChange = {
|
||||||
|
try {
|
||||||
|
onSettingChanged(BasicSettingChange.BaselineCycleLength(it.toInt()))
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
// Игнорируем некорректный ввод
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Базовая длина цикла (дни)") },
|
||||||
|
isError = validationState.baselineCycleLengthError != null,
|
||||||
|
supportingText = {
|
||||||
|
validationState.baselineCycleLengthError?.let { Text(it) }
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Вариабельность цикла
|
||||||
|
OutlinedTextField(
|
||||||
|
value = settings.cycleVariabilityDays.toString(),
|
||||||
|
onValueChange = {
|
||||||
|
try {
|
||||||
|
onSettingChanged(BasicSettingChange.CycleVariability(it.toInt()))
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
// Игнорируем некорректный ввод
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Вариабельность (±дни)") },
|
||||||
|
isError = validationState.cycleVariabilityError != null,
|
||||||
|
supportingText = {
|
||||||
|
validationState.cycleVariabilityError?.let { Text(it) }
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Длительность менструации
|
||||||
|
OutlinedTextField(
|
||||||
|
value = settings.periodLengthDays.toString(),
|
||||||
|
onValueChange = {
|
||||||
|
try {
|
||||||
|
onSettingChanged(BasicSettingChange.PeriodLength(it.toInt()))
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
// Игнорируем некорректный ввод
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Длительность менструации (дни)") },
|
||||||
|
isError = validationState.periodLengthError != null,
|
||||||
|
supportingText = {
|
||||||
|
validationState.periodLengthError?.let { Text(it) }
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Лютеиновая фаза
|
||||||
|
OutlinedTextField(
|
||||||
|
value = settings.lutealPhaseDays,
|
||||||
|
onValueChange = {
|
||||||
|
onSettingChanged(BasicSettingChange.LutealPhase(it))
|
||||||
|
},
|
||||||
|
label = { Text("Лютеиновая фаза (дни или 'auto')") },
|
||||||
|
isError = validationState.lutealPhaseError != null,
|
||||||
|
supportingText = {
|
||||||
|
validationState.lutealPhaseError?.let {
|
||||||
|
Text(it)
|
||||||
|
} ?: Text("Оставьте 'auto' или укажите значение 8-17 дней")
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Дата последней менструации
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showDatePicker = true },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
val dateText = if (settings.lastPeriodStart != null) {
|
||||||
|
"Последняя менструация: ${
|
||||||
|
settings.lastPeriodStart.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
|
||||||
|
}"
|
||||||
|
} else {
|
||||||
|
"Выбрать дату последней менструации"
|
||||||
|
}
|
||||||
|
Text(dateText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем диалог выбора даты
|
||||||
|
if (showDatePicker) {
|
||||||
|
val datePickerState = rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis = settings.lastPeriodStart?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||||
|
)
|
||||||
|
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { showDatePicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
datePickerState.selectedDateMillis?.let { millis ->
|
||||||
|
val date = java.time.Instant.ofEpochMilli(millis)
|
||||||
|
.atZone(java.time.ZoneId.systemDefault())
|
||||||
|
.toLocalDate()
|
||||||
|
onSettingChanged(BasicSettingChange.LastPeriodStart(date))
|
||||||
|
}
|
||||||
|
showDatePicker = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Подтвердить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { showDatePicker = false }
|
||||||
|
) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
DatePicker(state = datePickerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Секция метода определения овуляции
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OvulationMethodSection(
|
||||||
|
currentMethod: OvulationMethod,
|
||||||
|
allowManualOvulation: Boolean,
|
||||||
|
onMethodChanged: (OvulationMethod) -> Unit,
|
||||||
|
onAllowManualChanged: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Метод определения овуляции",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Список методов овуляции
|
||||||
|
val methods = OvulationMethod.values()
|
||||||
|
methods.forEach { method ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = currentMethod == method,
|
||||||
|
onClick = { onMethodChanged(method) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = when (method) {
|
||||||
|
OvulationMethod.AUTO -> "Автоматически (календарный метод)"
|
||||||
|
OvulationMethod.BBT -> "Базальная температура тела"
|
||||||
|
OvulationMethod.LH_TEST -> "Тест на ЛГ"
|
||||||
|
OvulationMethod.CERVICAL_MUCUS -> "Цервикальная слизь"
|
||||||
|
OvulationMethod.MEDICAL -> "Медицинское подтверждение"
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключатель для ручной фиксации
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Switch(
|
||||||
|
checked = allowManualOvulation,
|
||||||
|
onCheckedChange = onAllowManualChanged
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Разрешить ручную фиксацию овуляции",
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Секция статусов, влияющих на точность прогнозов
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun StatusSection(
|
||||||
|
hormonalContraception: HormonalContraceptionType,
|
||||||
|
isPregnant: Boolean,
|
||||||
|
isPostpartum: Boolean,
|
||||||
|
isLactating: Boolean,
|
||||||
|
perimenopause: Boolean,
|
||||||
|
onStatusChanged: (StatusChange) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Статусы (влияют на точность)",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Гормональная контрацепция
|
||||||
|
Text("Гормональная контрацепция:")
|
||||||
|
|
||||||
|
val contraceptionTypes = HormonalContraceptionType.values()
|
||||||
|
contraceptionTypes.forEach { type ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = hormonalContraception == type,
|
||||||
|
onClick = { onStatusChanged(StatusChange.HormonalContraception(type)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = when (type) {
|
||||||
|
HormonalContraceptionType.NONE -> "Нет"
|
||||||
|
HormonalContraceptionType.COC -> "Комбинированные оральные контрацептивы (КОК)"
|
||||||
|
HormonalContraceptionType.IUD -> "Гормональная внутриматочная спираль"
|
||||||
|
HormonalContraceptionType.IMPLANT -> "Имплант"
|
||||||
|
HormonalContraceptionType.OTHER -> "Другое"
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Другие статусы (чекбоксы)
|
||||||
|
val statusItems = listOf(
|
||||||
|
Triple("Беременность", isPregnant) { value: Boolean ->
|
||||||
|
onStatusChanged(StatusChange.Pregnant(value))
|
||||||
|
},
|
||||||
|
Triple("Послеродовой период", isPostpartum) { value: Boolean ->
|
||||||
|
onStatusChanged(StatusChange.Postpartum(value))
|
||||||
|
},
|
||||||
|
Triple("Грудное вскармливание", isLactating) { value: Boolean ->
|
||||||
|
onStatusChanged(StatusChange.Lactating(value))
|
||||||
|
},
|
||||||
|
Triple("Перименопауза", perimenopause) { value: Boolean ->
|
||||||
|
onStatusChanged(StatusChange.Perimenopause(value))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
statusItems.forEach { (label, isChecked, onCheckedChange) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = isChecked,
|
||||||
|
onCheckedChange = onCheckedChange
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Секция истории циклов
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CycleHistorySection(
|
||||||
|
cycleHistory: List<CycleHistoryEntity>,
|
||||||
|
onCycleAtypicalToggled: (Long, Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "История циклов",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cycleHistory.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "История циклов пуста",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Показываем историю циклов
|
||||||
|
val sortedHistory = cycleHistory.sortedByDescending { it.periodStart }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
sortedHistory.forEach { cycle ->
|
||||||
|
ElevatedCard(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Начало: ${cycle.periodStart.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))}",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cycle.atypical) {
|
||||||
|
Badge(
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
containerColor = MaterialTheme.colorScheme.error
|
||||||
|
) {
|
||||||
|
Text("Атипичный")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cycle.periodEnd?.let {
|
||||||
|
Text(
|
||||||
|
text = "Конец: ${it.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cycle.ovulationDate?.let {
|
||||||
|
Text(
|
||||||
|
text = "Овуляция: ${it.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cycle.notes.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Примечания: ${cycle.notes}",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onCycleAtypicalToggled(cycle.id, !cycle.atypical) }
|
||||||
|
) {
|
||||||
|
Text(if (cycle.atypical) "Пометить как типичный" else "Пометить как атипичный")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для полной реализации всех секций потребуется продолжить в новом файле
|
||||||
|
// Из-за ограничений размера файла
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.FertileWindowMode
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.TemperatureUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Секция настроек сенсоров и единиц измерения
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SensorSettingsSection(
|
||||||
|
tempUnit: TemperatureUnit,
|
||||||
|
bbtTimeWindow: String,
|
||||||
|
timezone: String,
|
||||||
|
validationState: CycleSettingsViewModel.ValidationState,
|
||||||
|
onSettingChanged: (SensorSetting) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Сенсоры и единицы измерения",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Единицы измерения температуры
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Единица измерения температуры:",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RadioButton(
|
||||||
|
selected = tempUnit == TemperatureUnit.CELSIUS,
|
||||||
|
onClick = { onSettingChanged(SensorSetting.TempUnit(TemperatureUnit.CELSIUS)) }
|
||||||
|
)
|
||||||
|
Text("°C")
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RadioButton(
|
||||||
|
selected = tempUnit == TemperatureUnit.FAHRENHEIT,
|
||||||
|
onClick = { onSettingChanged(SensorSetting.TempUnit(TemperatureUnit.FAHRENHEIT)) }
|
||||||
|
)
|
||||||
|
Text("°F")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Временное окно для БТТ
|
||||||
|
OutlinedTextField(
|
||||||
|
value = bbtTimeWindow,
|
||||||
|
onValueChange = { onSettingChanged(SensorSetting.BbtTimeWindow(it)) },
|
||||||
|
label = { Text("Временное окно для измерения БТТ") },
|
||||||
|
placeholder = { Text("06:00-10:00") },
|
||||||
|
isError = validationState.bbtTimeWindowError != null,
|
||||||
|
supportingText = {
|
||||||
|
validationState.bbtTimeWindowError?.let { Text(it) }
|
||||||
|
?: Text("Формат: ЧЧ:ММ-ЧЧ:ММ")
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Временная зона
|
||||||
|
OutlinedTextField(
|
||||||
|
value = timezone,
|
||||||
|
onValueChange = { onSettingChanged(SensorSetting.Timezone(it)) },
|
||||||
|
label = { Text("Временная зона") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Секция настроек уведомлений
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotificationSettingsSection(
|
||||||
|
periodReminderDays: Int,
|
||||||
|
ovulationReminderDays: Int,
|
||||||
|
pmsWindowDays: Int,
|
||||||
|
deviationAlertDays: Int,
|
||||||
|
fertileWindowMode: FertileWindowMode,
|
||||||
|
onSettingChanged: (NotificationSetting) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Уведомления",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Напоминание о менструации
|
||||||
|
OutlinedTextField(
|
||||||
|
value = periodReminderDays.toString(),
|
||||||
|
onValueChange = {
|
||||||
|
try {
|
||||||
|
onSettingChanged(NotificationSetting.PeriodReminder(it.toInt()))
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
// Игнорируем некорректный ввод
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Напоминание о менструации (дней до)") },
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Напоминание об овуляции
|
||||||
|
OutlinedTextField(
|
||||||
|
value = ovulationReminderDays.toString(),
|
||||||
|
onValueChange = {
|
||||||
|
try {
|
||||||
|
onSettingChanged(NotificationSetting.OvulationReminder(it.toInt()))
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
// Игнорируем некорректный ввод
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Напоминание об овуляции (дней до)") },
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Окно ПМС
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pmsWindowDays.toString(),
|
||||||
|
onValueChange = {
|
||||||
|
try {
|
||||||
|
onSettingChanged(NotificationSetting.PmsWindow(it.toInt()))
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
// Игнорируем некорректный ввод
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Окно ПМС (дней)") },
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Оповещение об отклонении
|
||||||
|
OutlinedTextField(
|
||||||
|
value = deviationAlertDays.toString(),
|
||||||
|
onValueChange = {
|
||||||
|
try {
|
||||||
|
onSettingChanged(NotificationSetting.DeviationAlert(it.toInt()))
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
// Игнорируем некорректный ввод
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Оповещение об отклонении (дней после ожидаемой даты)") },
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Режим фертильного окна
|
||||||
|
Text(
|
||||||
|
text = "Режим определения фертильного окна:",
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
val fertileWindowModes = FertileWindowMode.values()
|
||||||
|
fertileWindowModes.forEach { mode ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = fertileWindowMode == mode,
|
||||||
|
onClick = { onSettingChanged(NotificationSetting.FertileWindowMode(mode)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when (mode) {
|
||||||
|
FertileWindowMode.CONSERVATIVE -> "Консервативный"
|
||||||
|
FertileWindowMode.BALANCED -> "Сбалансированный"
|
||||||
|
FertileWindowMode.BROAD -> "Широкий"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = when (mode) {
|
||||||
|
FertileWindowMode.CONSERVATIVE -> "3 дня до овуляции + день овуляции"
|
||||||
|
FertileWindowMode.BALANCED -> "5 дней до овуляции + день овуляции"
|
||||||
|
FertileWindowMode.BROAD -> "7 дней до овуляции + день овуляции"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Секция настроек истории
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HistorySettingsSection(
|
||||||
|
historyWindowCycles: Int,
|
||||||
|
excludeOutliers: Boolean,
|
||||||
|
onSettingChanged: (HistorySetting) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Настройки истории",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Окно истории для расчётов
|
||||||
|
OutlinedTextField(
|
||||||
|
value = historyWindowCycles.toString(),
|
||||||
|
onValueChange = {
|
||||||
|
try {
|
||||||
|
onSettingChanged(HistorySetting.WindowCycles(it.toInt()))
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
// Игнорируем некорректный ввод
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Количество циклов для расчётов") },
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Исключать выбросы
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Switch(
|
||||||
|
checked = excludeOutliers,
|
||||||
|
onCheckedChange = { onSettingChanged(HistorySetting.ExcludeOutliers(it)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Исключать выбросы (циклы < 18 или > 60 дней)",
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Секция экспорта/импорта настроек
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ExportImportSection(
|
||||||
|
exportImportState: CycleSettingsViewModel.ExportImportState,
|
||||||
|
onExportSettings: () -> Unit,
|
||||||
|
onImportSettings: (String) -> Unit,
|
||||||
|
onResetExportImportState: () -> Unit
|
||||||
|
) {
|
||||||
|
var showImportDialog by remember { mutableStateOf(false) }
|
||||||
|
var importJson by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Экспорт/Импорт настроек",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onExportSettings,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Экспортировать")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { showImportDialog = true },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Импортировать")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val state = exportImportState) {
|
||||||
|
is CycleSettingsViewModel.ExportImportState.Loading -> {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is CycleSettingsViewModel.ExportImportState.ExportSuccess -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Настройки экспортированы:",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.json,
|
||||||
|
onValueChange = { },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.height(120.dp),
|
||||||
|
readOnly = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
clipboardManager.setText(AnnotatedString(state.json))
|
||||||
|
onResetExportImportState()
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = "Копировать в буфер обмена"
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Копировать")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is CycleSettingsViewModel.ExportImportState.ImportSuccess -> {
|
||||||
|
Text(
|
||||||
|
text = "Настройки успешно импортированы!",
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Автоматически сбрасываем состояние через небольшую задержку
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(3000)
|
||||||
|
onResetExportImportState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is CycleSettingsViewModel.ExportImportState.Error -> {
|
||||||
|
Text(
|
||||||
|
text = state.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onResetExportImportState,
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Text("Закрыть")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> { /* Ничего не показываем в состоянии Idle */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог импорта настроек
|
||||||
|
if (showImportDialog) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
showImportDialog = false
|
||||||
|
importJson = ""
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surface
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Импорт настроек из JSON",
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = importJson,
|
||||||
|
onValueChange = { importJson = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(150.dp),
|
||||||
|
label = { Text("Вставьте JSON настроек") }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showImportDialog = false
|
||||||
|
importJson = ""
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (importJson.isNotBlank()) {
|
||||||
|
onImportSettings(importJson)
|
||||||
|
showImportDialog = false
|
||||||
|
importJson = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = importJson.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text("Импортировать")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kr.smartsoltech.wellshe.R
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Главный экран настроек цикла
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CycleSettingsScreen(
|
||||||
|
viewModel: CycleSettingsViewModel = hiltViewModel(),
|
||||||
|
onNavigateBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val settings by viewModel.settingsState.collectAsStateWithLifecycle()
|
||||||
|
val validationState by viewModel.validationState.collectAsStateWithLifecycle()
|
||||||
|
val exportImportState by viewModel.exportImportState.collectAsStateWithLifecycle()
|
||||||
|
val cycleHistory by viewModel.cycleHistory.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Отслеживаем события UI из ViewModel
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.uiEvents.observeForever { event ->
|
||||||
|
when (event) {
|
||||||
|
is CycleSettingsViewModel.UiEvent.ShowSnackbar -> {
|
||||||
|
coroutineScope.launch {
|
||||||
|
val result = if (event.actionLabel != null && event.action != null) {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
message = event.message,
|
||||||
|
actionLabel = event.actionLabel,
|
||||||
|
duration = SnackbarDuration.Long
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
message = event.message,
|
||||||
|
duration = SnackbarDuration.Short
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем действие отмены, если пользователь нажал на кнопку действия
|
||||||
|
if (result == SnackbarResult.ActionPerformed && event.action != null) {
|
||||||
|
event.action.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Настройки цикла") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowBack,
|
||||||
|
contentDescription = "Назад"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.resetToDefaults() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = "Сбросить к рекомендуемым"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
if (settings == null) {
|
||||||
|
// Показываем загрузку, если настройки еще не получены
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Основное содержимое экрана настроек
|
||||||
|
CycleSettingsContent(
|
||||||
|
settings = settings!!,
|
||||||
|
validationState = validationState,
|
||||||
|
cycleHistory = cycleHistory,
|
||||||
|
exportImportState = exportImportState,
|
||||||
|
onBasicSettingChanged = {
|
||||||
|
when (it) {
|
||||||
|
is BasicSettingChange.BaselineCycleLength ->
|
||||||
|
viewModel.updateBaselineCycleLength(it.value)
|
||||||
|
is BasicSettingChange.CycleVariability ->
|
||||||
|
viewModel.updateCycleVariability(it.value)
|
||||||
|
is BasicSettingChange.PeriodLength ->
|
||||||
|
viewModel.updatePeriodLength(it.value)
|
||||||
|
is BasicSettingChange.LutealPhase ->
|
||||||
|
viewModel.updateLutealPhase(it.value)
|
||||||
|
is BasicSettingChange.LastPeriodStart ->
|
||||||
|
viewModel.updateLastPeriodStart(it.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOvulationMethodChanged = {
|
||||||
|
viewModel.updateOvulationMethod(it)
|
||||||
|
},
|
||||||
|
onAllowManualOvulationChanged = {
|
||||||
|
viewModel.updateAllowManualOvulation(it)
|
||||||
|
},
|
||||||
|
onStatusChanged = {
|
||||||
|
when (it) {
|
||||||
|
is StatusChange.HormonalContraception ->
|
||||||
|
viewModel.updateHormonalContraception(it.value)
|
||||||
|
is StatusChange.Pregnant ->
|
||||||
|
viewModel.updatePregnancyStatus(it.value)
|
||||||
|
is StatusChange.Postpartum ->
|
||||||
|
viewModel.updatePostpartumStatus(it.value)
|
||||||
|
is StatusChange.Lactating ->
|
||||||
|
viewModel.updateLactatingStatus(it.value)
|
||||||
|
is StatusChange.Perimenopause ->
|
||||||
|
viewModel.updatePerimenopauseStatus(it.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHistorySettingChanged = {
|
||||||
|
when (it) {
|
||||||
|
is HistorySetting.WindowCycles ->
|
||||||
|
viewModel.updateHistoryWindow(it.value)
|
||||||
|
is HistorySetting.ExcludeOutliers ->
|
||||||
|
viewModel.updateExcludeOutliers(it.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSensorSettingChanged = {
|
||||||
|
when (it) {
|
||||||
|
is SensorSetting.TempUnit ->
|
||||||
|
viewModel.updateTemperatureUnit(it.value)
|
||||||
|
is SensorSetting.BbtTimeWindow ->
|
||||||
|
viewModel.updateBbtTimeWindow(it.value)
|
||||||
|
is SensorSetting.Timezone ->
|
||||||
|
viewModel.updateTimezone(it.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNotificationSettingChanged = {
|
||||||
|
when (it) {
|
||||||
|
is NotificationSetting.PeriodReminder ->
|
||||||
|
viewModel.updatePeriodReminderDays(it.value)
|
||||||
|
is NotificationSetting.OvulationReminder ->
|
||||||
|
viewModel.updateOvulationReminderDays(it.value)
|
||||||
|
is NotificationSetting.PmsWindow ->
|
||||||
|
viewModel.updatePmsWindowDays(it.value)
|
||||||
|
is NotificationSetting.DeviationAlert ->
|
||||||
|
viewModel.updateDeviationAlertDays(it.value)
|
||||||
|
is NotificationSetting.FertileWindowMode ->
|
||||||
|
viewModel.updateFertileWindowMode(it.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCycleAtypicalToggled = { cycleId, atypical ->
|
||||||
|
viewModel.toggleCycleAtypical(cycleId, atypical)
|
||||||
|
},
|
||||||
|
onExportSettings = {
|
||||||
|
viewModel.exportSettingsToJson()
|
||||||
|
},
|
||||||
|
onImportSettings = { json ->
|
||||||
|
viewModel.importSettingsFromJson(json)
|
||||||
|
},
|
||||||
|
onResetExportImportState = {
|
||||||
|
viewModel.resetExportImportState()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Классы для передачи событий изменения настроек из UI в ViewModel
|
||||||
|
*/
|
||||||
|
sealed class BasicSettingChange {
|
||||||
|
data class BaselineCycleLength(val value: Int) : BasicSettingChange()
|
||||||
|
data class CycleVariability(val value: Int) : BasicSettingChange()
|
||||||
|
data class PeriodLength(val value: Int) : BasicSettingChange()
|
||||||
|
data class LutealPhase(val value: String) : BasicSettingChange()
|
||||||
|
data class LastPeriodStart(val value: java.time.LocalDate) : BasicSettingChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class StatusChange {
|
||||||
|
data class HormonalContraception(val value: kr.smartsoltech.wellshe.domain.models.HormonalContraceptionType) : StatusChange()
|
||||||
|
data class Pregnant(val value: Boolean) : StatusChange()
|
||||||
|
data class Postpartum(val value: Boolean) : StatusChange()
|
||||||
|
data class Lactating(val value: Boolean) : StatusChange()
|
||||||
|
data class Perimenopause(val value: Boolean) : StatusChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class HistorySetting {
|
||||||
|
data class WindowCycles(val value: Int) : HistorySetting()
|
||||||
|
data class ExcludeOutliers(val value: Boolean) : HistorySetting()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SensorSetting {
|
||||||
|
data class TempUnit(val value: kr.smartsoltech.wellshe.domain.models.TemperatureUnit) : SensorSetting()
|
||||||
|
data class BbtTimeWindow(val value: String) : SensorSetting()
|
||||||
|
data class Timezone(val value: String) : SensorSetting()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class NotificationSetting {
|
||||||
|
data class PeriodReminder(val value: Int) : NotificationSetting()
|
||||||
|
data class OvulationReminder(val value: Int) : NotificationSetting()
|
||||||
|
data class PmsWindow(val value: Int) : NotificationSetting()
|
||||||
|
data class DeviationAlert(val value: Int) : NotificationSetting()
|
||||||
|
data class FertileWindowMode(val value: kr.smartsoltech.wellshe.domain.models.FertileWindowMode) : NotificationSetting()
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle.settings
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.CycleRepository
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.CycleSettings
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.FertileWindowMode
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.HormonalContraceptionType
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.OvulationMethod
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.TemperatureUnit
|
||||||
|
import kr.smartsoltech.wellshe.domain.services.CycleSettingsExportService
|
||||||
|
import kr.smartsoltech.wellshe.workers.CycleNotificationManager
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel для экрана настроек цикла
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class CycleSettingsViewModel @Inject constructor(
|
||||||
|
private val cycleRepository: CycleRepository,
|
||||||
|
private val exportService: CycleSettingsExportService,
|
||||||
|
private val notificationManager: CycleNotificationManager
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// Текущее состояние настроек
|
||||||
|
private val _settingsState = MutableStateFlow<CycleSettingsEntity?>(null)
|
||||||
|
val settingsState: StateFlow<CycleSettingsEntity?> = _settingsState.asStateFlow()
|
||||||
|
|
||||||
|
// История циклов
|
||||||
|
private val _cycleHistory = MutableStateFlow<List<CycleHistoryEntity>>(emptyList())
|
||||||
|
val cycleHistory: StateFlow<List<CycleHistoryEntity>> = _cycleHistory.asStateFlow()
|
||||||
|
|
||||||
|
// Состояние операций экспорта/импорта
|
||||||
|
private val _exportImportState = MutableStateFlow<ExportImportState>(ExportImportState.Idle)
|
||||||
|
val exportImportState: StateFlow<ExportImportState> = _exportImportState.asStateFlow()
|
||||||
|
|
||||||
|
// Состояние валидации полей
|
||||||
|
private val _validationState = MutableStateFlow(ValidationState())
|
||||||
|
val validationState: StateFlow<ValidationState> = _validationState.asStateFlow()
|
||||||
|
|
||||||
|
// Последнее действие для отмены (Undo)
|
||||||
|
private var lastAction: UndoAction? = null
|
||||||
|
|
||||||
|
// События для UI
|
||||||
|
private val _uiEvents = MutableLiveData<UiEvent>()
|
||||||
|
val uiEvents: LiveData<UiEvent> = _uiEvents
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadSettings()
|
||||||
|
loadCycleHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает настройки цикла из репозитория
|
||||||
|
*/
|
||||||
|
private fun loadSettings() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
cycleRepository.getSettingsFlow().collect { settings ->
|
||||||
|
_settingsState.value = settings ?: CycleSettingsEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает историю циклов из репозитория
|
||||||
|
*/
|
||||||
|
private fun loadCycleHistory() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
cycleRepository.getAllHistoryFlow().collect { history ->
|
||||||
|
_cycleHistory.value = history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет базовую длину цикла
|
||||||
|
*/
|
||||||
|
fun updateBaselineCycleLength(length: Int) {
|
||||||
|
val validatedLength = length.coerceIn(18, 60)
|
||||||
|
if (validatedLength != length) {
|
||||||
|
_validationState.value = _validationState.value.copy(
|
||||||
|
baselineCycleLengthError = "Длина цикла должна быть от 18 до 60 дней"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_validationState.value = _validationState.value.copy(baselineCycleLengthError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSetting {
|
||||||
|
it.copy(baselineCycleLength = validatedLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет вариабельность цикла
|
||||||
|
*/
|
||||||
|
fun updateCycleVariability(days: Int) {
|
||||||
|
val validatedDays = days.coerceIn(0, 10)
|
||||||
|
if (validatedDays != days) {
|
||||||
|
_validationState.value = _validationState.value.copy(
|
||||||
|
cycleVariabilityError = "Вариабельность должна быть от 0 до 10 дней"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_validationState.value = _validationState.value.copy(cycleVariabilityError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSetting {
|
||||||
|
it.copy(cycleVariabilityDays = validatedDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет длительность периода
|
||||||
|
*/
|
||||||
|
fun updatePeriodLength(days: Int) {
|
||||||
|
val validatedDays = days.coerceIn(1, 10)
|
||||||
|
if (validatedDays != days) {
|
||||||
|
_validationState.value = _validationState.value.copy(
|
||||||
|
periodLengthError = "Длительность периода должна быть от 1 до 10 дней"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_validationState.value = _validationState.value.copy(periodLengthError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSetting {
|
||||||
|
it.copy(periodLengthDays = validatedDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет лютеиновую фазу
|
||||||
|
*/
|
||||||
|
fun updateLutealPhase(value: String) {
|
||||||
|
val validatedValue = if (value != "auto") {
|
||||||
|
try {
|
||||||
|
val days = value.toInt()
|
||||||
|
if (days in 8..17) {
|
||||||
|
_validationState.value = _validationState.value.copy(lutealPhaseError = null)
|
||||||
|
days.toString()
|
||||||
|
} else {
|
||||||
|
_validationState.value = _validationState.value.copy(
|
||||||
|
lutealPhaseError = "Лютеиновая фаза должна быть от 8 до 17 дней"
|
||||||
|
)
|
||||||
|
value
|
||||||
|
}
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
_validationState.value = _validationState.value.copy(
|
||||||
|
lutealPhaseError = "Введите число или 'auto'"
|
||||||
|
)
|
||||||
|
value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_validationState.value = _validationState.value.copy(lutealPhaseError = null)
|
||||||
|
"auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSetting {
|
||||||
|
it.copy(lutealPhaseDays = validatedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет дату последней менструации
|
||||||
|
*/
|
||||||
|
fun updateLastPeriodStart(date: LocalDate) {
|
||||||
|
updateSetting(shouldSaveLastAction = true) {
|
||||||
|
it.copy(lastPeriodStart = date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет метод определения овуляции
|
||||||
|
*/
|
||||||
|
fun updateOvulationMethod(method: OvulationMethod) {
|
||||||
|
updateSetting {
|
||||||
|
it.copy(ovulationMethod = method.toStorageString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет разрешение на ручное указание овуляции
|
||||||
|
*/
|
||||||
|
fun updateAllowManualOvulation(allow: Boolean) {
|
||||||
|
updateSetting {
|
||||||
|
it.copy(allowManualOvulation = allow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет тип гормональной контрацепции
|
||||||
|
*/
|
||||||
|
fun updateHormonalContraception(type: HormonalContraceptionType) {
|
||||||
|
updateSetting(shouldSaveLastAction = true) {
|
||||||
|
it.copy(hormonalContraception = type.toStorageString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет статус беременности
|
||||||
|
*/
|
||||||
|
fun updatePregnancyStatus(isPregnant: Boolean) {
|
||||||
|
updateSetting(shouldSaveLastAction = true) {
|
||||||
|
it.copy(isPregnant = isPregnant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет статус послеродового периода
|
||||||
|
*/
|
||||||
|
fun updatePostpartumStatus(isPostpartum: Boolean) {
|
||||||
|
updateSetting(shouldSaveLastAction = true) {
|
||||||
|
it.copy(isPostpartum = isPostpartum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет статус грудного вскармливания
|
||||||
|
*/
|
||||||
|
fun updateLactatingStatus(isLactating: Boolean) {
|
||||||
|
updateSetting(shouldSaveLastAction = true) {
|
||||||
|
it.copy(isLactating = isLactating)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет статус перименопаузы
|
||||||
|
*/
|
||||||
|
fun updatePerimenopauseStatus(perimenopause: Boolean) {
|
||||||
|
updateSetting(shouldSaveLastAction = true) {
|
||||||
|
it.copy(perimenopause = perimenopause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет окно истории для расчетов
|
||||||
|
*/
|
||||||
|
fun updateHistoryWindow(cycles: Int) {
|
||||||
|
updateSetting {
|
||||||
|
it.copy(historyWindowCycles = cycles.coerceIn(2, 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет исключение выбросов при расчетах
|
||||||
|
*/
|
||||||
|
fun updateExcludeOutliers(exclude: Boolean) {
|
||||||
|
updateSetting {
|
||||||
|
it.copy(excludeOutliers = exclude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет единицы измерения температуры
|
||||||
|
*/
|
||||||
|
fun updateTemperatureUnit(unit: TemperatureUnit) {
|
||||||
|
updateSetting {
|
||||||
|
it.copy(tempUnit = unit.toStorageString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет временное окно для измерения базальной температуры
|
||||||
|
*/
|
||||||
|
fun updateBbtTimeWindow(timeWindow: String) {
|
||||||
|
// Валидация формата "HH:mm-HH:mm"
|
||||||
|
val isValid = try {
|
||||||
|
val parts = timeWindow.split("-")
|
||||||
|
val formatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
formatter.parse(parts[0])
|
||||||
|
formatter.parse(parts[1])
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
_validationState.value = _validationState.value.copy(
|
||||||
|
bbtTimeWindowError = "Формат должен быть ЧЧ:ММ-ЧЧ:ММ"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_validationState.value = _validationState.value.copy(bbtTimeWindowError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSetting {
|
||||||
|
it.copy(bbtTimeWindow = timeWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет timezone
|
||||||
|
*/
|
||||||
|
fun updateTimezone(timezone: String) {
|
||||||
|
updateSetting {
|
||||||
|
it.copy(timezone = timezone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет количество дней для напоминания о менструации
|
||||||
|
*/
|
||||||
|
fun updatePeriodReminderDays(days: Int) {
|
||||||
|
updateSetting {
|
||||||
|
it.copy(periodReminderDaysBefore = days.coerceIn(0, 7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет количество дней для напоминания об овуляции
|
||||||
|
*/
|
||||||
|
fun updateOvulationReminderDays(days: Int) {
|
||||||
|
updateSetting {
|
||||||
|
it.copy(ovulationReminderDaysBefore = days.coerceIn(0, 7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет окно ПМС
|
||||||
|
*/
|
||||||
|
fun updatePmsWindowDays(days: Int) {
|
||||||
|
updateSetting {
|
||||||
|
it.copy(pmsWindowDays = days.coerceIn(1, 7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет количество дней для оповещения об отклонении
|
||||||
|
*/
|
||||||
|
fun updateDeviationAlertDays(days: Int) {
|
||||||
|
updateSetting {
|
||||||
|
it.copy(deviationAlertDays = days.coerceIn(1, 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет режим фертильного окна
|
||||||
|
*/
|
||||||
|
fun updateFertileWindowMode(mode: FertileWindowMode) {
|
||||||
|
updateSetting {
|
||||||
|
it.copy(fertileWindowMode = mode.toStorageString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сбрасывает настройки к рекомендуемым значениям по умолчанию
|
||||||
|
*/
|
||||||
|
fun resetToDefaults() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Сохраняем текущие настройки для возможности отмены
|
||||||
|
val currentSettings = _settingsState.value
|
||||||
|
lastAction = if (currentSettings != null) {
|
||||||
|
UndoAction.ResetSettings(currentSettings)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем все ошибки валидации
|
||||||
|
_validationState.value = ValidationState()
|
||||||
|
|
||||||
|
// Сбрасываем настройки к рекомендуемым
|
||||||
|
cycleRepository.resetToDefaults()
|
||||||
|
|
||||||
|
// Отправляем событие для показа снекбара
|
||||||
|
_uiEvents.value = UiEvent.ShowSnackbar(
|
||||||
|
message = "Настройки сброшены к рекомендуемым",
|
||||||
|
actionLabel = "Отменить",
|
||||||
|
action = { undoLastAction() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Помечает цикл как атипичный или типичный
|
||||||
|
*/
|
||||||
|
fun toggleCycleAtypical(cycleId: Long, atypical: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
cycleRepository.markCycleAsAtypical(cycleId, atypical)
|
||||||
|
|
||||||
|
// Оповещаем пользователя о действии
|
||||||
|
val message = if (atypical) {
|
||||||
|
"Цикл помечен как атипичный"
|
||||||
|
} else {
|
||||||
|
"Цикл помечен как типичный"
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiEvents.value = UiEvent.ShowSnackbar(message = message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экспортирует настройки в JSON
|
||||||
|
*/
|
||||||
|
fun exportSettingsToJson() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_exportImportState.value = ExportImportState.Loading
|
||||||
|
|
||||||
|
try {
|
||||||
|
val settings = _settingsState.value ?: CycleSettingsEntity()
|
||||||
|
val json = exportService.exportSettingsToJson(settings)
|
||||||
|
_exportImportState.value = ExportImportState.ExportSuccess(json)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_exportImportState.value = ExportImportState.Error("Ошибка экспорта: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Импортирует настройки из JSON
|
||||||
|
*/
|
||||||
|
fun importSettingsFromJson(json: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_exportImportState.value = ExportImportState.Loading
|
||||||
|
|
||||||
|
try {
|
||||||
|
val importedSettings = exportService.importSettingsFromJson(json)
|
||||||
|
if (importedSettings != null) {
|
||||||
|
// Сохраняем текущие настройки для возможности отмены
|
||||||
|
val currentSettings = _settingsState.value
|
||||||
|
if (currentSettings != null) {
|
||||||
|
lastAction = UndoAction.ImportSettings(currentSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем импортированные настройки
|
||||||
|
cycleRepository.saveSettings(importedSettings)
|
||||||
|
|
||||||
|
_exportImportState.value = ExportImportState.ImportSuccess
|
||||||
|
|
||||||
|
// Отправляем событие для показа снекбара
|
||||||
|
_uiEvents.value = UiEvent.ShowSnackbar(
|
||||||
|
message = "Настройки успешно импортированы",
|
||||||
|
actionLabel = "Отменить",
|
||||||
|
action = { undoLastAction() }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_exportImportState.value = ExportImportState.Error("Некорректный формат JSON")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_exportImportState.value = ExportImportState.Error("Ошибка импорта: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отменяет последнее критичное действие (Undo)
|
||||||
|
*/
|
||||||
|
fun undoLastAction() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val action = lastAction
|
||||||
|
if (action != null) {
|
||||||
|
when (action) {
|
||||||
|
is UndoAction.ResetSettings -> {
|
||||||
|
cycleRepository.saveSettings(action.previousSettings)
|
||||||
|
_uiEvents.value = UiEvent.ShowSnackbar("Изменения отменены")
|
||||||
|
}
|
||||||
|
is UndoAction.ImportSettings -> {
|
||||||
|
cycleRepository.saveSettings(action.previousSettings)
|
||||||
|
_uiEvents.value = UiEvent.ShowSnackbar("Импорт отменен")
|
||||||
|
}
|
||||||
|
is UndoAction.UpdateCriticalSetting -> {
|
||||||
|
cycleRepository.saveSettings(action.previousSettings)
|
||||||
|
_uiEvents.value = UiEvent.ShowSnackbar("Изменение отменено")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очищаем последнее действие после отмены
|
||||||
|
lastAction = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сбрасывает состояние экспорта/импорта
|
||||||
|
*/
|
||||||
|
fun resetExportImportState() {
|
||||||
|
_exportImportState.value = ExportImportState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет настройку цикла и сохраняет её в репозиторий
|
||||||
|
*/
|
||||||
|
private fun updateSetting(
|
||||||
|
shouldSaveLastAction: Boolean = false,
|
||||||
|
update: (CycleSettingsEntity) -> CycleSettingsEntity
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val currentSettings = _settingsState.value ?: CycleSettingsEntity()
|
||||||
|
|
||||||
|
// Сохраняем текущие настройки для критичных изменений
|
||||||
|
if (shouldSaveLastAction) {
|
||||||
|
lastAction = UndoAction.UpdateCriticalSetting(currentSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем и сохраняем настройки
|
||||||
|
val updatedSettings = update(currentSettings)
|
||||||
|
cycleRepository.saveSettings(updatedSettings)
|
||||||
|
|
||||||
|
// Если это критичное изменение, показываем снекбар с возможностью отмены
|
||||||
|
if (shouldSaveLastAction) {
|
||||||
|
_uiEvents.value = UiEvent.ShowSnackbar(
|
||||||
|
message = "Настройки обновлены",
|
||||||
|
actionLabel = "Отменить",
|
||||||
|
action = { undoLastAction() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель состояния валидации полей
|
||||||
|
*/
|
||||||
|
data class ValidationState(
|
||||||
|
val baselineCycleLengthError: String? = null,
|
||||||
|
val cycleVariabilityError: String? = null,
|
||||||
|
val periodLengthError: String? = null,
|
||||||
|
val lutealPhaseError: String? = null,
|
||||||
|
val bbtTimeWindowError: String? = null
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Проверяет, есть ли ошибки валидации
|
||||||
|
*/
|
||||||
|
fun hasErrors(): Boolean {
|
||||||
|
return baselineCycleLengthError != null ||
|
||||||
|
cycleVariabilityError != null ||
|
||||||
|
periodLengthError != null ||
|
||||||
|
lutealPhaseError != null ||
|
||||||
|
bbtTimeWindowError != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель состояния экспорта/импорта
|
||||||
|
*/
|
||||||
|
sealed class ExportImportState {
|
||||||
|
object Idle : ExportImportState()
|
||||||
|
object Loading : ExportImportState()
|
||||||
|
data class ExportSuccess(val json: String) : ExportImportState()
|
||||||
|
object ImportSuccess : ExportImportState()
|
||||||
|
data class Error(val message: String) : ExportImportState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для отмены последнего действия
|
||||||
|
*/
|
||||||
|
sealed class UndoAction {
|
||||||
|
data class ResetSettings(val previousSettings: CycleSettingsEntity) : UndoAction()
|
||||||
|
data class ImportSettings(val previousSettings: CycleSettingsEntity) : UndoAction()
|
||||||
|
data class UpdateCriticalSetting(val previousSettings: CycleSettingsEntity) : UndoAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* События для UI
|
||||||
|
*/
|
||||||
|
sealed class UiEvent {
|
||||||
|
data class ShowSnackbar(
|
||||||
|
val message: String,
|
||||||
|
val actionLabel: String? = null,
|
||||||
|
val action: (() -> Unit)? = null
|
||||||
|
) : UiEvent()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.*
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -23,7 +24,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import kr.smartsoltech.wellshe.domain.model.*
|
import kr.smartsoltech.wellshe.domain.model.*
|
||||||
import kr.smartsoltech.wellshe.ui.theme.*
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
@@ -300,14 +300,11 @@ private fun QuickActionsRow(
|
|||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = TextPrimary
|
color = TextPrimary
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(horizontal = 4.dp)
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
LazyRow(
|
LazyRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
contentPadding = PaddingValues(horizontal = 4.dp)
|
|
||||||
) {
|
) {
|
||||||
items(quickActions) { action ->
|
items(quickActions) { action ->
|
||||||
QuickActionCard(
|
QuickActionCard(
|
||||||
@@ -327,7 +324,7 @@ private fun QuickActionCard(
|
|||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(120.dp)
|
.width(140.dp)
|
||||||
.clickable { onClick() },
|
.clickable { onClick() },
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
@@ -345,14 +342,14 @@ private fun QuickActionCard(
|
|||||||
imageVector = action.icon,
|
imageVector = action.icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = action.iconColor,
|
tint = action.iconColor,
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = action.title,
|
text = action.title,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = action.textColor
|
color = action.textColor
|
||||||
),
|
),
|
||||||
@@ -695,13 +692,13 @@ private fun getSleepQualityText(quality: SleepQuality): String {
|
|||||||
|
|
||||||
private fun getWorkoutIcon(type: WorkoutType): ImageVector {
|
private fun getWorkoutIcon(type: WorkoutType): ImageVector {
|
||||||
return when (type) {
|
return when (type) {
|
||||||
WorkoutType.CARDIO -> Icons.Default.DirectionsRun
|
WorkoutType.CARDIO -> Icons.AutoMirrored.Filled.DirectionsRun
|
||||||
WorkoutType.STRENGTH -> Icons.Default.FitnessCenter
|
WorkoutType.STRENGTH -> Icons.Default.FitnessCenter
|
||||||
WorkoutType.YOGA -> Icons.Default.SelfImprovement
|
WorkoutType.YOGA -> Icons.Default.SelfImprovement
|
||||||
WorkoutType.PILATES -> Icons.Default.SelfImprovement
|
WorkoutType.PILATES -> Icons.Default.SelfImprovement
|
||||||
WorkoutType.RUNNING -> Icons.Default.DirectionsRun
|
WorkoutType.RUNNING -> Icons.AutoMirrored.Filled.DirectionsRun
|
||||||
WorkoutType.WALKING -> Icons.Default.DirectionsWalk
|
WorkoutType.WALKING -> Icons.AutoMirrored.Filled.DirectionsWalk
|
||||||
WorkoutType.CYCLING -> Icons.Default.DirectionsBike
|
WorkoutType.CYCLING -> Icons.AutoMirrored.Filled.DirectionsBike
|
||||||
WorkoutType.SWIMMING -> Icons.Default.Pool
|
WorkoutType.SWIMMING -> Icons.Default.Pool
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
import kr.smartsoltech.wellshe.data.entity.SleepLogEntity
|
import kr.smartsoltech.wellshe.data.entity.SleepLogEntity
|
||||||
@@ -14,6 +15,7 @@ import kr.smartsoltech.wellshe.data.repository.WellSheRepository
|
|||||||
import kr.smartsoltech.wellshe.domain.model.*
|
import kr.smartsoltech.wellshe.domain.model.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
data class DashboardUiState(
|
data class DashboardUiState(
|
||||||
val user: User = User(),
|
val user: User = User(),
|
||||||
@@ -45,12 +47,16 @@ class DashboardViewModel @Inject constructor(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Загружаем данные пользователя
|
// Загружаем данные пользователя
|
||||||
repository.getUserProfile().collect { user ->
|
repository.getUserProfile().catch {
|
||||||
|
// Игнорируем ошибки, используем дефолтные данные
|
||||||
|
}.collect { user: User ->
|
||||||
_uiState.value = _uiState.value.copy(user = user)
|
_uiState.value = _uiState.value.copy(user = user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем данные о здоровье
|
// Загружаем данные о здоровье
|
||||||
repository.getTodayHealthData().collect { healthEntity ->
|
repository.getTodayHealthData().catch {
|
||||||
|
// Игнорируем ошибки, используем дефолтные данные
|
||||||
|
}.collect { healthEntity: HealthRecordEntity? ->
|
||||||
val healthData = healthEntity?.let { convertHealthEntityToModel(it) } ?: HealthData()
|
val healthData = healthEntity?.let { convertHealthEntityToModel(it) } ?: HealthData()
|
||||||
_uiState.value = _uiState.value.copy(todayHealth = healthData)
|
_uiState.value = _uiState.value.copy(todayHealth = healthData)
|
||||||
}
|
}
|
||||||
@@ -59,13 +65,16 @@ class DashboardViewModel @Inject constructor(
|
|||||||
loadSleepData()
|
loadSleepData()
|
||||||
|
|
||||||
// Загружаем данные о цикле
|
// Загружаем данные о цикле
|
||||||
repository.getCurrentCyclePeriod().collect { cycleEntity ->
|
repository.getRecentPeriods().let { periods ->
|
||||||
|
val cycleEntity = periods.firstOrNull()
|
||||||
val cycleData = cycleEntity?.let { convertCycleEntityToModel(it) } ?: CycleData()
|
val cycleData = cycleEntity?.let { convertCycleEntityToModel(it) } ?: CycleData()
|
||||||
_uiState.value = _uiState.value.copy(cycleData = cycleData)
|
_uiState.value = _uiState.value.copy(cycleData = cycleData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем тренировки
|
// Загружаем тренировки
|
||||||
repository.getRecentWorkouts().collect { workoutEntities ->
|
repository.getRecentWorkouts().catch {
|
||||||
|
// Игнорируем ошибки
|
||||||
|
}.collect { workoutEntities: List<WorkoutSession> ->
|
||||||
val workouts = workoutEntities.map { convertWorkoutEntityToModel(it) }
|
val workouts = workoutEntities.map { convertWorkoutEntityToModel(it) }
|
||||||
_uiState.value = _uiState.value.copy(recentWorkouts = workouts)
|
_uiState.value = _uiState.value.copy(recentWorkouts = workouts)
|
||||||
}
|
}
|
||||||
@@ -93,7 +102,7 @@ class DashboardViewModel @Inject constructor(
|
|||||||
val sleepEntity = repository.getSleepForDate(yesterday)
|
val sleepEntity = repository.getSleepForDate(yesterday)
|
||||||
val sleepData = sleepEntity?.let { convertSleepEntityToModel(it) } ?: SleepData()
|
val sleepData = sleepEntity?.let { convertSleepEntityToModel(it) } ?: SleepData()
|
||||||
_uiState.value = _uiState.value.copy(sleepData = sleepData)
|
_uiState.value = _uiState.value.copy(sleepData = sleepData)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// Игнорируем ошибки загрузки сна
|
// Игнорируем ошибки загрузки сна
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,10 +110,12 @@ class DashboardViewModel @Inject constructor(
|
|||||||
private suspend fun loadTodayFitnessData() {
|
private suspend fun loadTodayFitnessData() {
|
||||||
try {
|
try {
|
||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
repository.getFitnessDataForDate(today).collect { fitnessData ->
|
repository.getFitnessDataForDate(today).catch {
|
||||||
|
// Игнорируем ошибки
|
||||||
|
}.collect { fitnessData: FitnessData ->
|
||||||
_uiState.value = _uiState.value.copy(todaySteps = fitnessData.steps)
|
_uiState.value = _uiState.value.copy(todaySteps = fitnessData.steps)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// Игнорируем ошибки загрузки фитнеса
|
// Игнорируем ошибки загрузки фитнеса
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,11 +123,13 @@ class DashboardViewModel @Inject constructor(
|
|||||||
private suspend fun loadTodayWaterData() {
|
private suspend fun loadTodayWaterData() {
|
||||||
try {
|
try {
|
||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
repository.getWaterIntakeForDate(today).collect { waterIntakes ->
|
repository.getWaterIntakeForDate(today).catch {
|
||||||
|
// Игнорируем ошибки
|
||||||
|
}.collect { waterIntakes: List<WaterIntake> ->
|
||||||
val totalAmount = waterIntakes.sumOf { it.amount.toDouble() }.toFloat()
|
val totalAmount = waterIntakes.sumOf { it.amount.toDouble() }.toFloat()
|
||||||
_uiState.value = _uiState.value.copy(todayWater = totalAmount)
|
_uiState.value = _uiState.value.copy(todayWater = totalAmount)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// Игнорируем ошибки загрузки воды
|
// Игнорируем ошибки загрузки воды
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,10 +148,10 @@ class DashboardViewModel @Inject constructor(
|
|||||||
heartRate = entity.heartRate ?: 70,
|
heartRate = entity.heartRate ?: 70,
|
||||||
bloodPressureSystolic = entity.bloodPressureS ?: 120,
|
bloodPressureSystolic = entity.bloodPressureS ?: 120,
|
||||||
bloodPressureDiastolic = entity.bloodPressureD ?: 80,
|
bloodPressureDiastolic = entity.bloodPressureD ?: 80,
|
||||||
mood = convertMoodStringToEnum(entity.mood),
|
mood = convertMoodStringToEnum(entity.mood ?: "neutral"),
|
||||||
energyLevel = entity.energyLevel,
|
energyLevel = entity.energyLevel ?: 5,
|
||||||
stressLevel = entity.stressLevel,
|
stressLevel = entity.stressLevel ?: 5,
|
||||||
symptoms = entity.symptoms.split(",").filter { it.isNotBlank() }
|
symptoms = entity.symptoms ?: emptyList()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,13 +171,13 @@ class DashboardViewModel @Inject constructor(
|
|||||||
return CycleData(
|
return CycleData(
|
||||||
id = entity.id.toString(),
|
id = entity.id.toString(),
|
||||||
userId = "current_user",
|
userId = "current_user",
|
||||||
cycleLength = entity.cycleLength,
|
cycleLength = entity.cycleLength ?: 28,
|
||||||
periodLength = entity.endDate?.let {
|
periodLength = entity.endDate?.let {
|
||||||
java.time.temporal.ChronoUnit.DAYS.between(entity.startDate, it).toInt() + 1
|
ChronoUnit.DAYS.between(entity.startDate, it).toInt() + 1
|
||||||
} ?: 5,
|
} ?: 5,
|
||||||
lastPeriodDate = entity.startDate,
|
lastPeriodDate = entity.startDate,
|
||||||
nextPeriodDate = entity.startDate.plusDays(entity.cycleLength.toLong()),
|
nextPeriodDate = entity.startDate.plusDays((entity.cycleLength ?: 28).toLong()),
|
||||||
ovulationDate = entity.startDate.plusDays((entity.cycleLength / 2).toLong())
|
ovulationDate = entity.startDate.plusDays(((entity.cycleLength ?: 28) / 2).toLong())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,14 +228,4 @@ class DashboardViewModel @Inject constructor(
|
|||||||
else -> WorkoutType.CARDIO
|
else -> WorkoutType.CARDIO
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun convertWorkoutIntensityStringToEnum(intensity: String): WorkoutIntensity {
|
|
||||||
return when (intensity.lowercase()) {
|
|
||||||
"low" -> WorkoutIntensity.LOW
|
|
||||||
"moderate" -> WorkoutIntensity.MODERATE
|
|
||||||
"high" -> WorkoutIntensity.HIGH
|
|
||||||
"intense" -> WorkoutIntensity.INTENSE
|
|
||||||
else -> WorkoutIntensity.MODERATE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class FitnessViewModel @Inject constructor(
|
|||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
|
|
||||||
// Загружаем данные фитнеса за сегодня
|
// Загружаем данные фитнеса за сегодня
|
||||||
repository.getFitnessDataForDate(today).collect { fitnessData ->
|
repository.getFitnessDataForDate(today).collect { fitnessData: FitnessData ->
|
||||||
val calories = calculateCaloriesFromSteps(fitnessData.steps)
|
val calories = calculateCaloriesFromSteps(fitnessData.steps)
|
||||||
val distance = calculateDistanceFromSteps(fitnessData.steps)
|
val distance = calculateDistanceFromSteps(fitnessData.steps)
|
||||||
|
|
||||||
@@ -101,92 +101,64 @@ class FitnessViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startStepTracking() {
|
fun toggleStepTracking() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
val isTracking = _uiState.value.isTrackingSteps
|
||||||
_uiState.value = _uiState.value.copy(isTrackingSteps = true)
|
|
||||||
repository.startStepTracking()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopStepTracking() {
|
if (isTracking) {
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
repository.stopStepTracking()
|
repository.stopStepTracking()
|
||||||
_uiState.value = _uiState.value.copy(isTrackingSteps = false)
|
} else {
|
||||||
} catch (e: Exception) {
|
repository.startStepTracking()
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(isTrackingSteps = !isTracking)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startWorkout(workoutType: String) {
|
fun startWorkout(type: String, notes: String = "") {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
|
||||||
val workout = WorkoutSession(
|
val workout = WorkoutSession(
|
||||||
id = 0,
|
type = type,
|
||||||
type = workoutType,
|
|
||||||
date = LocalDate.now(),
|
date = LocalDate.now(),
|
||||||
startTime = LocalDateTime.now(),
|
startTime = LocalDateTime.now(),
|
||||||
duration = 0,
|
notes = notes
|
||||||
caloriesBurned = 0,
|
|
||||||
distance = 0f
|
|
||||||
)
|
)
|
||||||
|
|
||||||
repository.startWorkout(workout)
|
repository.startWorkout(workout)
|
||||||
loadFitnessData() // Перезагружаем данные
|
loadFitnessData() // Перезагружаем данные
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun endWorkout(workoutId: Long, duration: Int, caloriesBurned: Int, distance: Float) {
|
fun endWorkout(workoutId: Long, duration: Int, caloriesBurned: Int, distance: Float = 0f) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
|
||||||
repository.endWorkout(workoutId, duration, caloriesBurned, distance)
|
repository.endWorkout(workoutId, duration, caloriesBurned, distance)
|
||||||
loadFitnessData() // Перезагружаем данные
|
loadFitnessData() // Перезагружаем данные
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSteps(steps: Int) {
|
fun startStepTracking() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updateTodaySteps(steps)
|
repository.startStepTracking()
|
||||||
val calories = calculateCaloriesFromSteps(steps)
|
_uiState.value = _uiState.value.copy(isTrackingSteps = true)
|
||||||
val distance = calculateDistanceFromSteps(steps)
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
todaySteps = steps,
|
|
||||||
caloriesBurned = calories,
|
|
||||||
distance = distance
|
|
||||||
)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateCaloriesFromSteps(steps: Int): Int {
|
|
||||||
// Приблизительная формула: 1 шаг = 0.04 калории
|
|
||||||
return (steps * 0.04).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateDistanceFromSteps(steps: Int): Float {
|
|
||||||
// Приблизительная формула: 1 шаг = 0.8 метра
|
|
||||||
return (steps * 0.8f) / 1000f // конвертируем в километры
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
_uiState.value = _uiState.value.copy(error = null)
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Вспомогательные функции для расчетов
|
||||||
|
private fun calculateCaloriesFromSteps(steps: Int): Int {
|
||||||
|
// Средний расход калорий: около 0.04 ккал на шаг
|
||||||
|
return (steps * 0.04).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateDistanceFromSteps(steps: Int): Float {
|
||||||
|
// Средняя длина шага: около 0.7 метра
|
||||||
|
return steps * 0.7f / 1000 // Переводим в км
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -28,6 +29,9 @@ import java.time.format.DateTimeFormatter
|
|||||||
@Composable
|
@Composable
|
||||||
fun HealthOverviewScreen(
|
fun HealthOverviewScreen(
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
|
onWater: () -> Unit,
|
||||||
|
onWeight: () -> Unit,
|
||||||
|
onSport: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: HealthViewModel = hiltViewModel()
|
viewModel: HealthViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
@@ -43,44 +47,41 @@ fun HealthOverviewScreen(
|
|||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
SuccessGreenLight.copy(alpha = 0.3f),
|
Color(0xFFFFF0F5),
|
||||||
|
Color(0xFFFAF0E6),
|
||||||
NeutralWhite
|
NeutralWhite
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
Surface(
|
||||||
title = {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Text(
|
color = Color.White,
|
||||||
text = "Здоровье",
|
shadowElevation = 4.dp
|
||||||
style = MaterialTheme.typography.titleLarge.copy(
|
) {
|
||||||
fontWeight = FontWeight.Bold,
|
Row(
|
||||||
color = TextPrimary
|
modifier = Modifier
|
||||||
)
|
.fillMaxWidth()
|
||||||
)
|
.padding(16.dp),
|
||||||
},
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
navigationIcon = {
|
) {
|
||||||
IconButton(onClick = onNavigateBack) {
|
Button(onClick = onWater) {
|
||||||
Icon(
|
Icon(Icons.Default.LocalDrink, contentDescription = null)
|
||||||
imageVector = Icons.Default.ArrowBack,
|
Spacer(Modifier.width(8.dp))
|
||||||
contentDescription = "Назад",
|
Text("Вода")
|
||||||
tint = TextPrimary
|
}
|
||||||
)
|
Button(onClick = onWeight) {
|
||||||
|
Icon(Icons.Default.MonitorWeight, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Вес")
|
||||||
|
}
|
||||||
|
Button(onClick = onSport) {
|
||||||
|
Icon(Icons.Default.FitnessCenter, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Спорт")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = { viewModel.toggleEditMode() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (uiState.isEditMode) Icons.Default.Save else Icons.Default.Edit,
|
|
||||||
contentDescription = if (uiState.isEditMode) "Сохранить" else "Редактировать",
|
|
||||||
tint = SuccessGreen
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = NeutralWhite.copy(alpha = 0.95f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -241,7 +241,19 @@ private fun VitalSignsCard(
|
|||||||
onValueChange = {
|
onValueChange = {
|
||||||
systolic = it
|
systolic = it
|
||||||
it.toIntOrNull()?.let { sys ->
|
it.toIntOrNull()?.let { sys ->
|
||||||
val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now())
|
val currentRecord = healthRecord ?: HealthRecordEntity(
|
||||||
|
date = LocalDate.now(),
|
||||||
|
weight = 0f,
|
||||||
|
heartRate = 0,
|
||||||
|
bloodPressureS = 0,
|
||||||
|
bloodPressureD = 0,
|
||||||
|
temperature = 36.6f,
|
||||||
|
mood = "",
|
||||||
|
energyLevel = 5,
|
||||||
|
stressLevel = 5,
|
||||||
|
symptoms = emptyList(),
|
||||||
|
notes = ""
|
||||||
|
)
|
||||||
onRecordUpdate(currentRecord.copy(bloodPressureS = sys))
|
onRecordUpdate(currentRecord.copy(bloodPressureS = sys))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -256,7 +268,19 @@ private fun VitalSignsCard(
|
|||||||
onValueChange = {
|
onValueChange = {
|
||||||
diastolic = it
|
diastolic = it
|
||||||
it.toIntOrNull()?.let { dia ->
|
it.toIntOrNull()?.let { dia ->
|
||||||
val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now())
|
val currentRecord = healthRecord ?: HealthRecordEntity(
|
||||||
|
date = LocalDate.now(),
|
||||||
|
weight = 0f,
|
||||||
|
heartRate = 0,
|
||||||
|
bloodPressureS = 0,
|
||||||
|
bloodPressureD = 0,
|
||||||
|
temperature = 36.6f,
|
||||||
|
mood = "",
|
||||||
|
energyLevel = 5,
|
||||||
|
stressLevel = 5,
|
||||||
|
symptoms = emptyList(),
|
||||||
|
notes = ""
|
||||||
|
)
|
||||||
onRecordUpdate(currentRecord.copy(bloodPressureD = dia))
|
onRecordUpdate(currentRecord.copy(bloodPressureD = dia))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -274,7 +298,19 @@ private fun VitalSignsCard(
|
|||||||
onValueChange = {
|
onValueChange = {
|
||||||
heartRate = it
|
heartRate = it
|
||||||
it.toIntOrNull()?.let { hr ->
|
it.toIntOrNull()?.let { hr ->
|
||||||
val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now())
|
val currentRecord = healthRecord ?: HealthRecordEntity(
|
||||||
|
date = LocalDate.now(),
|
||||||
|
weight = 0f,
|
||||||
|
heartRate = 0,
|
||||||
|
bloodPressureS = 0,
|
||||||
|
bloodPressureD = 0,
|
||||||
|
temperature = 36.6f,
|
||||||
|
mood = "",
|
||||||
|
energyLevel = 5,
|
||||||
|
stressLevel = 5,
|
||||||
|
symptoms = emptyList(),
|
||||||
|
notes = ""
|
||||||
|
)
|
||||||
onRecordUpdate(currentRecord.copy(heartRate = hr))
|
onRecordUpdate(currentRecord.copy(heartRate = hr))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -290,7 +326,19 @@ private fun VitalSignsCard(
|
|||||||
value = notes,
|
value = notes,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
notes = it
|
notes = it
|
||||||
val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now())
|
val currentRecord = healthRecord ?: HealthRecordEntity(
|
||||||
|
date = LocalDate.now(),
|
||||||
|
weight = 0f,
|
||||||
|
heartRate = 0,
|
||||||
|
bloodPressureS = 0,
|
||||||
|
bloodPressureD = 0,
|
||||||
|
temperature = 36.6f,
|
||||||
|
mood = "",
|
||||||
|
energyLevel = 5,
|
||||||
|
stressLevel = 5,
|
||||||
|
symptoms = emptyList(),
|
||||||
|
notes = ""
|
||||||
|
)
|
||||||
onRecordUpdate(currentRecord.copy(notes = it))
|
onRecordUpdate(currentRecord.copy(notes = it))
|
||||||
},
|
},
|
||||||
label = { Text("Заметки") },
|
label = { Text("Заметки") },
|
||||||
@@ -329,7 +377,7 @@ private fun VitalSignsCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (healthRecord.notes.isNotEmpty()) {
|
if ((healthRecord.notes ?: "").isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -339,7 +387,7 @@ private fun VitalSignsCard(
|
|||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = healthRecord.notes,
|
text = healthRecord.notes ?: "",
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
color = TextPrimary
|
color = TextPrimary
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,21 +38,29 @@ class HealthViewModel @Inject constructor(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Загружаем данные о здоровье за сегодня
|
// Загружаем данные о здоровье за сегодня
|
||||||
repository.getTodayHealthData().collect { todayRecord ->
|
repository.getTodayHealthData().collect { todayRecord: HealthRecordEntity? ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
todayRecord = todayRecord,
|
todayRecord = todayRecord,
|
||||||
lastUpdateDate = todayRecord?.date,
|
lastUpdateDate = todayRecord?.date,
|
||||||
todaySymptoms = todayRecord?.symptoms?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
todaySymptoms = todayRecord?.symptoms ?: emptyList(),
|
||||||
todayNotes = todayRecord?.notes ?: "",
|
todayNotes = todayRecord?.notes ?: "",
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем недельные данные веса
|
// Загружаем недельные данные веса
|
||||||
loadWeeklyWeights()
|
repository.getAllHealthRecords().collect { records: List<HealthRecordEntity> ->
|
||||||
|
val weightsMap = records
|
||||||
|
.filter { it.weight != null && it.weight > 0f }
|
||||||
|
.groupBy { it.date }
|
||||||
|
.mapValues { entry -> entry.value.last().weight ?: 0f }
|
||||||
|
_uiState.value = _uiState.value.copy(weeklyWeights = weightsMap)
|
||||||
|
}
|
||||||
|
|
||||||
// Загружаем последние записи
|
// Загружаем последние записи
|
||||||
loadRecentRecords()
|
repository.getRecentHealthRecords().collect { records: List<HealthRecordEntity> ->
|
||||||
|
_uiState.value = _uiState.value.copy(recentRecords = records)
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
@@ -63,26 +71,6 @@ class HealthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadWeeklyWeights() {
|
|
||||||
try {
|
|
||||||
// Временная заглушка - методы репозитория пока не реализованы
|
|
||||||
val weightsMap = emptyMap<LocalDate, Float>()
|
|
||||||
_uiState.value = _uiState.value.copy(weeklyWeights = weightsMap)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Игнорируем ошибки загрузки весов
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadRecentRecords() {
|
|
||||||
try {
|
|
||||||
// Временная заглушка - методы репозитория пока не реализованы
|
|
||||||
val records = emptyList<HealthRecordEntity>()
|
|
||||||
_uiState.value = _uiState.value.copy(recentRecords = records)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Игнорируем ошибки загрузки записей
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateVitals(weight: Float?, heartRate: Int?, bpSystolic: Int?, bpDiastolic: Int?, temperature: Float?) {
|
fun updateVitals(weight: Float?, heartRate: Int?, bpSystolic: Int?, bpDiastolic: Int?, temperature: Float?) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -102,13 +90,15 @@ class HealthViewModel @Inject constructor(
|
|||||||
heartRate = heartRate,
|
heartRate = heartRate,
|
||||||
bloodPressureS = bpSystolic,
|
bloodPressureS = bpSystolic,
|
||||||
bloodPressureD = bpDiastolic,
|
bloodPressureD = bpDiastolic,
|
||||||
temperature = temperature
|
temperature = temperature,
|
||||||
|
mood = "",
|
||||||
|
energyLevel = 5,
|
||||||
|
stressLevel = 5,
|
||||||
|
symptoms = emptyList(),
|
||||||
|
notes = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
repository.saveHealthRecord(updatedRecord)
|
||||||
// Временная заглушка - метод saveHealthRecord пока не реализован
|
|
||||||
// repository.saveHealthRecord(updatedRecord)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
@@ -124,13 +114,19 @@ class HealthViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
HealthRecordEntity(
|
HealthRecordEntity(
|
||||||
date = LocalDate.now(),
|
date = LocalDate.now(),
|
||||||
mood = mood
|
weight = 0f,
|
||||||
|
heartRate = 0,
|
||||||
|
bloodPressureS = 0,
|
||||||
|
bloodPressureD = 0,
|
||||||
|
temperature = 36.6f,
|
||||||
|
mood = mood,
|
||||||
|
energyLevel = 5,
|
||||||
|
stressLevel = 5,
|
||||||
|
symptoms = emptyList(),
|
||||||
|
notes = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
repository.saveHealthRecord(updatedRecord)
|
||||||
// Временная заглушка - метод saveHealthRecord пока не реализован
|
|
||||||
// repository.saveHealthRecord(updatedRecord)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
@@ -146,13 +142,19 @@ class HealthViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
HealthRecordEntity(
|
HealthRecordEntity(
|
||||||
date = LocalDate.now(),
|
date = LocalDate.now(),
|
||||||
energyLevel = energy
|
weight = 0f,
|
||||||
|
heartRate = 0,
|
||||||
|
bloodPressureS = 0,
|
||||||
|
bloodPressureD = 0,
|
||||||
|
temperature = 36.6f,
|
||||||
|
mood = "",
|
||||||
|
energyLevel = energy,
|
||||||
|
stressLevel = 5,
|
||||||
|
symptoms = emptyList(),
|
||||||
|
notes = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
repository.saveHealthRecord(updatedRecord)
|
||||||
// Временная заглушка - метод saveHealthRecord пока не реализован
|
|
||||||
// repository.saveHealthRecord(updatedRecord)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
@@ -168,13 +170,19 @@ class HealthViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
HealthRecordEntity(
|
HealthRecordEntity(
|
||||||
date = LocalDate.now(),
|
date = LocalDate.now(),
|
||||||
stressLevel = stress
|
weight = 0f,
|
||||||
|
heartRate = 0,
|
||||||
|
bloodPressureS = 0,
|
||||||
|
bloodPressureD = 0,
|
||||||
|
temperature = 36.6f,
|
||||||
|
mood = "",
|
||||||
|
energyLevel = 5,
|
||||||
|
stressLevel = stress,
|
||||||
|
symptoms = emptyList(),
|
||||||
|
notes = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
repository.saveHealthRecord(updatedRecord)
|
||||||
// Временная заглушка - метод saveHealthRecord пока не реализован
|
|
||||||
// repository.saveHealthRecord(updatedRecord)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
@@ -183,23 +191,27 @@ class HealthViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun updateSymptoms(symptoms: List<String>) {
|
fun updateSymptoms(symptoms: List<String>) {
|
||||||
_uiState.value = _uiState.value.copy(todaySymptoms = symptoms)
|
_uiState.value = _uiState.value.copy(todaySymptoms = symptoms)
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val currentRecord = _uiState.value.todayRecord
|
val currentRecord = _uiState.value.todayRecord
|
||||||
val symptomsString = symptoms.joinToString(",")
|
|
||||||
val updatedRecord = if (currentRecord != null) {
|
val updatedRecord = if (currentRecord != null) {
|
||||||
currentRecord.copy(symptoms = symptomsString)
|
currentRecord.copy(symptoms = symptoms)
|
||||||
} else {
|
} else {
|
||||||
HealthRecordEntity(
|
HealthRecordEntity(
|
||||||
date = LocalDate.now(),
|
date = LocalDate.now(),
|
||||||
symptoms = symptomsString
|
weight = 0f,
|
||||||
|
heartRate = 0,
|
||||||
|
bloodPressureS = 0,
|
||||||
|
bloodPressureD = 0,
|
||||||
|
temperature = 36.6f,
|
||||||
|
mood = "",
|
||||||
|
energyLevel = 5,
|
||||||
|
stressLevel = 5,
|
||||||
|
symptoms = symptoms,
|
||||||
|
notes = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
repository.saveHealthRecord(updatedRecord)
|
||||||
// Временная заглушка - метод saveHealthRecord пока не реализован
|
|
||||||
// repository.saveHealthRecord(updatedRecord)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
@@ -208,7 +220,6 @@ class HealthViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun updateNotes(notes: String) {
|
fun updateNotes(notes: String) {
|
||||||
_uiState.value = _uiState.value.copy(todayNotes = notes)
|
_uiState.value = _uiState.value.copy(todayNotes = notes)
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val currentRecord = _uiState.value.todayRecord
|
val currentRecord = _uiState.value.todayRecord
|
||||||
@@ -217,13 +228,19 @@ class HealthViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
HealthRecordEntity(
|
HealthRecordEntity(
|
||||||
date = LocalDate.now(),
|
date = LocalDate.now(),
|
||||||
|
weight = 0f,
|
||||||
|
heartRate = 0,
|
||||||
|
bloodPressureS = 0,
|
||||||
|
bloodPressureD = 0,
|
||||||
|
temperature = 36.6f,
|
||||||
|
mood = "",
|
||||||
|
energyLevel = 5,
|
||||||
|
stressLevel = 5,
|
||||||
|
symptoms = emptyList(),
|
||||||
notes = notes
|
notes = notes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
repository.saveHealthRecord(updatedRecord)
|
||||||
// Временная заглушка - метод saveHealthRecord пока не реализован
|
|
||||||
// repository.saveHealthRecord(updatedRecord)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
@@ -233,8 +250,7 @@ class HealthViewModel @Inject constructor(
|
|||||||
fun deleteHealthRecord(record: HealthRecordEntity) {
|
fun deleteHealthRecord(record: HealthRecordEntity) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
// Временная заглушка - метод deleteHealthRecord пока не реализован
|
repository.deleteHealthRecord(record.id)
|
||||||
// repository.deleteHealthRecord(record.id)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.health
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SportScreen(onBack: () -> Unit, viewModel: SportViewModel = hiltViewModel()) {
|
||||||
|
val exercises by viewModel.exercises.collectAsState()
|
||||||
|
val sessions by viewModel.sessions.collectAsState()
|
||||||
|
val activeSessionId by viewModel.activeSessionId.collectAsState()
|
||||||
|
val (search, setSearch) = remember { mutableStateOf("") }
|
||||||
|
val (selectedExercise, setSelectedExercise) = remember { mutableStateOf<Long?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { viewModel.loadExercises() ; viewModel.loadSessions() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(title = { Text("Тренировки и спорт") }, navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = null) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = search,
|
||||||
|
onValueChange = {
|
||||||
|
setSearch(it)
|
||||||
|
viewModel.loadExercises(it)
|
||||||
|
},
|
||||||
|
label = { Text("Поиск упражнения") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Выберите упражнение:", style = MaterialTheme.typography.titleSmall)
|
||||||
|
exercises.forEach { ex ->
|
||||||
|
Row(Modifier.fillMaxWidth().clickable { setSelectedExercise(ex.id) }) {
|
||||||
|
RadioButton(selected = selectedExercise == ex.id, onClick = { setSelectedExercise(ex.id) })
|
||||||
|
Text(ex.name, Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
if (activeSessionId == null && selectedExercise != null) {
|
||||||
|
Button(onClick = { viewModel.startSession(selectedExercise!!) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Старт тренировки")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (activeSessionId != null) {
|
||||||
|
Button(onClick = { viewModel.stopSession() }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Стоп тренировки")
|
||||||
|
}
|
||||||
|
// TODO: таймер, параметры, онлайн-расчёт калорий
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text("История тренировок за неделю", style = MaterialTheme.typography.titleSmall)
|
||||||
|
sessions.forEach { session ->
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
|
Text(session.startedAt.toString(), Modifier.weight(1f))
|
||||||
|
Text("${session.kcalTotal?.let { String.format("%.0f ккал", it) } ?: "—"}", Modifier.weight(1f))
|
||||||
|
Text("${session.distanceKm?.let { String.format("%.2f км", it) } ?: "—"}", Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.health
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kr.smartsoltech.wellshe.data.repo.WorkoutService
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.Exercise
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.WorkoutSession
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SportViewModel @Inject constructor(
|
||||||
|
private val workoutService: WorkoutService
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _exercises = MutableStateFlow<List<Exercise>>(emptyList())
|
||||||
|
val exercises: StateFlow<List<Exercise>> = _exercises
|
||||||
|
|
||||||
|
private val _sessions = MutableStateFlow<List<WorkoutSession>>(emptyList())
|
||||||
|
val sessions: StateFlow<List<WorkoutSession>> = _sessions
|
||||||
|
|
||||||
|
private val _activeSessionId = MutableStateFlow<Long?>(null)
|
||||||
|
val activeSessionId: StateFlow<Long?> = _activeSessionId
|
||||||
|
|
||||||
|
fun loadExercises(query: String = "") {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_exercises.value = workoutService.searchExercises(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startSession(exerciseId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val sessionId = workoutService.startSession(exerciseId)
|
||||||
|
_activeSessionId.value = sessionId
|
||||||
|
loadSessions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopSession() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val sessionId = _activeSessionId.value
|
||||||
|
if (sessionId != null) {
|
||||||
|
workoutService.stopSession(sessionId)
|
||||||
|
}
|
||||||
|
_activeSessionId.value = null
|
||||||
|
loadSessions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSessions() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_sessions.value = workoutService.getSessions(days = 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.health
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WaterScreen(onBack: () -> Unit, viewModel: WaterViewModel = hiltViewModel()) {
|
||||||
|
val waterToday by viewModel.waterToday.collectAsState()
|
||||||
|
val dailyGoal by viewModel.dailyGoal.collectAsState()
|
||||||
|
val waterHistory by viewModel.waterHistory.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { viewModel.loadHistory() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(title = { Text("Вода и напитки") }, navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = null) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||||
|
Text("Быстрые кнопки объёмов воды и напитков", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Row(Modifier.padding(vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
listOf(200, 300, 500).forEach { ml ->
|
||||||
|
Button(onClick = { viewModel.logWater(ml) }) {
|
||||||
|
Text("+${ml} мл")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text("Сегодня: $waterToday мл / $dailyGoal мл", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = (waterToday / dailyGoal.toFloat()).coerceIn(0f, 1f),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(12.dp),
|
||||||
|
color = Color(0xFF42A5F5)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text("График за неделю", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Canvas(modifier = Modifier.fillMaxWidth().height(120.dp)) {
|
||||||
|
val max = (waterHistory.maxOrNull() ?: 1).toFloat()
|
||||||
|
val barWidth = size.width / (waterHistory.size.coerceAtLeast(1))
|
||||||
|
waterHistory.forEachIndexed { i, v ->
|
||||||
|
drawRect(
|
||||||
|
color = Color(0xFF42A5F5),
|
||||||
|
topLeft = androidx.compose.ui.geometry.Offset(i * barWidth, size.height * (1 - v / max)),
|
||||||
|
size = androidx.compose.ui.geometry.Size(barWidth * 0.7f, size.height * (v / max))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.health
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kr.smartsoltech.wellshe.data.repo.DrinkLogger
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import java.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class WaterViewModel @Inject constructor(
|
||||||
|
private val drinkLogger: DrinkLogger
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _waterToday = MutableStateFlow(0)
|
||||||
|
val waterToday: StateFlow<Int> = _waterToday
|
||||||
|
|
||||||
|
private val _waterHistory = MutableStateFlow<List<Int>>(emptyList())
|
||||||
|
val waterHistory: StateFlow<List<Int>> = _waterHistory
|
||||||
|
|
||||||
|
private val _dailyGoal = MutableStateFlow(2000) // по умолчанию 2 литра
|
||||||
|
val dailyGoal: StateFlow<Int> = _dailyGoal
|
||||||
|
|
||||||
|
fun setDailyGoal(goal: Int) { _dailyGoal.value = goal }
|
||||||
|
|
||||||
|
fun logWater(volumeMl: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
drinkLogger.logWater(ts = Instant.now(), volumeMl = volumeMl)
|
||||||
|
loadHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadHistory() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val history = drinkLogger.getWaterHistory(days = 7)
|
||||||
|
_waterHistory.value = history
|
||||||
|
_waterToday.value = history.lastOrNull() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: прогресс-бар
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.health
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WeightScreen(onBack: () -> Unit, viewModel: WeightViewModel = hiltViewModel()) {
|
||||||
|
val weightToday by viewModel.weightToday.collectAsState()
|
||||||
|
val weightHistory by viewModel.weightHistory.collectAsState()
|
||||||
|
val (inputWeight, setInputWeight) = remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { viewModel.loadHistory() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(title = { Text("Контроль веса") }, navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = null) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||||
|
Text("Последний вес: ${weightToday?.let { String.format("%.1f", it) } ?: "—"} кг", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Row(Modifier.padding(vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = inputWeight,
|
||||||
|
onValueChange = setInputWeight,
|
||||||
|
label = { Text("Новый вес (кг)") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Button(onClick = {
|
||||||
|
inputWeight.toFloatOrNull()?.let {
|
||||||
|
viewModel.addWeight(it)
|
||||||
|
setInputWeight("")
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("Сохранить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text("График веса за неделю", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Canvas(modifier = Modifier.fillMaxWidth().height(120.dp)) {
|
||||||
|
val max = (weightHistory.maxOfOrNull { it.second } ?: 1f)
|
||||||
|
val min = (weightHistory.minOfOrNull { it.second } ?: 0f)
|
||||||
|
val barWidth = size.width / (weightHistory.size.coerceAtLeast(1))
|
||||||
|
weightHistory.forEachIndexed { i, pair ->
|
||||||
|
val v = pair.second
|
||||||
|
val norm = if (max > min) (v - min) / (max - min) else 0.5f
|
||||||
|
drawRect(
|
||||||
|
color = Color(0xFFAB47BC),
|
||||||
|
topLeft = androidx.compose.ui.geometry.Offset(i * barWidth, size.height * (1 - norm)),
|
||||||
|
size = androidx.compose.ui.geometry.Size(barWidth * 0.7f, size.height * norm)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.health
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kr.smartsoltech.wellshe.data.repo.WeightRepository
|
||||||
|
import java.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class WeightViewModel @Inject constructor(
|
||||||
|
private val weightRepository: WeightRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _weightToday = MutableStateFlow<Float?>(null)
|
||||||
|
val weightToday: StateFlow<Float?> = _weightToday
|
||||||
|
|
||||||
|
private val _weightHistory = MutableStateFlow<List<Pair<String, Float>>>(emptyList())
|
||||||
|
val weightHistory: StateFlow<List<Pair<String, Float>>> = _weightHistory
|
||||||
|
|
||||||
|
fun addWeight(kg: Float) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
weightRepository.addWeight(ts = Instant.now(), kg = kg)
|
||||||
|
loadHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadHistory() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val history = weightRepository.getWeightHistory(days = 7)
|
||||||
|
_weightHistory.value = history
|
||||||
|
_weightToday.value = history.lastOrNull()?.second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
app/src/main/java/kr/smartsoltech/wellshe/ui/mood/MoodScreen.kt
Normal file
258
app/src/main/java/kr/smartsoltech/wellshe/ui/mood/MoodScreen.kt
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.mood
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
|
import androidx.compose.material.icons.filled.ModeNight
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.InfoCard
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.StatCard
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.MoodTabColor
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран "Настроение" для отслеживания сна и эмоционального состояния
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MoodScreen(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Статистические карточки
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
StatCard(
|
||||||
|
title = "Сон",
|
||||||
|
value = "7.2 ч",
|
||||||
|
tone = Color(0xFF673AB7), // Фиолетовый для сна
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
StatCard(
|
||||||
|
title = "Стресс",
|
||||||
|
value = "3/10",
|
||||||
|
tone = Color(0xFFE91E63), // Розовый для стресса
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Карточка дневника
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MoodTabColor.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок
|
||||||
|
Text(
|
||||||
|
text = "Дневник",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Содержимое дневника
|
||||||
|
Text(
|
||||||
|
text = "Сегодня было продуктивно, немного тревоги перед встречей. Выполнила все запланированные задачи, чувствую удовлетворение от проделанной работы.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
// Кнопки действий
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TextButton(onClick = { /* TODO */ }) {
|
||||||
|
Text("Редактировать")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = { /* TODO */ }) {
|
||||||
|
Text("Добавить запись")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Карточка сна
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок с иконкой
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ModeNight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF673AB7)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Качество сна",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оценка сна
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text("Продолжительность")
|
||||||
|
Text("7.2 часа", fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text("Качество")
|
||||||
|
Text("Хорошее", fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text("Пробуждения")
|
||||||
|
Text("1 раз", fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка добавления записи
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { /* TODO */ },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Записать сон")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Карточка эмоций
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок с иконкой
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Favorite,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFFE91E63)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Эмоциональное состояние",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текущее настроение
|
||||||
|
Text(
|
||||||
|
text = "Текущее настроение: Спокойствие, удовлетворение",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
// Кнопки эмоций
|
||||||
|
EmojiButtonsRow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Карточка рекомендаций
|
||||||
|
InfoCard(
|
||||||
|
title = "Рекомендации",
|
||||||
|
content = "Стабильный сон и низкий уровень стресса положительно влияют на ваш цикл. Рекомендуется поддерживать текущий режим для гормонального баланса."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строка кнопок с эмодзи для выбора эмоций
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EmojiButtonsRow() {
|
||||||
|
val emojis = listOf("😊", "😌", "🙂", "😐", "😔", "😢", "😡")
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
emojis.forEach { emoji ->
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { /* TODO */ },
|
||||||
|
contentPadding = PaddingValues(12.dp),
|
||||||
|
modifier = Modifier.size(44.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = emoji,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun MoodScreenPreview() {
|
||||||
|
WellSheTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
MoodScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.mood
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class MoodViewModel @Inject constructor() : ViewModel() {
|
||||||
|
// Данные для экрана настроения
|
||||||
|
private val _sleepHours = MutableStateFlow(7.2f)
|
||||||
|
val sleepHours: StateFlow<Float> = _sleepHours.asStateFlow()
|
||||||
|
|
||||||
|
private val _stressLevel = MutableStateFlow(3)
|
||||||
|
val stressLevel: StateFlow<Int> = _stressLevel.asStateFlow()
|
||||||
|
|
||||||
|
private val _journalEntry = MutableStateFlow("Сегодня было продуктивно, немного тревоги перед встречей.")
|
||||||
|
val journalEntry: StateFlow<String> = _journalEntry.asStateFlow()
|
||||||
|
|
||||||
|
fun updateSleepHours(hours: Float) {
|
||||||
|
_sleepHours.value = hours
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateStressLevel(level: Int) {
|
||||||
|
_stressLevel.value = level
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateJournalEntry(entry: String) {
|
||||||
|
_journalEntry.value = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import kr.smartsoltech.wellshe.ui.analytics.AnalyticsScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.body.BodyScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.cycle.CycleScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.mood.MoodScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.profile.ProfileScreen
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppNavGraph(
|
||||||
|
navController: NavHostController,
|
||||||
|
startDestination: String = BottomNavItem.Cycle.route
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startDestination
|
||||||
|
) {
|
||||||
|
composable(BottomNavItem.Cycle.route) {
|
||||||
|
CycleScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(BottomNavItem.Body.route) {
|
||||||
|
BodyScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(BottomNavItem.Mood.route) {
|
||||||
|
MoodScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(BottomNavItem.Analytics.route) {
|
||||||
|
AnalyticsScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(BottomNavItem.Profile.route) {
|
||||||
|
ProfileScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BarChart
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.WaterDrop
|
||||||
|
import androidx.compose.material.icons.filled.WbSunny
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель навигационного элемента для нижней панели навигац<D0B0><D186>и
|
||||||
|
*/
|
||||||
|
sealed class BottomNavItem(
|
||||||
|
val route: String,
|
||||||
|
val title: String,
|
||||||
|
val icon: ImageVector
|
||||||
|
) {
|
||||||
|
data object Cycle : BottomNavItem(
|
||||||
|
route = "cycle",
|
||||||
|
title = "Цикл",
|
||||||
|
icon = Icons.Default.WbSunny
|
||||||
|
)
|
||||||
|
|
||||||
|
data object Body : BottomNavItem(
|
||||||
|
route = "body",
|
||||||
|
title = "Тело",
|
||||||
|
icon = Icons.Default.WaterDrop
|
||||||
|
)
|
||||||
|
|
||||||
|
data object Mood : BottomNavItem(
|
||||||
|
route = "mood",
|
||||||
|
title = "Настроение",
|
||||||
|
icon = Icons.Default.Favorite
|
||||||
|
)
|
||||||
|
|
||||||
|
data object Analytics : BottomNavItem(
|
||||||
|
route = "analytics",
|
||||||
|
title = "Аналитика",
|
||||||
|
icon = Icons.Default.BarChart
|
||||||
|
)
|
||||||
|
|
||||||
|
data object Profile : BottomNavItem(
|
||||||
|
route = "profile",
|
||||||
|
title = "Профиль",
|
||||||
|
icon = Icons.Default.Person
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val items = listOf(Cycle, Body, Mood, Analytics, Profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Analytics
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.WbSunny
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BottomNavigation(
|
||||||
|
navController: NavController,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
NavigationBar(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
tonalElevation = 8.dp
|
||||||
|
) {
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentDestination = navBackStackEntry?.destination
|
||||||
|
|
||||||
|
val items = listOf(
|
||||||
|
BottomNavItem.Cycle,
|
||||||
|
BottomNavItem.Body,
|
||||||
|
BottomNavItem.Mood,
|
||||||
|
BottomNavItem.Analytics,
|
||||||
|
BottomNavItem.Profile
|
||||||
|
)
|
||||||
|
|
||||||
|
items.forEach { item ->
|
||||||
|
val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true
|
||||||
|
|
||||||
|
// Определяем цвет фона для выбранного элемента
|
||||||
|
val backgroundColor = when (item) {
|
||||||
|
BottomNavItem.Cycle -> CycleTabColor
|
||||||
|
BottomNavItem.Body -> BodyTabColor
|
||||||
|
BottomNavItem.Mood -> MoodTabColor
|
||||||
|
BottomNavItem.Analytics -> AnalyticsTabColor
|
||||||
|
BottomNavItem.Profile -> ProfileTabColor
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = item.icon,
|
||||||
|
contentDescription = item.title,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(backgroundColor)
|
||||||
|
.padding(2.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = item.icon,
|
||||||
|
contentDescription = item.title,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = item.title,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
},
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(item.route) {
|
||||||
|
// Pop up to the start destination of the graph to
|
||||||
|
// avoid building up a large stack of destinations
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
// Avoid multiple copies of the same destination when
|
||||||
|
// reselecting the same item
|
||||||
|
launchSingleTop = true
|
||||||
|
// Restore state when reselecting a previously selected item
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
selectedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
package kr.smartsoltech.wellshe.ui.navigation
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
|
||||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import kr.smartsoltech.wellshe.ui.dashboard.DashboardScreen
|
|
||||||
import kr.smartsoltech.wellshe.ui.cycle.CycleScreen
|
|
||||||
import kr.smartsoltech.wellshe.ui.workouts.WorkoutsScreen
|
|
||||||
import kr.smartsoltech.wellshe.ui.profile.ProfileScreen
|
|
||||||
import kr.smartsoltech.wellshe.ui.settings.SettingsScreen
|
|
||||||
import kr.smartsoltech.wellshe.ui.health.HealthOverviewScreen
|
|
||||||
import kr.smartsoltech.wellshe.ui.sleep.SleepTrackingScreen
|
|
||||||
import kr.smartsoltech.wellshe.ui.theme.*
|
|
||||||
|
|
||||||
sealed class Screen(val route: String, val title: String, val icon: ImageVector) {
|
|
||||||
object Dashboard : Screen("dashboard", "Главная", Icons.Default.Home)
|
|
||||||
object Cycle : Screen("cycle", "Цикл", Icons.Default.Favorite)
|
|
||||||
object Workouts : Screen("workouts", "Тренировки", Icons.Default.FitnessCenter)
|
|
||||||
object Health : Screen("health", "Здоровье", Icons.Default.HealthAndSafety)
|
|
||||||
object Profile : Screen("profile", "Профиль", Icons.Default.Person)
|
|
||||||
|
|
||||||
// Дополнительные экраны без навигации в нижнем меню
|
|
||||||
object Settings : Screen("settings", "Настройки", Icons.Default.Settings)
|
|
||||||
object Sleep : Screen("sleep", "Сон", Icons.Default.Bedtime)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun WellSheNavigation() {
|
|
||||||
val navController = rememberNavController()
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
bottomBar = {
|
|
||||||
BottomNavigationBar(
|
|
||||||
navController = navController,
|
|
||||||
onNavigate = { route ->
|
|
||||||
navController.navigate(route) {
|
|
||||||
popUpTo(navController.graph.findStartDestination().id) {
|
|
||||||
saveState = true
|
|
||||||
}
|
|
||||||
launchSingleTop = true
|
|
||||||
restoreState = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { innerPadding ->
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = Screen.Dashboard.route,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
) {
|
|
||||||
composable(Screen.Dashboard.route) {
|
|
||||||
DashboardScreen(
|
|
||||||
onNavigate = { route -> navController.navigate(route) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(Screen.Cycle.route) {
|
|
||||||
CycleScreen(
|
|
||||||
onNavigateBack = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(Screen.Workouts.route) {
|
|
||||||
WorkoutsScreen(
|
|
||||||
onNavigateBack = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(Screen.Health.route) {
|
|
||||||
HealthOverviewScreen(
|
|
||||||
onNavigateBack = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(Screen.Profile.route) {
|
|
||||||
ProfileScreen(
|
|
||||||
onNavigateBack = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(Screen.Settings.route) {
|
|
||||||
SettingsScreen(
|
|
||||||
onNavigateBack = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(Screen.Sleep.route) {
|
|
||||||
SleepTrackingScreen(
|
|
||||||
onBackClick = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BottomNavigationBar(
|
|
||||||
navController: androidx.navigation.NavController,
|
|
||||||
onNavigate: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
|
||||||
val currentDestination = navBackStackEntry?.destination
|
|
||||||
|
|
||||||
NavigationBar(
|
|
||||||
containerColor = NeutralWhite,
|
|
||||||
tonalElevation = 8.dp
|
|
||||||
) {
|
|
||||||
bottomNavItems.forEach { item ->
|
|
||||||
NavigationBarItem(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = item.icon,
|
|
||||||
contentDescription = item.title,
|
|
||||||
tint = if (currentDestination?.hierarchy?.any { it.route == item.route } == true)
|
|
||||||
PrimaryPink else NeutralGray
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text(
|
|
||||||
item.title,
|
|
||||||
color = if (currentDestination?.hierarchy?.any { it.route == item.route } == true)
|
|
||||||
PrimaryPink else NeutralGray
|
|
||||||
)
|
|
||||||
},
|
|
||||||
selected = currentDestination?.hierarchy?.any { it.route == item.route } == true,
|
|
||||||
onClick = {
|
|
||||||
onNavigate(item.route)
|
|
||||||
},
|
|
||||||
colors = NavigationBarItemDefaults.colors(
|
|
||||||
selectedIconColor = PrimaryPink,
|
|
||||||
selectedTextColor = PrimaryPink,
|
|
||||||
indicatorColor = PrimaryPinkLight
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class BottomNavItem(
|
|
||||||
val title: String,
|
|
||||||
val icon: ImageVector,
|
|
||||||
val route: String
|
|
||||||
)
|
|
||||||
|
|
||||||
private val bottomNavItems = listOf(
|
|
||||||
BottomNavItem(
|
|
||||||
title = "Главная",
|
|
||||||
icon = Icons.Default.Home,
|
|
||||||
route = Screen.Dashboard.route
|
|
||||||
),
|
|
||||||
BottomNavItem(
|
|
||||||
title = "Цикл",
|
|
||||||
icon = Icons.Default.CalendarMonth,
|
|
||||||
route = Screen.Cycle.route
|
|
||||||
),
|
|
||||||
BottomNavItem(
|
|
||||||
title = "Здоровье",
|
|
||||||
icon = Icons.Default.Favorite,
|
|
||||||
route = Screen.Health.route
|
|
||||||
),
|
|
||||||
BottomNavItem(
|
|
||||||
title = "Профиль",
|
|
||||||
icon = Icons.Default.Person,
|
|
||||||
route = Screen.Profile.route
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WellSheNavigation() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
BottomNavigation(navController = navController)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
AppNavGraph(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun WellSheNavigationPreview() {
|
||||||
|
WellSheTheme {
|
||||||
|
Surface {
|
||||||
|
WellSheNavigation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,164 +4,355 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import kr.smartsoltech.wellshe.ui.components.InfoCard
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.ToggleRow
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.ProfileTabColor
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран "Профиль" с настройками пользователя
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(
|
fun ProfileScreen(
|
||||||
onNavigateBack: () -> Unit,
|
modifier: Modifier = Modifier
|
||||||
viewModel: ProfileViewModel = hiltViewModel()
|
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Scaffold(
|
// Состояния для различных настроек
|
||||||
topBar = {
|
var units by remember {
|
||||||
TopAppBar(
|
mutableStateOf(
|
||||||
title = { Text("Профиль") },
|
mapOf(
|
||||||
navigationIcon = {
|
"weight" to "кг",
|
||||||
IconButton(onClick = onNavigateBack) {
|
"speed" to "км/ч",
|
||||||
Icon(Icons.Filled.ArrowBack, contentDescription = "Назад")
|
"temp" to "°C"
|
||||||
}
|
)
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
|
||||||
|
var goals by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
mapOf(
|
||||||
|
"water" to 2000
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var privacy by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
mapOf(
|
||||||
|
"biometrics" to true,
|
||||||
|
"analytics" to true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var integrations by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
listOf(
|
||||||
|
Integration("Google Fit", false),
|
||||||
|
Integration("FatSecret Proxy", true),
|
||||||
|
Integration("Wear OS", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(16.dp)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(scrollState),
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
// Заголовок
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ProfileContent(
|
|
||||||
user = uiState.user,
|
|
||||||
onUpdateProfile = { user ->
|
|
||||||
viewModel.updateProfile(user)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
uiState.error?.let { error ->
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = error,
|
text = "Профиль",
|
||||||
modifier = Modifier.padding(16.dp),
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
// Карточка целей и единиц измерения
|
||||||
private fun ProfileContent(
|
Card(
|
||||||
user: kr.smartsoltech.wellshe.domain.model.User,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onUpdateProfile: (kr.smartsoltech.wellshe.domain.model.User) -> Unit
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = ProfileTabColor.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
var name by remember { mutableStateOf(user.name) }
|
|
||||||
var email by remember { mutableStateOf(user.email) }
|
|
||||||
var age by remember { mutableStateOf(user.age.toString()) }
|
|
||||||
var height by remember { mutableStateOf(user.height.toString()) }
|
|
||||||
var weight by remember { mutableStateOf(user.weight.toString()) }
|
|
||||||
|
|
||||||
Card {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Основная информация",
|
text = "Цели и единицы измерения",
|
||||||
style = MaterialTheme.typography.headlineSmall
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
// Цель воды
|
||||||
value = name,
|
Row(
|
||||||
onValueChange = { name = it },
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("Имя") },
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier.fillMaxWidth()
|
verticalAlignment = Alignment.CenterVertically
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = email,
|
|
||||||
onValueChange = { email = it },
|
|
||||||
label = { Text("Email") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = age,
|
|
||||||
onValueChange = { age = it },
|
|
||||||
label = { Text("Возраст") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = height,
|
|
||||||
onValueChange = { height = it },
|
|
||||||
label = { Text("Рост (см)") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = weight,
|
|
||||||
onValueChange = { weight = it },
|
|
||||||
label = { Text("Вес (кг)") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onUpdateProfile(
|
|
||||||
user.copy(
|
|
||||||
name = name,
|
|
||||||
email = email,
|
|
||||||
age = age.toIntOrNull() ?: user.age,
|
|
||||||
height = height.toFloatOrNull() ?: user.height,
|
|
||||||
weight = weight.toFloatOrNull() ?: user.weight
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Цель воды (мл)",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
var waterGoal by remember { mutableStateOf(goals["water"].toString()) }
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = waterGoal,
|
||||||
|
onValueChange = {
|
||||||
|
waterGoal = it
|
||||||
|
goals = goals.toMutableMap().apply {
|
||||||
|
this["water"] = waterGoal.toIntOrNull() ?: 2000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.width(100.dp),
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
singleLine = true,
|
||||||
|
colors = TextFieldDefaults.outlinedTextFieldColors(
|
||||||
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Единицы веса
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Вес",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
UnitSelector(
|
||||||
|
options = listOf("кг", "lb"),
|
||||||
|
selectedOption = units["weight"] ?: "кг",
|
||||||
|
onOptionSelected = { units = units.toMutableMap().apply { this["weight"] = it } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Единицы скорости
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Скорость",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
UnitSelector(
|
||||||
|
options = listOf("км/ч", "mph"),
|
||||||
|
selectedOption = units["speed"] ?: "км/ч",
|
||||||
|
onOptionSelected = { units = units.toMutableMap().apply { this["speed"] = it } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Единицы температуры
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Температура",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
UnitSelector(
|
||||||
|
options = listOf("°C", "°F"),
|
||||||
|
selectedOption = units["temp"] ?: "°C",
|
||||||
|
onOptionSelected = { units = units.toMutableMap().apply { this["temp"] = it } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Карточка уведомлений цикла
|
||||||
|
InfoCard(
|
||||||
|
title = "Уведомления цикла",
|
||||||
|
content = "Управляются в разделе Настройки → Цикл."
|
||||||
|
)
|
||||||
|
|
||||||
|
// Карточка приватности и безопасности
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Приватность и безопасность",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
ToggleRow(
|
||||||
|
label = "Блокировка по биометрии",
|
||||||
|
checked = privacy["biometrics"] ?: false,
|
||||||
|
onCheckedChange = { privacy = privacy.toMutableMap().apply { this["biometrics"] = it } }
|
||||||
|
)
|
||||||
|
|
||||||
|
ToggleRow(
|
||||||
|
label = "Анонимная аналитика",
|
||||||
|
checked = privacy["analytics"] ?: false,
|
||||||
|
onCheckedChange = { privacy = privacy.toMutableMap().apply { this["analytics"] = it } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Карточка интеграций
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Интеграции",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
integrations.forEachIndexed { index, integration ->
|
||||||
|
ToggleRow(
|
||||||
|
label = integration.title,
|
||||||
|
checked = integration.enabled,
|
||||||
|
onCheckedChange = { isEnabled ->
|
||||||
|
integrations = integrations.toMutableList().apply {
|
||||||
|
this[index] = Integration(integration.title, isEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопки действий
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { /* TODO */ },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Сохранить")
|
Text("Сохранить")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { /* TODO */ },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Settings,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Экспорт настроек")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Card {
|
/**
|
||||||
Column(
|
* Селектор единиц измерения
|
||||||
modifier = Modifier.padding(16.dp),
|
*/
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
@Composable
|
||||||
|
fun UnitSelector(
|
||||||
|
options: List<String>,
|
||||||
|
selectedOption: String,
|
||||||
|
onOptionSelected: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
Text(
|
var expanded by remember { mutableStateOf(false) }
|
||||||
text = "Цели",
|
|
||||||
style = MaterialTheme.typography.headlineSmall
|
Box {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { expanded = true },
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(selectedOption)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
options.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(option) },
|
||||||
|
onClick = {
|
||||||
|
onOptionSelected(option)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные для интеграций
|
||||||
|
*/
|
||||||
|
data class Integration(
|
||||||
|
val title: String,
|
||||||
|
val enabled: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
Text("Вода: ${user.dailyWaterGoal} л/день")
|
@Preview(showBackground = true)
|
||||||
Text("Шаги: ${user.dailyStepsGoal} шагов/день")
|
@Composable
|
||||||
Text("Калории: ${user.dailyCaloriesGoal} ккал/день")
|
fun ProfileScreenPreview() {
|
||||||
Text("Сон: ${user.dailySleepGoal} часов/день")
|
WellSheTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
ProfileScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -14,7 +16,10 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import kr.smartsoltech.wellshe.ui.theme.*
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
@@ -197,30 +202,30 @@ private fun CycleSettingsCard(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
title = "Настройки цикла",
|
title = "Настройки менструального цикла",
|
||||||
icon = Icons.Default.CalendarMonth,
|
icon = Icons.Default.CalendarMonth,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
SettingsSliderItem(
|
SettingsNumberField(
|
||||||
title = "Длина цикла",
|
title = "Длина цикла",
|
||||||
subtitle = "Количество дней в цикле",
|
subtitle = "Количество дней в цикле (21-35)",
|
||||||
value = cycleLength.toFloat(),
|
value = cycleLength,
|
||||||
valueRange = 21f..35f,
|
onValueChange = { value ->
|
||||||
steps = 13,
|
if (value in 21..35) onCycleLengthChange(value)
|
||||||
onValueChange = { onCycleLengthChange(it.toInt()) },
|
},
|
||||||
valueFormatter = { "${it.toInt()} дней" }
|
suffix = "дней"
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
SettingsSliderItem(
|
SettingsNumberField(
|
||||||
title = "Длина менструации",
|
title = "Длина менструации",
|
||||||
subtitle = "Количество дней менструации",
|
subtitle = "Количество дней менструации (3-8)",
|
||||||
value = periodLength.toFloat(),
|
value = periodLength,
|
||||||
valueRange = 3f..8f,
|
onValueChange = { value ->
|
||||||
steps = 4,
|
if (value in 3..8) onPeriodLengthChange(value)
|
||||||
onValueChange = { onPeriodLengthChange(it.toInt()) },
|
},
|
||||||
valueFormatter = { "${it.toInt()} дней" }
|
suffix = "дней"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,38 +245,38 @@ private fun GoalsSettingsCard(
|
|||||||
icon = Icons.Default.TrackChanges,
|
icon = Icons.Default.TrackChanges,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
SettingsSliderItem(
|
SettingsDecimalField(
|
||||||
title = "Цель по воде",
|
title = "Цель по воде",
|
||||||
subtitle = "Количество воды в день",
|
subtitle = "Количество воды в день (1.5-4.0 л)",
|
||||||
value = waterGoal,
|
value = waterGoal,
|
||||||
valueRange = 1.5f..4.0f,
|
onValueChange = { value ->
|
||||||
steps = 24,
|
if (value in 1.5f..4.0f) onWaterGoalChange(value)
|
||||||
onValueChange = onWaterGoalChange,
|
},
|
||||||
valueFormatter = { "%.1f л".format(it) }
|
suffix = "л"
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
SettingsSliderItem(
|
SettingsNumberField(
|
||||||
title = "Цель по шагам",
|
title = "Цель по шагам",
|
||||||
subtitle = "Количество шагов в день",
|
subtitle = "Количество шагов в день (5000-20000)",
|
||||||
value = stepsGoal.toFloat(),
|
value = stepsGoal,
|
||||||
valueRange = 5000f..20000f,
|
onValueChange = { value ->
|
||||||
steps = 29,
|
if (value in 5000..20000) onStepsGoalChange(value)
|
||||||
onValueChange = { onStepsGoalChange(it.toInt()) },
|
},
|
||||||
valueFormatter = { "${(it/1000).toInt()}k шагов" }
|
suffix = "шагов"
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
SettingsSliderItem(
|
SettingsDecimalField(
|
||||||
title = "Цель по сну",
|
title = "Цель по сну",
|
||||||
subtitle = "Количество часов сна",
|
subtitle = "Количество часов сна (6-10 часов)",
|
||||||
value = sleepGoal,
|
value = sleepGoal,
|
||||||
valueRange = 6.0f..10.0f,
|
onValueChange = { value ->
|
||||||
steps = 7,
|
if (value in 6.0f..10.0f) onSleepGoalChange(value)
|
||||||
onValueChange = onSleepGoalChange,
|
},
|
||||||
valueFormatter = { "%.1f часов".format(it) }
|
suffix = "часов"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,15 +332,17 @@ private fun DataManagementCard(
|
|||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
title = "Очистить все данные",
|
title = "Очистить данные",
|
||||||
subtitle = "Удалить все сохраненные данные",
|
subtitle = "Удалить все данные приложения",
|
||||||
icon = Icons.Default.DeleteForever,
|
icon = Icons.Default.DeleteForever,
|
||||||
onClick = onClearData,
|
onClick = onClearData,
|
||||||
isDestructive = true
|
textColor = Color(0xFFFF5722)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Компоненты настроек
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsCard(
|
private fun SettingsCard(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -356,7 +363,7 @@ private fun SettingsCard(
|
|||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
modifier = Modifier.padding(bottom = 20.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
@@ -369,7 +376,7 @@ private fun SettingsCard(
|
|||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleLarge.copy(
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = TextPrimary
|
color = TextPrimary
|
||||||
)
|
)
|
||||||
@@ -405,7 +412,7 @@ private fun SettingsSwitchItem(
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
color = TextSecondary
|
color = TextSecondary
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -415,35 +422,26 @@ private fun SettingsSwitchItem(
|
|||||||
checked = isChecked,
|
checked = isChecked,
|
||||||
onCheckedChange = onCheckedChange,
|
onCheckedChange = onCheckedChange,
|
||||||
colors = SwitchDefaults.colors(
|
colors = SwitchDefaults.colors(
|
||||||
checkedThumbColor = NeutralWhite,
|
checkedThumbColor = PrimaryPink,
|
||||||
checkedTrackColor = PrimaryPink,
|
checkedTrackColor = PrimaryPinkLight
|
||||||
uncheckedThumbColor = NeutralWhite,
|
|
||||||
uncheckedTrackColor = Color.Gray.copy(alpha = 0.3f)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsSliderItem(
|
private fun SettingsNumberField(
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
value: Float,
|
value: Int,
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
onValueChange: (Int) -> Unit,
|
||||||
steps: Int,
|
suffix: String,
|
||||||
onValueChange: (Float) -> Unit,
|
|
||||||
valueFormatter: (Float) -> String,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Column(
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
modifier = modifier.fillMaxWidth()
|
var textValue by remember(value) { mutableStateOf(value.toString()) }
|
||||||
) {
|
|
||||||
Row(
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
@@ -453,32 +451,79 @@ private fun SettingsSliderItem(
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
|
||||||
color = TextSecondary
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = valueFormatter(value),
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontWeight = FontWeight.Bold,
|
color = TextSecondary
|
||||||
color = PrimaryPink
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = textValue,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
textValue = newValue
|
||||||
|
newValue.toIntOrNull()?.let { intValue ->
|
||||||
|
onValueChange(intValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
suffix = { Text(suffix) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.width(120.dp),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = { keyboardController?.hide() }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
@Composable
|
||||||
|
private fun SettingsDecimalField(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
value: Float,
|
||||||
|
onValueChange: (Float) -> Unit,
|
||||||
|
suffix: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
var textValue by remember(value) { mutableStateOf("%.1f".format(value)) }
|
||||||
|
|
||||||
Slider(
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
value = value,
|
Text(
|
||||||
onValueChange = onValueChange,
|
text = title,
|
||||||
valueRange = valueRange,
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
steps = steps,
|
fontWeight = FontWeight.Medium,
|
||||||
colors = SliderDefaults.colors(
|
color = TextPrimary
|
||||||
thumbColor = PrimaryPink,
|
)
|
||||||
activeTrackColor = PrimaryPink,
|
)
|
||||||
inactiveTrackColor = Color.Gray.copy(alpha = 0.3f)
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = textValue,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
textValue = newValue
|
||||||
|
newValue.toFloatOrNull()?.let { floatValue ->
|
||||||
|
onValueChange(floatValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
suffix = { Text(suffix) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.width(120.dp),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Decimal,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = { keyboardController?.hide() }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -490,7 +535,7 @@ private fun SettingsActionItem(
|
|||||||
subtitle: String,
|
subtitle: String,
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
isDestructive: Boolean = false,
|
textColor: Color = TextPrimary,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -503,7 +548,7 @@ private fun SettingsActionItem(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (isDestructive) Color(0xFFE53E3E) else PrimaryPink,
|
tint = textColor,
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -516,22 +561,21 @@ private fun SettingsActionItem(
|
|||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = if (isDestructive) Color(0xFFE53E3E) else TextPrimary
|
color = textColor
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
color = if (isDestructive) Color(0xFFE53E3E).copy(alpha = 0.7f) else TextSecondary
|
color = if (textColor == TextPrimary) TextSecondary else textColor.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ChevronRight,
|
imageVector = Icons.Default.ChevronRight,
|
||||||
contentDescription = "Выполнить",
|
contentDescription = null,
|
||||||
tint = TextSecondary,
|
tint = NeutralGray
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kr.smartsoltech.wellshe.data.repository.WellSheRepository
|
import kr.smartsoltech.wellshe.data.repository.WellSheRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -37,7 +38,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
repository.getSettings().collect { settings ->
|
repository.getSettings().catch { e ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message
|
||||||
|
)
|
||||||
|
}.collect { settings ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isWaterReminderEnabled = settings.isWaterReminderEnabled,
|
isWaterReminderEnabled = settings.isWaterReminderEnabled,
|
||||||
isCycleReminderEnabled = settings.isCycleReminderEnabled,
|
isCycleReminderEnabled = settings.isCycleReminderEnabled,
|
||||||
@@ -60,6 +66,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Уведомления
|
||||||
fun toggleWaterReminder(enabled: Boolean) {
|
fun toggleWaterReminder(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -93,7 +100,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Настройки цикла
|
||||||
fun updateCycleLength(length: Int) {
|
fun updateCycleLength(length: Int) {
|
||||||
|
if (length in 21..35) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updateCycleLength(length)
|
repository.updateCycleLength(length)
|
||||||
@@ -103,8 +112,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updatePeriodLength(length: Int) {
|
fun updatePeriodLength(length: Int) {
|
||||||
|
if (length in 3..8) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updatePeriodLength(length)
|
repository.updatePeriodLength(length)
|
||||||
@@ -114,8 +125,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цели
|
||||||
fun updateWaterGoal(goal: Float) {
|
fun updateWaterGoal(goal: Float) {
|
||||||
|
if (goal in 1.5f..4.0f) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updateWaterGoal(goal)
|
repository.updateWaterGoal(goal)
|
||||||
@@ -125,8 +139,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateStepsGoal(goal: Int) {
|
fun updateStepsGoal(goal: Int) {
|
||||||
|
if (goal in 5000..20000) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updateStepsGoal(goal)
|
repository.updateStepsGoal(goal)
|
||||||
@@ -136,8 +152,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateSleepGoal(goal: Float) {
|
fun updateSleepGoal(goal: Float) {
|
||||||
|
if (goal in 6.0f..10.0f) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updateSleepGoal(goal)
|
repository.updateSleepGoal(goal)
|
||||||
@@ -147,7 +165,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Внешний вид
|
||||||
fun toggleTheme(isDark: Boolean) {
|
fun toggleTheme(isDark: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -159,11 +179,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Управление данными
|
||||||
fun exportData() {
|
fun exportData() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.exportUserData()
|
repository.exportUserData()
|
||||||
// TODO: Показать уведомление об успешном экспорте
|
// Показать сообщение об успехе
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
@@ -174,7 +195,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.importUserData()
|
repository.importUserData()
|
||||||
loadSettings() // Перезагружаем настройки
|
loadSettings() // Перезагрузить настройки
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
@@ -185,7 +206,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.clearAllUserData()
|
repository.clearAllUserData()
|
||||||
loadSettings() // Перезагружаем настройки
|
// Сбросить на дефолтные значения
|
||||||
|
_uiState.value = SettingsUiState()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,3 +49,21 @@ val ChartPurple = Color(0xFF9C27B0)
|
|||||||
val ChartGreen = Color(0xFF4CAF50)
|
val ChartGreen = Color(0xFF4CAF50)
|
||||||
val ChartOrange = Color(0xFFFF9800)
|
val ChartOrange = Color(0xFFFF9800)
|
||||||
val ChartRed = Color(0xFFF44336)
|
val ChartRed = Color(0xFFF44336)
|
||||||
|
|
||||||
|
// Цвета для фаз цикла
|
||||||
|
val PeriodColor = Color(0xFFFFD6E0) // Розовый для менструации
|
||||||
|
val FertileColor = Color(0xFFD6F5E3) // Зелёный для фертильного окна
|
||||||
|
val PmsColor = Color(0xFFFFF2CC) // Янтарный для ПМС
|
||||||
|
val OvulationBorder = Color(0xFF6366F1) // Индиго для обводки дня овуляции
|
||||||
|
|
||||||
|
// Цвета для вкладок
|
||||||
|
val CycleTabColor = Color(0xFFFFF8E1) // Янтарный для вкладки Цикл
|
||||||
|
val BodyTabColor = Color(0xFFE3F2FD) // Синий для вкладки Тело
|
||||||
|
val MoodTabColor = Color(0xFFFCE4EC) // Розовый для вкладки Настроение
|
||||||
|
val AnalyticsTabColor = Color(0xFFE0F2F1) // Изумрудный для вкладки Аналитика
|
||||||
|
val ProfileTabColor = Color(0xFFF5F5F5) // Серый для вкладки Профиль
|
||||||
|
|
||||||
|
// Акцентные цвета для разделов
|
||||||
|
val WaterColor = Color(0xFF2196F3) // Синий для воды
|
||||||
|
val WeightColor = Color(0xFFEC407A) // Розовый для веса
|
||||||
|
val ActivityColor = Color(0xFF4CAF50) // Зелёный для активности
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Этот файл больше не используется - все цвета перенесены в Color.kt
|
||||||
|
// Файл оставлен только для обратной совместимости и будет удален в будущих версиях
|
||||||
|
@Deprecated("Используйте цвета из Color.kt вместо этого файла")
|
||||||
|
object CustomColorsDeprecated {
|
||||||
|
// Пустой объект для предотвращения ошибок при компиляции
|
||||||
|
}
|
||||||
13
app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Shape.kt
Normal file
13
app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Shape.kt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
// Формы для Material3 компонентов
|
||||||
|
val Shapes = Shapes(
|
||||||
|
small = RoundedCornerShape(12.dp),
|
||||||
|
medium = RoundedCornerShape(16.dp),
|
||||||
|
large = RoundedCornerShape(20.dp),
|
||||||
|
extraLarge = RoundedCornerShape(24.dp)
|
||||||
|
)
|
||||||
@@ -10,42 +10,70 @@ import androidx.compose.material3.dynamicLightColorScheme
|
|||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
// Основная светлая цветовая схема
|
||||||
primary = PrimaryPink,
|
private val LightColorScheme = lightColorScheme(
|
||||||
secondary = AccentPurple,
|
primary = Color(0xFF2196F3), // Основной синий цвет приложения
|
||||||
tertiary = SecondaryBlue,
|
onPrimary = Color.White,
|
||||||
background = NeutralBlack,
|
primaryContainer = Color(0xFFE3F2FD), // Светло-синий для карточек
|
||||||
surface = NeutralDarkGray,
|
onPrimaryContainer = Color(0xFF0D47A1),
|
||||||
onPrimary = NeutralWhite,
|
|
||||||
onSecondary = NeutralWhite,
|
secondary = Color(0xFF4CAF50), // Зеленый для акцентов
|
||||||
onTertiary = NeutralWhite,
|
onSecondary = Color.White,
|
||||||
onBackground = NeutralWhite,
|
secondaryContainer = Color(0xFFE8F5E9),
|
||||||
onSurface = NeutralWhite,
|
onSecondaryContainer = Color(0xFF1B5E20),
|
||||||
|
|
||||||
|
tertiary = Color(0xFFE91E63), // Розовый для женских элементов
|
||||||
|
onTertiary = Color.White,
|
||||||
|
tertiaryContainer = Color(0xFFFCE4EC),
|
||||||
|
onTertiaryContainer = Color(0xFF880E4F),
|
||||||
|
|
||||||
|
background = Color.White,
|
||||||
|
onBackground = Color(0xFF121212),
|
||||||
|
surface = Color(0xFFF5F5F5),
|
||||||
|
onSurface = Color(0xFF121212),
|
||||||
|
|
||||||
|
surfaceVariant = Color(0xFFEEEEEE), // Светло-серый для карточек
|
||||||
|
onSurfaceVariant = Color(0xFF616161),
|
||||||
|
outline = Color(0xFFBDBDBD)
|
||||||
)
|
)
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
// Темная цветовая схема
|
||||||
primary = PrimaryPink,
|
private val DarkColorScheme = darkColorScheme(
|
||||||
secondary = AccentPurple,
|
primary = Color(0xFF90CAF9),
|
||||||
tertiary = SecondaryBlue,
|
onPrimary = Color(0xFF0D47A1),
|
||||||
background = NeutralWhite,
|
primaryContainer = Color(0xFF1565C0),
|
||||||
surface = NeutralLightGray,
|
onPrimaryContainer = Color(0xFFE3F2FD),
|
||||||
onPrimary = NeutralWhite,
|
|
||||||
onSecondary = NeutralWhite,
|
secondary = Color(0xFFA5D6A7),
|
||||||
onTertiary = NeutralWhite,
|
onSecondary = Color(0xFF1B5E20),
|
||||||
onBackground = NeutralDarkGray,
|
secondaryContainer = Color(0xFF2E7D32),
|
||||||
onSurface = NeutralDarkGray,
|
onSecondaryContainer = Color(0xFFE8F5E9),
|
||||||
|
|
||||||
|
tertiary = Color(0xFFF48FB1),
|
||||||
|
onTertiary = Color(0xFF880E4F),
|
||||||
|
tertiaryContainer = Color(0xFFC2185B),
|
||||||
|
onTertiaryContainer = Color(0xFFFCE4EC),
|
||||||
|
|
||||||
|
background = Color(0xFF121212),
|
||||||
|
onBackground = Color.White,
|
||||||
|
surface = Color(0xFF212121),
|
||||||
|
onSurface = Color.White,
|
||||||
|
|
||||||
|
surfaceVariant = Color(0xFF303030),
|
||||||
|
onSurfaceVariant = Color(0xFFEEEEEE),
|
||||||
|
outline = Color(0xFF757575)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WellSheTheme(
|
fun WellSheTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
// Dynamic color is available on Android 12+
|
dynamicColor: Boolean = false,
|
||||||
dynamicColor: Boolean = true,
|
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
@@ -53,22 +81,23 @@ fun WellSheTheme(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
darkTheme -> DarkColorScheme
|
darkTheme -> DarkColorScheme
|
||||||
else -> LightColorScheme
|
else -> LightColorScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as Activity).window
|
val window = (view.context as Activity).window
|
||||||
window.statusBarColor = colorScheme.primary.toArgb()
|
window.statusBarColor = colorScheme.background.toArgb() // Прозрачный статусбар
|
||||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
|
shapes = Shapes,
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,48 +6,85 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
// Set of Material typography styles to start with
|
// Типографика в соответствии с Material 3 и дизайном веб-прототипа
|
||||||
val Typography = Typography(
|
val Typography = Typography(
|
||||||
bodyLarge = TextStyle(
|
// Заголовки
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 24.sp,
|
|
||||||
letterSpacing = 0.5.sp
|
|
||||||
),
|
|
||||||
titleLarge = TextStyle(
|
titleLarge = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 22.sp,
|
fontSize = 22.sp,
|
||||||
lineHeight = 28.sp,
|
lineHeight = 28.sp,
|
||||||
letterSpacing = 0.sp
|
letterSpacing = 0.sp
|
||||||
),
|
),
|
||||||
|
titleMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.15.sp
|
||||||
|
),
|
||||||
|
titleSmall = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 22.sp,
|
||||||
|
letterSpacing = 0.1.sp
|
||||||
|
),
|
||||||
|
|
||||||
|
// Основной текст
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.15.sp
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.25.sp
|
||||||
|
),
|
||||||
|
bodySmall = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.4.sp
|
||||||
|
),
|
||||||
|
|
||||||
|
// Метки
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.1.sp
|
||||||
|
),
|
||||||
|
labelMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
),
|
||||||
labelSmall = TextStyle(
|
labelSmall = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
lineHeight = 16.sp,
|
lineHeight = 16.sp,
|
||||||
letterSpacing = 0.5.sp
|
letterSpacing = 0.5.sp
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Заголовки дисплейные
|
||||||
headlineLarge = TextStyle(
|
headlineLarge = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 32.sp,
|
fontSize = 32.sp,
|
||||||
lineHeight = 40.sp,
|
lineHeight = 40.sp,
|
||||||
letterSpacing = 0.sp
|
letterSpacing = 0.sp
|
||||||
),
|
),
|
||||||
headlineMedium = TextStyle(
|
headlineMedium = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 28.sp,
|
fontSize = 28.sp,
|
||||||
lineHeight = 36.sp,
|
lineHeight = 36.sp,
|
||||||
letterSpacing = 0.sp
|
letterSpacing = 0.sp
|
||||||
),
|
),
|
||||||
bodyMedium = TextStyle(
|
headlineSmall = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontWeight = FontWeight.Normal,
|
fontSize = 24.sp,
|
||||||
fontSize = 14.sp,
|
lineHeight = 32.sp,
|
||||||
lineHeight = 20.sp,
|
letterSpacing = 0.sp
|
||||||
letterSpacing = 0.25.sp
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package kr.smartsoltech.wellshe.workers
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.work.*
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kr.smartsoltech.wellshe.MainActivity
|
||||||
|
import kr.smartsoltech.wellshe.R
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleForecastEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сервис для управления уведомлениями, связанными с менструальным циклом
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class CycleNotificationManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val workManager: WorkManager
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
// Изменено с private на internal для доступа из внутреннего класса
|
||||||
|
internal const val CHANNEL_ID_CYCLE = "cycle_notifications"
|
||||||
|
private const val CHANNEL_NAME_CYCLE = "Уведомления цикла"
|
||||||
|
|
||||||
|
const val WORK_TAG_PERIOD = "period_notification"
|
||||||
|
const val WORK_TAG_OVULATION = "ovulation_notification"
|
||||||
|
const val WORK_TAG_PMS = "pms_notification"
|
||||||
|
const val WORK_TAG_DEVIATION = "deviation_notification"
|
||||||
|
|
||||||
|
const val EXTRA_NOTIFICATION_TYPE = "notification_type"
|
||||||
|
const val TYPE_PERIOD = "period"
|
||||||
|
const val TYPE_OVULATION = "ovulation"
|
||||||
|
const val TYPE_PMS = "pms"
|
||||||
|
const val TYPE_DEVIATION = "deviation"
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID_CYCLE,
|
||||||
|
CHANNEL_NAME_CYCLE,
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "Уведомления о менструальном цикле"
|
||||||
|
enableLights(true)
|
||||||
|
enableVibration(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Планирует все уведомления на основе текущих прогнозов и настроек
|
||||||
|
*/
|
||||||
|
fun scheduleAllNotifications(forecast: CycleForecastEntity, settings: CycleSettingsEntity) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
// Отменяем все предыдущие запланированные уведомления
|
||||||
|
cancelAllNotifications()
|
||||||
|
|
||||||
|
// Планируем новые уведомления только если прогнозы надежны и даты не null
|
||||||
|
if (forecast.isReliable) {
|
||||||
|
forecast.nextPeriodStart?.let { nextPeriod ->
|
||||||
|
schedulePeriodNotification(nextPeriod, settings.periodReminderDaysBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
forecast.nextOvulation?.let { ovulation ->
|
||||||
|
scheduleOvulationNotification(ovulation, settings.ovulationReminderDaysBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
forecast.pmsStart?.let { pms ->
|
||||||
|
schedulePmsNotification(pms)
|
||||||
|
}
|
||||||
|
|
||||||
|
forecast.nextPeriodStart?.let { nextPeriod ->
|
||||||
|
scheduleDeviationCheck(nextPeriod, settings.deviationAlertDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Планирует уведомление о предстоящей менструации
|
||||||
|
*/
|
||||||
|
private fun schedulePeriodNotification(periodDate: LocalDate, daysBefore: Int) {
|
||||||
|
val notificationDate = periodDate.minusDays(daysBefore.toLong())
|
||||||
|
if (notificationDate.isBefore(LocalDate.now())) return
|
||||||
|
|
||||||
|
val data = workDataOf(EXTRA_NOTIFICATION_TYPE to TYPE_PERIOD)
|
||||||
|
scheduleNotification(WORK_TAG_PERIOD, notificationDate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Планирует уведомление о предстоящей овуляции
|
||||||
|
*/
|
||||||
|
private fun scheduleOvulationNotification(ovulationDate: LocalDate, daysBefore: Int) {
|
||||||
|
val notificationDate = ovulationDate.minusDays(daysBefore.toLong())
|
||||||
|
if (notificationDate.isBefore(LocalDate.now())) return
|
||||||
|
|
||||||
|
val data = workDataOf(EXTRA_NOTIFICATION_TYPE to TYPE_OVULATION)
|
||||||
|
scheduleNotification(WORK_TAG_OVULATION, notificationDate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Планирует уведомление о начале ПМС
|
||||||
|
*/
|
||||||
|
private fun schedulePmsNotification(pmsStartDate: LocalDate) {
|
||||||
|
if (pmsStartDate.isBefore(LocalDate.now())) return
|
||||||
|
|
||||||
|
val data = workDataOf(EXTRA_NOTIFICATION_TYPE to TYPE_PMS)
|
||||||
|
scheduleNotification(WORK_TAG_PMS, pmsStartDate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Планирует проверку на отклонение (если менструация не началась в ожидаемый день)
|
||||||
|
*/
|
||||||
|
private fun scheduleDeviationCheck(expectedPeriodDate: LocalDate, deviationDays: Int) {
|
||||||
|
val checkDate = expectedPeriodDate.plusDays(deviationDays.toLong())
|
||||||
|
|
||||||
|
val data = workDataOf(EXTRA_NOTIFICATION_TYPE to TYPE_DEVIATION)
|
||||||
|
scheduleNotification(WORK_TAG_DEVIATION, checkDate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Планирует отложенную работу для показа уведомления в указанную дату
|
||||||
|
*/
|
||||||
|
private fun scheduleNotification(tag: String, date: LocalDate, data: Data) {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val delayDays = Duration.between(
|
||||||
|
today.atStartOfDay(),
|
||||||
|
date.atStartOfDay()
|
||||||
|
).toDays()
|
||||||
|
|
||||||
|
// Если дата уже прошла, не планируем уведомление
|
||||||
|
if (delayDays < 0) return
|
||||||
|
|
||||||
|
val notificationWork = OneTimeWorkRequestBuilder<CycleNotificationWorker>()
|
||||||
|
.setInitialDelay(delayDays, TimeUnit.DAYS)
|
||||||
|
.setInputData(data)
|
||||||
|
.addTag(tag)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniqueWork(
|
||||||
|
tag,
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
notificationWork
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отменяет все запланированные уведомления о цикле
|
||||||
|
*/
|
||||||
|
fun cancelAllNotifications() {
|
||||||
|
workManager.cancelAllWorkByTag(WORK_TAG_PERIOD)
|
||||||
|
workManager.cancelAllWorkByTag(WORK_TAG_OVULATION)
|
||||||
|
workManager.cancelAllWorkByTag(WORK_TAG_PMS)
|
||||||
|
workManager.cancelAllWorkByTag(WORK_TAG_DEVIATION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worker для показа уведомлений о цикле
|
||||||
|
*/
|
||||||
|
class CycleNotificationWorker(
|
||||||
|
context: Context,
|
||||||
|
params: WorkerParameters
|
||||||
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val notificationType = inputData.getString(CycleNotificationManager.EXTRA_NOTIFICATION_TYPE)
|
||||||
|
?: return Result.failure()
|
||||||
|
|
||||||
|
showNotification(notificationType)
|
||||||
|
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNotification(type: String) {
|
||||||
|
val (title, message, id) = when (type) {
|
||||||
|
CycleNotificationManager.TYPE_PERIOD -> Triple(
|
||||||
|
"Скоро начнётся менструация",
|
||||||
|
"Подготовьтесь к началу менструации через несколько дней",
|
||||||
|
1001
|
||||||
|
)
|
||||||
|
CycleNotificationManager.TYPE_OVULATION -> Triple(
|
||||||
|
"Приближается овуляция",
|
||||||
|
"Через несколько дней ожидается овуляция",
|
||||||
|
1002
|
||||||
|
)
|
||||||
|
CycleNotificationManager.TYPE_PMS -> Triple(
|
||||||
|
"Вероятно начало ПМС",
|
||||||
|
"Обратите внимание на ваше самочувствие в эти дни",
|
||||||
|
1003
|
||||||
|
)
|
||||||
|
CycleNotificationManager.TYPE_DEVIATION -> Triple(
|
||||||
|
"Отклонение от прогноза",
|
||||||
|
"Менструация не началась в ожидаемый период. Возможно, стоит обновить данные",
|
||||||
|
1004
|
||||||
|
)
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(applicationContext, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
putExtra("destination", "cycle")
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CycleNotificationManager.CHANNEL_ID_CYCLE)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification) // Требуется добавить иконку
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(message)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.notify(id, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/src/main/res/drawable/ic_notification.xml
Normal file
11
app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M9,11L7,11L7,13L9,13L9,11ZM13,11L11,11L11,13L13,13L13,11ZM17,11L15,11L15,13L17,13L17,11ZM19,4L18,4L18,2L16,2L16,4L8,4L8,2L6,2L6,4L5,4C3.89,4 3.01,4.9 3.01,6L3,20C3,21.1 3.89,22 5,22L19,22C20.1,22 21,21.1 21,20L21,6C21,4.9 20.1,4 19,4ZM19,20L5,20L5,9L19,9L19,20Z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -1,18 +1,84 @@
|
|||||||
package kr.smartsoltech.wellshe.domain.analytics
|
package kr.smartsoltech.wellshe.domain.analytics
|
||||||
|
|
||||||
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
import kr.smartsoltech.wellshe.data.entity.CycleStatsEntity
|
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
class CycleAnalyticsTest {
|
class CycleAnalyticsTest {
|
||||||
@Test
|
@Test
|
||||||
fun testForecastHighConfidence() {
|
fun testForecastHighConfidence() {
|
||||||
val periods = listOf(CyclePeriodEntity(id = 0, startTs = 1_700_000_000_000, endTs = 1_700_000_000_000 + 5 * 24 * 60 * 60 * 1000, notes = ""))
|
val periods = listOf(
|
||||||
val stats = CycleStatsEntity(avgCycle = 28, variance = 1, lutealLen = 14)
|
CyclePeriodEntity(
|
||||||
val forecast = CycleAnalytics.forecast(periods, stats)
|
id = 0,
|
||||||
|
startDate = LocalDate.now().minusDays(28),
|
||||||
|
endDate = LocalDate.now().minusDays(23),
|
||||||
|
cycleLength = 28,
|
||||||
|
flow = "medium",
|
||||||
|
symptoms = emptyList(),
|
||||||
|
mood = "neutral"
|
||||||
|
),
|
||||||
|
CyclePeriodEntity(
|
||||||
|
id = 1,
|
||||||
|
startDate = LocalDate.now().minusDays(56),
|
||||||
|
endDate = LocalDate.now().minusDays(51),
|
||||||
|
cycleLength = 28,
|
||||||
|
flow = "medium",
|
||||||
|
symptoms = emptyList(),
|
||||||
|
mood = "neutral"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val forecast = CycleAnalytics.forecast(periods, null)
|
||||||
assertEquals("высокая", forecast.confidence)
|
assertEquals("высокая", forecast.confidence)
|
||||||
assertNotNull(forecast.nextStart)
|
assertNotNull(forecast.nextStart)
|
||||||
assertNotNull(forecast.fertileWindow)
|
assertNotNull(forecast.fertileWindow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAnalyzeRegularity() {
|
||||||
|
val regularPeriods = listOf(
|
||||||
|
CyclePeriodEntity(
|
||||||
|
id = 0,
|
||||||
|
startDate = LocalDate.now().minusDays(28),
|
||||||
|
endDate = LocalDate.now().minusDays(23),
|
||||||
|
cycleLength = 28,
|
||||||
|
flow = "medium",
|
||||||
|
symptoms = emptyList(),
|
||||||
|
mood = "neutral"
|
||||||
|
),
|
||||||
|
CyclePeriodEntity(
|
||||||
|
id = 1,
|
||||||
|
startDate = LocalDate.now().minusDays(56),
|
||||||
|
endDate = LocalDate.now().minusDays(51),
|
||||||
|
cycleLength = 28,
|
||||||
|
flow = "medium",
|
||||||
|
symptoms = emptyList(),
|
||||||
|
mood = "neutral"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val regularity = CycleAnalytics.analyzeRegularity(regularPeriods)
|
||||||
|
assertNotNull(regularity)
|
||||||
|
assertTrue(regularity.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testPredictNextPeriods() {
|
||||||
|
val periods = listOf(
|
||||||
|
CyclePeriodEntity(
|
||||||
|
id = 0,
|
||||||
|
startDate = LocalDate.now().minusDays(28),
|
||||||
|
endDate = LocalDate.now().minusDays(23),
|
||||||
|
cycleLength = 28,
|
||||||
|
flow = "medium",
|
||||||
|
symptoms = emptyList(),
|
||||||
|
mood = "neutral"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val predictions = CycleAnalytics.predictNextPeriods(periods, 3)
|
||||||
|
assertEquals(3, predictions.size)
|
||||||
|
assertTrue(!predictions[0].isBefore(LocalDate.now()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,24 @@ class SleepAnalyticsTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testSleepDebt() {
|
fun testSleepDebt() {
|
||||||
val logs = listOf(
|
val logs = listOf(
|
||||||
SleepLogEntity(id = 0, startTs = 1000, endTs = 1000 + 8 * 3600_000, quality = 5),
|
SleepLogEntity(
|
||||||
SleepLogEntity(id = 0, startTs = 2000, endTs = 2000 + 7 * 3600_000, quality = 4)
|
id = 0,
|
||||||
|
date = java.time.LocalDate.now(),
|
||||||
|
bedTime = "22:00",
|
||||||
|
wakeTime = "06:00",
|
||||||
|
duration = 8.0f,
|
||||||
|
quality = "good",
|
||||||
|
notes = ""
|
||||||
|
),
|
||||||
|
SleepLogEntity(
|
||||||
|
id = 0,
|
||||||
|
date = java.time.LocalDate.now().minusDays(1),
|
||||||
|
bedTime = "23:00",
|
||||||
|
wakeTime = "06:00",
|
||||||
|
duration = 7.0f,
|
||||||
|
quality = "normal",
|
||||||
|
notes = ""
|
||||||
|
)
|
||||||
)
|
)
|
||||||
val debt = SleepAnalytics.sleepDebt(logs, 8)
|
val debt = SleepAnalytics.sleepDebt(logs, 8)
|
||||||
assertEquals(1, debt)
|
assertEquals(1, debt)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dependencyResolutionManagement {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url = uri("https://jitpack.io") } // Добавляем репозиторий JitPack
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user