main commit

This commit is contained in:
2025-10-14 20:10:15 +09:00
parent fcd195403e
commit c00924be85
99 changed files with 10569 additions and 1880 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -5,30 +5,79 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters import 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()
} }

View File

@@ -0,0 +1,21 @@
package kr.smartsoltech.wellshe.data
import androidx.room.TypeConverter
import java.time.Instant
class InstantConverter {
@TypeConverter
fun fromTimestamp(value: Long?): Instant? = value?.let { Instant.ofEpochMilli(it) }
@TypeConverter
fun instantToTimestamp(instant: Instant?): Long? = instant?.toEpochMilli()
}
class StringListConverter {
@TypeConverter
fun fromString(value: String?): List<String>? = value?.let {
if (it.isEmpty()) emptyList() else it.split("||")
}
@TypeConverter
fun listToString(list: List<String>?): String = list?.joinToString("||") ?: ""
}

View File

@@ -0,0 +1,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()
)
}
}

View File

@@ -0,0 +1,151 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kr.smartsoltech.wellshe.data.entity.*
import java.time.Instant
import java.time.LocalDate
@Dao
interface NutrientDao {
@Query("SELECT * FROM Nutrient WHERE code = :code LIMIT 1")
suspend fun getByCode(code: String): Nutrient?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(nutrient: Nutrient): Long
@Query("SELECT * FROM Nutrient")
suspend fun getAll(): List<Nutrient>
}
@Dao
interface BeverageDao {
@Query("SELECT * FROM Beverage WHERE id = :id")
suspend fun getById(id: Long): Beverage?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(beverage: Beverage): Long
@Query("SELECT * FROM Beverage WHERE name LIKE :query LIMIT 20")
suspend fun search(query: String): List<Beverage>
}
@Dao
interface BeverageServingDao {
@Query("SELECT * FROM BeverageServing WHERE beverageId = :beverageId")
suspend fun getByBeverage(beverageId: Long): List<BeverageServing>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(serving: BeverageServing): Long
}
@Dao
interface BeverageServingNutrientDao {
@Query("SELECT * FROM BeverageServingNutrient WHERE servingId = :servingId")
suspend fun getByServing(servingId: Long): List<BeverageServingNutrient>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(nutrient: BeverageServingNutrient): Long
}
@Dao
interface WaterLogDao {
@Query("SELECT * FROM water_logs WHERE date = :date ORDER BY timestamp DESC")
suspend fun getWaterLogsForDate(date: LocalDate): List<WaterLogEntity>
@Query("SELECT SUM(amount) FROM water_logs WHERE date = :date")
suspend fun getTotalWaterForDate(date: LocalDate): Int?
@Insert
suspend fun insertWaterLog(waterLog: WaterLogEntity)
@Delete
suspend fun deleteWaterLog(waterLog: WaterLogEntity)
}
@Dao
interface BeverageLogDao {
@Query("SELECT * FROM BeverageLog WHERE ts BETWEEN :from AND :to ORDER BY ts DESC")
suspend fun getLogs(from: Instant, to: Instant): List<BeverageLog>
@Insert
suspend fun insert(log: BeverageLog): Long
}
@Dao
interface BeverageLogNutrientDao {
@Query("SELECT * FROM BeverageLogNutrient WHERE beverageLogId = :beverageLogId")
suspend fun getByLog(beverageLogId: Long): List<BeverageLogNutrient>
@Insert
suspend fun insert(nutrient: BeverageLogNutrient): Long
}
@Dao
interface WeightLogDao {
@Query("SELECT * FROM WeightLog ORDER BY ts DESC LIMIT 1")
suspend fun getLatestWeightKg(): WeightLog?
@Insert
suspend fun insert(log: WeightLog): Long
@Query("SELECT * FROM WeightLog WHERE ts BETWEEN :from AND :to ORDER BY ts DESC")
suspend fun getLogs(from: Instant, to: Instant): List<WeightLog>
}
@Dao
interface ExerciseDao {
@Query("SELECT * FROM Exercise WHERE id = :id")
suspend fun getById(id: Long): Exercise?
@Query("SELECT * FROM Exercise WHERE name LIKE :query LIMIT 20")
suspend fun search(query: String): List<Exercise>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(exercise: Exercise): Long
}
@Dao
interface ExerciseParamDao {
@Query("SELECT * FROM ExerciseParam WHERE exerciseId = :exerciseId")
suspend fun getByExercise(exerciseId: Long): List<ExerciseParam>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(param: ExerciseParam): Long
}
@Dao
interface ExerciseFormulaDao {
@Query("SELECT * FROM ExerciseFormula WHERE exerciseId = :exerciseId")
suspend fun getByExercise(exerciseId: Long): List<ExerciseFormula>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(formula: ExerciseFormula): Long
}
@Dao
interface ExerciseFormulaVarDao {
@Query("SELECT * FROM ExerciseFormulaVar WHERE formulaId = :formulaId")
suspend fun getByFormula(formulaId: Long): List<ExerciseFormulaVar>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(varDef: ExerciseFormulaVar): Long
}
@Dao
interface WorkoutSessionDao {
@Query("SELECT * FROM WorkoutSession WHERE startedAt BETWEEN :from AND :to ORDER BY startedAt DESC")
suspend fun getSessions(from: Instant, to: Instant): List<WorkoutSession>
@Insert
suspend fun insert(session: WorkoutSession): Long
@Query("SELECT * FROM WorkoutSession WHERE id = :id")
suspend fun getById(id: Long): WorkoutSession?
}
@Dao
interface WorkoutSessionParamDao {
@Query("SELECT * FROM WorkoutSessionParam WHERE sessionId = :sessionId")
suspend fun getBySession(sessionId: Long): List<WorkoutSessionParam>
@Insert
suspend fun insert(param: WorkoutSessionParam): Long
}
@Dao
interface WorkoutEventDao {
@Query("SELECT * FROM WorkoutEvent WHERE sessionId = :sessionId ORDER BY ts ASC")
suspend fun getBySession(sessionId: Long): List<WorkoutEvent>
@Insert
suspend fun insert(event: WorkoutEvent): Long
}
@Dao
interface CatalogVersionDao {
@Query("SELECT * FROM CatalogVersion WHERE source = :source LIMIT 1")
suspend fun getBySource(source: String): CatalogVersion?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(version: CatalogVersion): Long
}

View File

@@ -0,0 +1,33 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.data.entity.CycleForecastEntity
@Dao
interface CycleForecastDao {
@Query("SELECT * FROM cycle_forecast WHERE id = 1")
fun getForecastFlow(): Flow<CycleForecastEntity?>
@Query("SELECT * FROM cycle_forecast WHERE id = 1")
suspend fun getForecast(): CycleForecastEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(forecast: CycleForecastEntity): Long
@Update
suspend fun update(forecast: CycleForecastEntity)
@Transaction
suspend fun insertOrUpdate(forecast: CycleForecastEntity) {
val existing = getForecast()
if (existing == null) {
insert(forecast)
} else {
update(forecast)
}
}
@Query("DELETE FROM cycle_forecast")
suspend fun deleteAll()
}

View File

@@ -0,0 +1,42 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
import java.time.LocalDate
@Dao
interface CycleHistoryDao {
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC")
fun getAllFlow(): Flow<List<CycleHistoryEntity>>
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC")
suspend fun getAll(): List<CycleHistoryEntity>
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC LIMIT :limit")
suspend fun getRecentCycles(limit: Int): List<CycleHistoryEntity>
@Query("SELECT * FROM cycle_history WHERE atypical = 0 ORDER BY periodStart DESC LIMIT :limit")
suspend fun getRecentTypicalCycles(limit: Int): List<CycleHistoryEntity>
@Query("SELECT * FROM cycle_history WHERE id = :id")
suspend fun getById(id: Long): CycleHistoryEntity?
@Query("SELECT * FROM cycle_history WHERE periodStart = :date")
suspend fun getByStartDate(date: LocalDate): CycleHistoryEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(cycle: CycleHistoryEntity): Long
@Update
suspend fun update(cycle: CycleHistoryEntity)
@Delete
suspend fun delete(cycle: CycleHistoryEntity)
@Query("DELETE FROM cycle_history")
suspend fun deleteAll()
@Query("SELECT * FROM cycle_history WHERE periodStart BETWEEN :startDate AND :endDate")
suspend fun getCyclesInRange(startDate: LocalDate, endDate: LocalDate): List<CycleHistoryEntity>
}

View File

@@ -0,0 +1,24 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
import java.time.LocalDate
@Dao
interface CyclePeriodDao {
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC")
suspend fun getAll(): List<CyclePeriodEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(period: CyclePeriodEntity): Long
@Update
suspend fun update(period: CyclePeriodEntity)
@Delete
suspend fun delete(period: CyclePeriodEntity)
@Query("SELECT * FROM cycle_periods WHERE startDate = :date LIMIT 1")
suspend fun getByStartDate(date: LocalDate): CyclePeriodEntity?
}

View File

@@ -0,0 +1,36 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
@Dao
interface CycleSettingsDao {
@Query("SELECT * FROM cycle_settings WHERE id = 1")
fun getSettingsFlow(): Flow<CycleSettingsEntity?>
@Query("SELECT * FROM cycle_settings WHERE id = 1")
suspend fun getSettings(): CycleSettingsEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(settings: CycleSettingsEntity): Long
@Update
suspend fun update(settings: CycleSettingsEntity)
@Transaction
suspend fun insertOrUpdate(settings: CycleSettingsEntity) {
val existing = getSettings()
if (existing == null) {
insert(settings)
} else {
update(settings)
}
}
@Query("DELETE FROM cycle_settings")
suspend fun deleteAll()
@Query("UPDATE cycle_settings SET lastPeriodStart = :date WHERE id = 1")
suspend fun updateLastPeriodStart(date: java.time.LocalDate)
}

View File

@@ -5,51 +5,6 @@ import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.data.entity.* import 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")

View File

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

View File

@@ -0,0 +1,156 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Index
import java.time.Instant
@Entity(tableName = "Nutrient", indices = [Index("code", unique = true)])
data class Nutrient(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val code: String,
val name: String,
val unit: String
)
@Entity(tableName = "Beverage")
data class Beverage(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val brand: String?,
val category: String,
val source: String,
val sourceRef: String,
val isCaffeinated: Boolean,
val isSweetened: Boolean,
val createdAt: Instant
)
@Entity(tableName = "BeverageServing")
data class BeverageServing(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val beverageId: Long,
val label: String,
val volumeMl: Int
)
@Entity(tableName = "BeverageServingNutrient")
data class BeverageServingNutrient(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val servingId: Long,
val nutrientId: Long,
val amountPerServing: Float
)
@Entity(tableName = "WaterLog", indices = [Index("ts")])
data class WaterLog(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val ts: Instant,
val volumeMl: Int,
val source: String
)
@Entity(tableName = "BeverageLog", indices = [Index("ts")])
data class BeverageLog(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val ts: Instant,
val beverageId: Long,
val servingId: Long,
val servingsCount: Int,
val notes: String?
)
@Entity(tableName = "BeverageLogNutrient")
data class BeverageLogNutrient(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val beverageLogId: Long,
val nutrientId: Long,
val amountTotal: Float
)
@Entity(tableName = "WeightLog", indices = [Index("ts")])
data class WeightLog(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val ts: Instant,
val weightKg: Float,
val source: String
)
@Entity(tableName = "Exercise")
data class Exercise(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val category: String,
val description: String?,
val metValue: Float?,
val source: String,
val sourceRef: String?
)
@Entity(tableName = "ExerciseParam")
data class ExerciseParam(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val exerciseId: Long,
val key: String,
val valueType: String,
val unit: String?,
val required: Boolean,
val defaultValue: String?
)
@Entity(tableName = "ExerciseFormula")
data class ExerciseFormula(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val exerciseId: Long,
val name: String,
val exprKcal: String,
val notes: String?
)
@Entity(tableName = "ExerciseFormulaVar")
data class ExerciseFormulaVar(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val formulaId: Long,
val varKey: String,
val required: Boolean,
val unit: String?
)
@Entity(tableName = "WorkoutSession", indices = [Index("startedAt")])
data class WorkoutSession(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val startedAt: Instant,
val endedAt: Instant?,
val exerciseId: Long,
val kcalTotal: Float?,
val distanceKm: Float?,
val notes: String?
)
@Entity(tableName = "WorkoutSessionParam")
data class WorkoutSessionParam(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: Long,
val key: String,
val valueNum: Float?,
val valueText: String?,
val unit: String?
)
@Entity(tableName = "WorkoutEvent")
data class WorkoutEvent(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: Long,
val ts: Instant,
val eventType: String,
val payloadJson: String
)
@Entity(tableName = "CatalogVersion", indices = [Index("source", unique = true)])
data class CatalogVersion(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val localVersion: Int,
val source: String,
val lastSyncAt: Instant
)

View File

@@ -0,0 +1,21 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.Instant
import java.time.LocalDate
/**
* Кэш прогнозов цикла для быстрого доступа в UI.
*/
@Entity(tableName = "cycle_forecast")
data class CycleForecastEntity(
@PrimaryKey val id: Int = 1, // Singleton
val nextPeriodStart: LocalDate? = null,
val nextOvulation: LocalDate? = null,
val fertileStart: LocalDate? = null,
val fertileEnd: LocalDate? = null,
val pmsStart: LocalDate? = null,
val updatedAt: Instant = Instant.now(),
val isReliable: Boolean = true // Flag для пониженной точности при определенных статусах
)

View File

@@ -0,0 +1,27 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import java.time.LocalDate
/**
* История циклов для расчета прогнозов и анализа.
*/
@Entity(
tableName = "cycle_history",
indices = [Index(value = ["periodStart"], unique = true)]
)
data class CycleHistoryEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val periodStart: LocalDate,
val periodEnd: LocalDate? = null,
val ovulationDate: LocalDate? = null,
val notes: String = "",
val atypical: Boolean = false,
// Добавляем поля для соответствия с CyclePeriodEntity
val flow: String = "",
val symptoms: List<String> = emptyList(),
val mood: String = "",
val cycleLength: Int? = null
)

View File

@@ -0,0 +1,16 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDate
@Entity(tableName = "cycle_periods")
data class CyclePeriodEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val startDate: LocalDate,
val endDate: LocalDate?,
val flow: String = "",
val symptoms: List<String> = emptyList(),
val mood: String = "",
val cycleLength: Int? = null
)

View File

@@ -0,0 +1,47 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDate
/**
* Основные настройки для модуля отслеживания менструального цикла.
*/
@Entity(tableName = "cycle_settings")
data class CycleSettingsEntity(
@PrimaryKey val id: Int = 1, // Singleton
// Основные параметры цикла
val baselineCycleLength: Int = 28,
val cycleVariabilityDays: Int = 3,
val periodLengthDays: Int = 5,
val lutealPhaseDays: String = "auto", // "auto" или число (8-17)
val lastPeriodStart: LocalDate? = null,
// Метод определения овуляции
val ovulationMethod: String = "auto", // auto, bbt, lh_test, cervical_mucus, medical
val allowManualOvulation: Boolean = false,
// Статусы влияющие на точность
val hormonalContraception: String = "none", // none, coc, iud, implant, other
val isPregnant: Boolean = false,
val isPostpartum: Boolean = false,
val isLactating: Boolean = false,
val perimenopause: Boolean = false,
// Настройки истории и исключения выбросов
val historyWindowCycles: Int = 6,
val excludeOutliers: Boolean = true,
// Сенсоры и единицы измерения
val tempUnit: String = "C", // C или F
val bbtTimeWindow: String = "06:00-10:00",
val timezone: String = "Asia/Seoul",
// Уведомления
val periodReminderDaysBefore: Int = 2,
val ovulationReminderDaysBefore: Int = 1,
val pmsWindowDays: Int = 3,
val deviationAlertDays: Int = 5,
val fertileWindowMode: String = "balanced" // conservative, balanced, broad
)

View File

@@ -13,18 +13,6 @@ data class WaterLogEntity(
val timestamp: Long = System.currentTimeMillis() 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)

View File

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

View File

@@ -0,0 +1,22 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDate
@Entity(tableName = "health_records")
data class HealthRecordEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val date: LocalDate,
val weight: Float?,
val heartRate: Int?,
val bloodPressureS: Int?,
val bloodPressureD: Int?,
val temperature: Float?,
val mood: String?,
val energyLevel: Int?,
val stressLevel: Int?,
val symptoms: List<String>?,
val notes: String?
)

View File

@@ -0,0 +1,119 @@
package kr.smartsoltech.wellshe.data.repo
import kr.smartsoltech.wellshe.data.dao.*
import kr.smartsoltech.wellshe.data.entity.*
import java.time.Instant
class BeverageCatalogRepository(
private val beverageDao: BeverageDao,
private val servingDao: BeverageServingDao,
private val servingNutrientDao: BeverageServingNutrientDao
) {
suspend fun search(query: String): List<Beverage> = beverageDao.search("%$query%")
suspend fun getServings(beverageId: Long): List<BeverageServing> = servingDao.getByBeverage(beverageId)
suspend fun getNutrients(servingId: Long): List<BeverageServingNutrient> = servingNutrientDao.getByServing(servingId)
// Методы syncFromUsda(), syncFromOpenFoodFacts() будут реализованы отдельно
}
class DrinkLogger(
private val waterLogDao: WaterLogDao,
private val beverageLogDao: BeverageLogDao,
private val beverageLogNutrientDao: BeverageLogNutrientDao,
private val servingNutrientDao: BeverageServingNutrientDao
) {
suspend fun logWater(ts: Instant, volumeMl: Int, source: String = "manual") {
waterLogDao.insertWaterLog(WaterLogEntity(date = ts.atZone(java.time.ZoneId.systemDefault()).toLocalDate(), amount = volumeMl, timestamp = ts.toEpochMilli()))
}
suspend fun logBeverage(ts: Instant, beverageId: Long, servingId: Long, servingsCount: Int, notes: String? = null) {
val logId = beverageLogDao.insert(BeverageLog(ts = ts, beverageId = beverageId, servingId = servingId, servingsCount = servingsCount, notes = notes))
val nutrients = servingNutrientDao.getByServing(servingId)
nutrients.forEach { n ->
beverageLogNutrientDao.insert(
BeverageLogNutrient(
beverageLogId = logId,
nutrientId = n.nutrientId,
amountTotal = n.amountPerServing * servingsCount
)
)
}
}
suspend fun getWaterHistory(days: Int): List<Int> {
val today = java.time.LocalDate.now()
return (0 until days).map { offset ->
val date = today.minusDays(offset.toLong())
waterLogDao.getTotalWaterForDate(date) ?: 0
}.reversed()
}
}
class WeightRepository(private val weightLogDao: WeightLogDao) {
suspend fun addWeight(ts: Instant, kg: Float, source: String = "manual") {
weightLogDao.insert(WeightLog(ts = ts, weightKg = kg, source = source))
}
suspend fun getLatestWeightKg(): Float? = weightLogDao.getLatestWeightKg()?.weightKg
suspend fun getWeightHistory(days: Int): List<Pair<String, Float>> {
val today = java.time.LocalDate.now()
return (0 until days).map { offset ->
val date = today.minusDays(offset.toLong())
val logs = weightLogDao.getLogs(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant(), date.plusDays(1).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant())
val weight = logs.firstOrNull()?.weightKg ?: 0f
date.toString() to weight
}.reversed()
}
}
class ExerciseCatalogRepository(
private val exerciseDao: ExerciseDao,
private val paramDao: ExerciseParamDao,
private val formulaDao: ExerciseFormulaDao
) {
suspend fun searchExercises(query: String): List<Exercise> = exerciseDao.search("%$query%")
suspend fun getParams(exerciseId: Long): List<ExerciseParam> = paramDao.getByExercise(exerciseId)
suspend fun getFormulas(exerciseId: Long): List<ExerciseFormula> = formulaDao.getByExercise(exerciseId)
// Методы syncFromWger(), syncMetTables() будут реализованы отдельно
}
class WorkoutService(
private val sessionDao: WorkoutSessionDao,
private val paramDao: WorkoutSessionParamDao,
private val eventDao: WorkoutEventDao,
private val weightRepo: WeightRepository,
private val formulaDao: ExerciseFormulaDao,
private val formulaVarDao: ExerciseFormulaVarDao,
private val exerciseDao: ExerciseDao
) {
suspend fun searchExercises(query: String): List<Exercise> = exerciseDao.search("%$query%")
suspend fun getSessions(days: Int): List<WorkoutSession> {
val now = java.time.Instant.now()
val start = now.minusSeconds(days * 24 * 3600L)
return sessionDao.getSessions(start, now)
}
suspend fun stopSession(sessionId: Long) {
val session = sessionDao.getById(sessionId)
if (session != null && session.endedAt == null) {
sessionDao.insert(session.copy(endedAt = java.time.Instant.now()))
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = java.time.Instant.now(), eventType = "stop", payloadJson = "{}"))
}
}
suspend fun startSession(exerciseId: Long): Long {
val baseWeightKg = weightRepo.getLatestWeightKg() ?: 70f
val sessionId = sessionDao.insert(
WorkoutSession(
startedAt = Instant.now(),
endedAt = null,
exerciseId = exerciseId,
kcalTotal = null,
distanceKm = null,
notes = null
)
)
paramDao.insert(WorkoutSessionParam(sessionId = sessionId, key = "baseWeightKg", valueNum = baseWeightKg, valueText = null, unit = "kg"))
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = Instant.now(), eventType = "start", payloadJson = "{}"))
return sessionId
}
suspend fun updateParam(sessionId: Long, key: String, valueNum: Float?, valueText: String?, unit: String?) {
paramDao.insert(WorkoutSessionParam(sessionId = sessionId, key = key, valueNum = valueNum, valueText = valueText, unit = unit))
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = Instant.now(), eventType = "param_change", payloadJson = "{\"key\":\"$key\"}"))
}
// tick(sessionId) и stopSession(sessionId) будут реализованы с расчетом калорий по формуле
}

View File

@@ -0,0 +1,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)
}
}

View File

@@ -0,0 +1,16 @@
package kr.smartsoltech.wellshe.data.repository
import kr.smartsoltech.wellshe.data.dao.HealthRecordDao
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HealthRepository @Inject constructor(private val dao: HealthRecordDao) {
suspend fun getAllRecords(): List<HealthRecordEntity> = dao.getAll()
suspend fun insertRecord(record: HealthRecordEntity): Long = dao.insert(record)
suspend fun updateRecord(record: HealthRecordEntity) = dao.update(record)
suspend fun deleteRecord(record: HealthRecordEntity) = dao.delete(record)
suspend fun getRecordByDate(date: java.time.LocalDate): HealthRecordEntity? = dao.getByDate(date)
}

View File

@@ -1,11 +1,16 @@
package kr.smartsoltech.wellshe.data.repository 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,
flow = flow,
symptoms = symptoms,
mood = mood
)
cyclePeriodDao.insert(period)
}
suspend fun updatePeriod(periodId: Long, endDate: LocalDate?, flow: String, symptoms: List<String>, mood: String) {
val periods = cyclePeriodDao.getAll()
val existingPeriod = periods.firstOrNull { it.id == periodId }
if (existingPeriod != null) {
val updatedPeriod = existingPeriod.copy(
endDate = endDate, endDate = endDate,
flow = flow, flow = flow,
symptoms = symptoms.joinToString(","), symptoms = symptoms,
mood = mood 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))
}
} }
} }

View File

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

View File

@@ -0,0 +1,59 @@
package kr.smartsoltech.wellshe.di
import android.content.Context
import androidx.work.WorkManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kr.smartsoltech.wellshe.data.dao.CycleForecastDao
import kr.smartsoltech.wellshe.data.dao.CycleHistoryDao
import kr.smartsoltech.wellshe.data.dao.CycleSettingsDao
import kr.smartsoltech.wellshe.data.repository.CycleRepository
import kr.smartsoltech.wellshe.domain.services.CycleSettingsExportService
import kr.smartsoltech.wellshe.workers.CycleNotificationManager
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object CycleModule {
@Provides
@Singleton
fun provideCycleRepository(
settingsDao: CycleSettingsDao,
historyDao: CycleHistoryDao,
forecastDao: CycleForecastDao
): CycleRepository = CycleRepository(settingsDao, historyDao, forecastDao)
@Provides
@Singleton
fun provideCycleSettingsExportService(): CycleSettingsExportService =
CycleSettingsExportService()
@Provides
@Singleton
fun provideWorkManager(@ApplicationContext context: Context): WorkManager =
WorkManager.getInstance(context)
@Provides
@Singleton
fun provideCycleNotificationManager(
@ApplicationContext context: Context,
workManager: WorkManager
): CycleNotificationManager = CycleNotificationManager(context, workManager)
// DAO providers
@Provides
fun provideCycleSettingsDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleSettingsDao =
database.cycleSettingsDao()
@Provides
fun provideCycleHistoryDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleHistoryDao =
database.cycleHistoryDao()
@Provides
fun provideCycleForecastDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleForecastDao =
database.cycleForecastDao()
}

View File

@@ -1,19 +1,34 @@
package kr.smartsoltech.wellshe.domain.analytics 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 -> "Нерегулярный"
}
}
/**
* Предсказание следующих дат
*/
fun predictNextPeriods(periods: List<CyclePeriodEntity>, count: Int = 3): List<LocalDate> {
if (periods.isEmpty()) return emptyList()
val stats = calculateStats(periods)
val lastPeriod = periods.first()
val predictions = mutableListOf<LocalDate>()
for (i in 1..count) {
val nextDate = lastPeriod.startDate.plusDays((stats.avgCycle * i).toLong())
predictions.add(nextDate)
}
return predictions
} }
} }
data class CycleForecast(
val nextStart: Long?,
val fertileWindow: Pair<Long, Long>?,
val confidence: String
)
data class CycleStats(
val avgCycle: Int,
val variance: Double,
val lutealLen: Int
)

View File

@@ -0,0 +1,95 @@
package kr.smartsoltech.wellshe.domain.models
import java.time.LocalDate
/**
* Доменная модель для истории циклов.
*/
data class CycleHistory(
val id: Long = 0,
val periodStart: LocalDate,
val periodEnd: LocalDate? = null,
val ovulationDate: LocalDate? = null,
val notes: String = "",
val atypical: Boolean = false
)
/**
* Доменная модель для прогнозов цикла.
*/
data class CycleForecast(
val nextPeriodStart: LocalDate? = null,
val nextOvulation: LocalDate? = null,
val fertileStart: LocalDate? = null,
val fertileEnd: LocalDate? = null,
val pmsStart: LocalDate? = null,
val isReliable: Boolean = true, // Флаг для пониженной точности
val currentCyclePhase: CyclePhase = CyclePhase.UNKNOWN
)
/**
* Фаза менструального цикла.
*/
enum class CyclePhase {
MENSTRUATION, // Менструация
FOLLICULAR, // Фолликулярная фаза (после менструации до фертильного окна)
FERTILE, // Фертильное окно
OVULATION, // День овуляции
LUTEAL, // Лютеиновая фаза (после овуляции)
PMS, // ПМС (последние дни перед менструацией)
UNKNOWN; // Неизвестная фаза (например, при недостатке данных)
companion object {
/**
* Определяет текущую фазу цикла на основе прогноза и текущей даты.
*/
fun determinePhase(
today: LocalDate = LocalDate.now(),
nextPeriodStart: LocalDate?,
lastPeriodStart: LocalDate?,
fertileStart: LocalDate?,
fertileEnd: LocalDate?,
ovulationDate: LocalDate?,
pmsStart: LocalDate?,
periodLengthDays: Int = 5
): CyclePhase {
if (lastPeriodStart == null || nextPeriodStart == null) return UNKNOWN
// Определяем конец последней менструации
val lastPeriodEnd = lastPeriodStart.plusDays(periodLengthDays.toLong() - 1)
return when {
// Период менструации
(today.isEqual(lastPeriodStart) || today.isAfter(lastPeriodStart)) &&
(today.isEqual(lastPeriodEnd) || today.isBefore(lastPeriodEnd)) -> MENSTRUATION
// День овуляции
ovulationDate != null && today.isEqual(ovulationDate) -> OVULATION
// Фертильное окно
fertileStart != null && fertileEnd != null &&
(today.isEqual(fertileStart) || today.isAfter(fertileStart)) &&
(today.isEqual(fertileEnd) || today.isBefore(fertileEnd)) &&
(ovulationDate == null || !today.isEqual(ovulationDate)) -> FERTILE
// ПМС
pmsStart != null &&
(today.isEqual(pmsStart) || today.isAfter(pmsStart)) &&
today.isBefore(nextPeriodStart) -> PMS
// Лютеиновая фаза (после овуляции/фертильного окна до ПМС)
ovulationDate != null && fertileEnd != null && pmsStart != null &&
today.isAfter(fertileEnd) &&
today.isBefore(pmsStart) -> LUTEAL
// Фолликулярная фаза (после менструации до фертильного окна)
lastPeriodEnd != null && fertileStart != null &&
today.isAfter(lastPeriodEnd) &&
today.isBefore(fertileStart) -> FOLLICULAR
// Если не удалось определить фазу
else -> UNKNOWN
}
}
}
}

View File

@@ -0,0 +1,116 @@
package kr.smartsoltech.wellshe.domain.models
import java.time.LocalDate
/**
* Доменная модель для настроек цикла.
*/
data class CycleSettings(
// Основные параметры цикла
val baselineCycleLength: Int = 28,
val cycleVariabilityDays: Int = 3,
val periodLengthDays: Int = 5,
val lutealPhaseDays: String = "auto", // "auto" или число (8-17)
val lastPeriodStart: LocalDate? = null,
// Метод определения овуляции
val ovulationMethod: OvulationMethod = OvulationMethod.AUTO,
val allowManualOvulation: Boolean = false,
// Статусы влияющие на точность
val hormonalContraception: HormonalContraceptionType = HormonalContraceptionType.NONE,
val isPregnant: Boolean = false,
val isPostpartum: Boolean = false,
val isLactating: Boolean = false,
val perimenopause: Boolean = false,
// Настройки истории и исключения выбросов
val historyWindowCycles: Int = 6,
val excludeOutliers: Boolean = true,
// Сенсоры и единицы измерения
val tempUnit: TemperatureUnit = TemperatureUnit.CELSIUS,
val bbtTimeWindow: String = "06:00-10:00",
val timezone: String = "Asia/Seoul",
// Уведомления
val periodReminderDaysBefore: Int = 2,
val ovulationReminderDaysBefore: Int = 1,
val pmsWindowDays: Int = 3,
val deviationAlertDays: Int = 5,
val fertileWindowMode: FertileWindowMode = FertileWindowMode.BALANCED
)
/**
* Метод определения овуляции
*/
enum class OvulationMethod {
AUTO, BBT, LH_TEST, CERVICAL_MUCUS, MEDICAL;
companion object {
fun fromString(value: String): OvulationMethod = when (value.lowercase()) {
"bbt" -> BBT
"lh_test" -> LH_TEST
"cervical_mucus" -> CERVICAL_MUCUS
"medical" -> MEDICAL
else -> AUTO
}
}
fun toStorageString(): String = this.name.lowercase()
}
/**
* Тип гормональной контрацепции
*/
enum class HormonalContraceptionType {
NONE, COC, IUD, IMPLANT, OTHER;
companion object {
fun fromString(value: String): HormonalContraceptionType = when (value.lowercase()) {
"coc" -> COC
"iud" -> IUD
"implant" -> IMPLANT
"other" -> OTHER
else -> NONE
}
}
fun toStorageString(): String = this.name.lowercase()
}
/**
* Единицы измерения температуры
*/
enum class TemperatureUnit {
CELSIUS, FAHRENHEIT;
companion object {
fun fromString(value: String): TemperatureUnit = when (value.uppercase()) {
"F" -> FAHRENHEIT
else -> CELSIUS
}
}
fun toStorageString(): String = when (this) {
CELSIUS -> "C"
FAHRENHEIT -> "F"
}
}
/**
* Режим определения фертильного окна
*/
enum class FertileWindowMode {
CONSERVATIVE, BALANCED, BROAD;
companion object {
fun fromString(value: String): FertileWindowMode = when (value.lowercase()) {
"conservative" -> CONSERVATIVE
"broad" -> BROAD
else -> BALANCED
}
}
fun toStorageString(): String = this.name.lowercase()
}

View File

@@ -0,0 +1,172 @@
package kr.smartsoltech.wellshe.domain.services
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
import kr.smartsoltech.wellshe.domain.models.*
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
/**
* Класс для импорта и экспорта настроек цикла в JSON формате
*/
@Singleton
class CycleSettingsExportService @Inject constructor() {
// Создаем адаптер для LocalDate вне класса LocalDateAdapter
private val localDateAdapter = object : JsonAdapter<LocalDate>() {
override fun fromJson(reader: com.squareup.moshi.JsonReader): LocalDate? {
return try {
val dateString = reader.nextString()
LocalDate.parse(dateString)
} catch (e: Exception) {
null
}
}
override fun toJson(writer: com.squareup.moshi.JsonWriter, value: LocalDate?) {
if (value == null) {
writer.nullValue()
} else {
writer.value(value.toString())
}
}
}
// Настройка Moshi для сериализации/десериализации
private val moshi: Moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.add(Date::class.java, Rfc3339DateJsonAdapter())
.add(LocalDate::class.java, localDateAdapter)
.build()
// Адаптер для сериализации/десериализации настроек цикла
private val settingsAdapter: JsonAdapter<CycleSettingsJsonDto> = moshi.adapter(CycleSettingsJsonDto::class.java)
/**
* Экспортирует настройки в формат JSON
*/
fun exportSettingsToJson(settings: CycleSettingsEntity): String {
val jsonDto = convertToJsonDto(settings)
return settingsAdapter.toJson(jsonDto)
}
/**
* Импортирует настройки из JSON
* @return Импортированные настройки или null в случае ошибки
*/
fun importSettingsFromJson(json: String): CycleSettingsEntity? {
return try {
val jsonDto = settingsAdapter.fromJson(json)
jsonDto?.let { convertToEntity(it) }
} catch (e: Exception) {
null
}
}
/**
* Конвертирует Entity в DTO для экспорта в JSON
*/
private fun convertToJsonDto(entity: CycleSettingsEntity): CycleSettingsJsonDto {
return CycleSettingsJsonDto(
baselineCycleLength = entity.baselineCycleLength,
cycleVariabilityDays = entity.cycleVariabilityDays,
periodLengthDays = entity.periodLengthDays,
lutealPhaseDays = entity.lutealPhaseDays,
lastPeriodStart = entity.lastPeriodStart,
ovulationMethod = entity.ovulationMethod,
allowManualOvulation = entity.allowManualOvulation,
hormonalContraception = entity.hormonalContraception,
isPregnant = entity.isPregnant,
isPostpartum = entity.isPostpartum,
isLactating = entity.isLactating,
perimenopause = entity.perimenopause,
historyWindowCycles = entity.historyWindowCycles,
excludeOutliers = entity.excludeOutliers,
tempUnit = entity.tempUnit,
bbtTimeWindow = entity.bbtTimeWindow,
timezone = entity.timezone,
periodReminderDaysBefore = entity.periodReminderDaysBefore,
ovulationReminderDaysBefore = entity.ovulationReminderDaysBefore,
pmsWindowDays = entity.pmsWindowDays,
deviationAlertDays = entity.deviationAlertDays,
fertileWindowMode = entity.fertileWindowMode
)
}
/**
* Конвертирует DTO в Entity
*/
private fun convertToEntity(dto: CycleSettingsJsonDto): CycleSettingsEntity {
return CycleSettingsEntity(
id = 1, // Singleton ID
baselineCycleLength = dto.baselineCycleLength.coerceIn(18, 60),
cycleVariabilityDays = dto.cycleVariabilityDays.coerceIn(0, 10),
periodLengthDays = dto.periodLengthDays.coerceIn(1, 10),
lutealPhaseDays = dto.lutealPhaseDays, // Валидация будет в ViewModel
lastPeriodStart = dto.lastPeriodStart,
ovulationMethod = dto.ovulationMethod,
allowManualOvulation = dto.allowManualOvulation,
hormonalContraception = dto.hormonalContraception,
isPregnant = dto.isPregnant,
isPostpartum = dto.isPostpartum,
isLactating = dto.isLactating,
perimenopause = dto.perimenopause,
historyWindowCycles = dto.historyWindowCycles,
excludeOutliers = dto.excludeOutliers,
tempUnit = dto.tempUnit,
bbtTimeWindow = dto.bbtTimeWindow,
timezone = dto.timezone,
periodReminderDaysBefore = dto.periodReminderDaysBefore.coerceIn(0, 7),
ovulationReminderDaysBefore = dto.ovulationReminderDaysBefore.coerceIn(0, 7),
pmsWindowDays = dto.pmsWindowDays.coerceIn(1, 7),
deviationAlertDays = dto.deviationAlertDays.coerceIn(1, 14),
fertileWindowMode = dto.fertileWindowMode
)
}
/**
* DTO для сериализации/десериализации настроек цикла в JSON
*/
data class CycleSettingsJsonDto(
// Основные параметры цикла
val baselineCycleLength: Int = 28,
val cycleVariabilityDays: Int = 3,
val periodLengthDays: Int = 5,
val lutealPhaseDays: String = "auto",
val lastPeriodStart: LocalDate? = null,
// Метод определения овуляции
val ovulationMethod: String = "auto",
val allowManualOvulation: Boolean = false,
// Статусы влияющие на точность
val hormonalContraception: String = "none",
val isPregnant: Boolean = false,
val isPostpartum: Boolean = false,
val isLactating: Boolean = false,
val perimenopause: Boolean = false,
// Настройки истории и исключения выбросов
val historyWindowCycles: Int = 6,
val excludeOutliers: Boolean = true,
// Сенсоры и единицы измерения
val tempUnit: String = "C",
val bbtTimeWindow: String = "06:00-10:00",
val timezone: String = "Asia/Seoul",
// Уведомления
val periodReminderDaysBefore: Int = 2,
val ovulationReminderDaysBefore: Int = 1,
val pmsWindowDays: Int = 3,
val deviationAlertDays: Int = 5,
val fertileWindowMode: String = "balanced"
)
}

View File

@@ -0,0 +1,46 @@
package kr.smartsoltech.wellshe.model
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
/**
* Модель данных для прогноза менструального цикла
*/
data class CycleForecast(
val nextPeriodStart: LocalDate,
val nextOvulation: LocalDate,
val fertileStart: LocalDate,
val fertileEnd: LocalDate,
val pmsStart: LocalDate,
val periodLengthDays: Int
) {
val periodEnd: LocalDate get() = nextPeriodStart.plusDays(periodLengthDays.toLong() - 1)
}
/**
* Расчет прогноза цикла на основе настроек
*/
fun computeForecast(settings: CycleSettings): CycleForecast {
val nextPeriod = settings.lastPeriodStart.plusDays(settings.baselineLength.toLong())
val ovulation = nextPeriod.minusDays(settings.lutealDays.toLong())
val fertileStart = ovulation.minusDays(5)
val fertileEnd = ovulation
val pmsStart = nextPeriod.minusDays(3)
return CycleForecast(
nextPeriodStart = nextPeriod,
nextOvulation = ovulation,
fertileStart = fertileStart,
fertileEnd = fertileEnd,
pmsStart = pmsStart,
periodLengthDays = settings.periodLength
)
}
/**
* Форматирует дату в формате "DD MMM"
*/
fun fmt(date: LocalDate): String {
return date.format(DateTimeFormatter.ofPattern("dd MMM"))
}

View File

@@ -0,0 +1,10 @@
package kr.smartsoltech.wellshe.model
import java.time.LocalDate
data class CycleSettings(
val baselineLength: Int = 28,
val periodLength: Int = 5,
val lutealDays: Int = 14,
val lastPeriodStart: LocalDate
)

View File

@@ -0,0 +1,24 @@
package kr.smartsoltech.wellshe.model
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.time.LocalDate
class JournalConverters {
@TypeConverter
fun fromLocalDate(date: LocalDate): String = date.toString()
@TypeConverter
fun toLocalDate(dateString: String): LocalDate = LocalDate.parse(dateString)
@TypeConverter
fun fromMediaList(media: List<JournalMedia>): String = Gson().toJson(media)
@TypeConverter
fun toMediaList(mediaString: String): List<JournalMedia> {
val type = object : TypeToken<List<JournalMedia>>() {}.type
return Gson().fromJson(mediaString, type)
}
}

View File

@@ -0,0 +1,30 @@
package kr.smartsoltech.wellshe.model
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(entities = [JournalEntryEntity::class], version = 1, exportSchema = false)
@TypeConverters(JournalConverters::class)
abstract class JournalDatabase : RoomDatabase() {
abstract fun journalEntryDao(): JournalEntryDao
companion object {
@Volatile
private var INSTANCE: JournalDatabase? = null
fun getDatabase(context: Context): JournalDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
JournalDatabase::class.java,
"journal_database"
).build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -0,0 +1,22 @@
package kr.smartsoltech.wellshe.model
import java.time.LocalDate
// Модель записи дневника
data class JournalEntry(
val id: Long = 0L, // уникальный идентификатор
val date: LocalDate,
val text: String,
val media: List<JournalMedia> = emptyList()
)
// Модель медиафайла (картинка, видео, музыка)
data class JournalMedia(
val uri: String,
val type: MediaType
)
enum class MediaType {
IMAGE, VIDEO, AUDIO
}

View File

@@ -0,0 +1,20 @@
package kr.smartsoltech.wellshe.model
import androidx.room.*
import java.time.LocalDate
@Dao
interface JournalEntryDao {
@Query("SELECT * FROM journal_entries WHERE date = :date")
suspend fun getEntriesByDate(date: LocalDate): List<JournalEntryEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertEntry(entry: JournalEntryEntity): Long
@Update
suspend fun updateEntry(entry: JournalEntryEntity)
@Delete
suspend fun deleteEntry(entry: JournalEntryEntity)
}

View File

@@ -0,0 +1,16 @@
package kr.smartsoltech.wellshe.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import java.time.LocalDate
@Entity(tableName = "journal_entries")
data class JournalEntryEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0L,
val date: LocalDate,
val text: String,
val media: String // сериализованный список медиа (например, JSON)
)

View File

@@ -0,0 +1,39 @@
package kr.smartsoltech.wellshe.model
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.LocalDate
class JournalRepository(private val dao: JournalEntryDao) {
suspend fun getEntriesByDate(date: LocalDate): List<JournalEntry> = withContext(Dispatchers.IO) {
dao.getEntriesByDate(date).map { entity ->
JournalEntry(
id = entity.id,
date = entity.date,
text = entity.text,
media = JournalConverters().toMediaList(entity.media)
)
}
}
suspend fun addOrUpdateEntry(entry: JournalEntry) = withContext(Dispatchers.IO) {
val entity = JournalEntryEntity(
id = entry.id,
date = entry.date,
text = entry.text,
media = JournalConverters().fromMediaList(entry.media)
)
dao.insertEntry(entity)
}
suspend fun deleteEntry(entry: JournalEntry) = withContext(Dispatchers.IO) {
val entity = JournalEntryEntity(
id = entry.id,
date = entry.date,
text = entry.text,
media = JournalConverters().fromMediaList(entry.media)
)
dao.deleteEntry(entity)
}
}

View File

@@ -0,0 +1,310 @@
package kr.smartsoltech.wellshe.ui.analytics
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kr.smartsoltech.wellshe.ui.components.InfoCard
import kr.smartsoltech.wellshe.ui.components.KPI
import kr.smartsoltech.wellshe.ui.theme.*
/**
* Экран "Аналитика" с данными и графиками
*/
@Composable
fun AnalyticsScreen(
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
// Состояния для выбранного временного диапазона
var selectedRange by remember { mutableStateOf("7d") }
val rangeLabels = mapOf(
"7d" to "7 дней",
"30d" to "30 дней",
"90d" to "90 дней"
)
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Заголовок и селектор периода
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Аналитика",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
// Выпадающее меню для выбора периода
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Период:",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
var expanded by remember { mutableStateOf(false) }
OutlinedButton(
onClick = { expanded = true },
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Text(rangeLabels[selectedRange] ?: "7 дней")
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = "Выбрать период"
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
rangeLabels.forEach { (key, value) ->
DropdownMenuItem(
text = { Text(value) },
onClick = {
selectedRange = key
expanded = false
}
)
}
}
}
}
// KPI блоки
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
KPI(
title = "Стабильность цикла",
value = "Высокая",
tone = Color(0xFF4CAF50),
modifier = Modifier.weight(1f)
)
KPI(
title = "Гидратация",
value = "~85% нормы",
tone = Color(0xFF2196F3),
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
KPI(
title = "Вес (${rangeLabels[selectedRange]})",
value = "0.6 кг",
tone = Color(0xFFE91E63),
modifier = Modifier.weight(1f)
)
KPI(
title = "Калории (спорт)",
value = "+2.9k",
tone = Color(0xFF4CAF50),
modifier = Modifier.weight(1f)
)
}
// Графики
AnalyticsChart(
title = "Динамика веса (${rangeLabels[selectedRange]})",
subtitle = "Тренд: медленное снижение веса, без резких скачков."
)
AnalyticsChart(
title = "Гидратация (${rangeLabels[selectedRange]})",
subtitle = "Среднее за период ~1950 мл/день."
)
AnalyticsChart(
title = "Сожжённые калории (${rangeLabels[selectedRange]})",
subtitle = "Пики в выходные — неплохая стратегия, добавь лёгкую сессию в среду."
)
// Корреляции
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = AnalyticsTabColor.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Корреляции с фазами",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
// Корреляционные линии
CorrelationItem("Гидратация ↔ энергия", 0.42f)
CorrelationItem("Сон ↔ настроение", 0.58f)
CorrelationItem("ПМС ↔ боль", 0.66f)
CorrelationItem("Вес ↔ вода", -0.18f)
}
}
// Инсайт недели
InfoCard(
title = "Инсайт недели",
content = "Лучшая тренировка — суббота (600 ккал). Пиковая продуктивность совпадает с фолликулярной фазой. Вес ↓ на 0.6 кг — отличный тренд."
)
}
}
/**
* Карточка с графиком для аналитики
*/
@Composable
fun AnalyticsChart(
title: String,
subtitle: String
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
// Заглушка для графика (в реальном приложении использовать библиотеку графиков)
Box(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.padding(vertical = 8.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)),
contentAlignment = Alignment.Center
) {
// В реальном приложении здесь будет график
Text(
text = "Графическое представление данных",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
/**
* Элемент для отображения корреляции с визуализацией
*/
@Composable
fun CorrelationItem(
title: String,
value: Float
) {
val color = when {
value > 0.3f -> Color(0xFF4CAF50) // Зеленый для положительных
value < -0.3f -> Color(0xFFE91E63) // Красный для отрицательных
else -> Color(0xFF9E9E9E) // Серый для нейтральных
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Полоса корреляции
Box(
modifier = Modifier
.width(80.dp)
.height(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Box(
modifier = Modifier
.width((80 * kotlin.math.abs(value)).dp)
.height(8.dp)
.background(color)
)
}
// Числовое значение
Text(
text = (if (value > 0) "+" else "") + String.format("%.2f", value),
style = MaterialTheme.typography.labelSmall,
color = color
)
}
}
}
@Preview(showBackground = true)
@Composable
fun AnalyticsScreenPreview() {
WellSheTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AnalyticsScreen()
}
}
}

View File

@@ -0,0 +1,27 @@
package kr.smartsoltech.wellshe.ui.analytics
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@HiltViewModel
class AnalyticsViewModel @Inject constructor() : ViewModel() {
// Данные для экрана аналитики
private val _cycleStability = MutableStateFlow("Высокая")
val cycleStability: StateFlow<String> = _cycleStability.asStateFlow()
private val _hydration = MutableStateFlow(85)
val hydration: StateFlow<Int> = _hydration.asStateFlow()
private val _weightChange = MutableStateFlow(-0.6f)
val weightChange: StateFlow<Float> = _weightChange.asStateFlow()
private val _caloriesBurned = MutableStateFlow(2900)
val caloriesBurned: StateFlow<Int> = _caloriesBurned.asStateFlow()
private val _weeklyInsight = MutableStateFlow("Лучшие тренировки приходятся на выходные; добавь лёгкое кардио в среду.")
val weeklyInsight: StateFlow<String> = _weeklyInsight.asStateFlow()
}

View File

@@ -0,0 +1,110 @@
package kr.smartsoltech.wellshe.ui.body
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kr.smartsoltech.wellshe.ui.body.tabs.ActivityTab
import kr.smartsoltech.wellshe.ui.body.tabs.WaterTab
import kr.smartsoltech.wellshe.ui.body.tabs.WeightTab
import kr.smartsoltech.wellshe.ui.theme.*
/**
* Основной экран "Тело" с вкладками для воды, веса и активности
*/
@Composable
fun BodyScreen(
modifier: Modifier = Modifier
) {
var selectedTabIndex by remember { mutableStateOf(0) }
val tabs = listOf("Вода", "Вес", "Активность")
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Вкладки
TabRow(
selectedTabIndex = selectedTabIndex,
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.large),
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
divider = {},
indicator = {}
) {
tabs.forEachIndexed { index, title ->
val selected = selectedTabIndex == index
val tabColor = when(index) {
0 -> if (selected) BodyTabColor else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
1 -> if (selected) Color(0xFFFCE4EC) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
2 -> if (selected) Color(0xFFE0F2F1) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
}
Tab(
selected = selected,
onClick = { selectedTabIndex = index },
modifier = Modifier
.padding(4.dp)
.clip(MaterialTheme.shapes.large)
.background(tabColor)
) {
val emoji = when(index) {
0 -> "💧"
1 -> "⚖️"
2 -> "🏃‍♀️"
else -> ""
}
Text(
text = "$emoji $title",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
)
}
}
}
// Содержимое вкладки
when (selectedTabIndex) {
0 -> WaterTab()
1 -> WeightTab()
2 -> ActivityTab()
}
}
}
@Preview(showBackground = true)
@Composable
fun BodyScreenPreview() {
WellSheTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
BodyScreen()
}
}
}
@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BodyScreenDarkPreview() {
WellSheTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
BodyScreen()
}
}
}

View File

@@ -0,0 +1,125 @@
package kr.smartsoltech.wellshe.ui.body
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate
import javax.inject.Inject
@HiltViewModel
class BodyViewModel @Inject constructor(
) : ViewModel() {
// Данные для вкладки "Вода"
private val _waterData = MutableStateFlow<List<Int>>(listOf(1800, 1950, 2050, 1900, 2100, 2300, 2000))
val waterData: StateFlow<List<Int>> = _waterData.asStateFlow()
private val _todayWaterAmount = MutableStateFlow(1800)
val todayWaterAmount: StateFlow<Int> = _todayWaterAmount.asStateFlow()
private val _waterGoal = MutableStateFlow(2000) // 2л по умолчанию в мл
val waterGoal: StateFlow<Int> = _waterGoal.asStateFlow()
// Данные для вкладки "Вес"
private val _weightHistory = MutableStateFlow<List<Pair<String, Float>>>(
listOf(
"Пн" to 58.9f,
"Вт" to 58.7f,
"Ср" to 58.6f,
"Чт" to 58.5f,
"Пт" to 58.4f,
"Сб" to 58.4f,
"Вс" to 58.3f
)
)
val weightHistory: StateFlow<List<Pair<String, Float>>> = _weightHistory.asStateFlow()
private val _currentWeight = MutableStateFlow<Float?>(58.4f)
val currentWeight: StateFlow<Float?> = _currentWeight.asStateFlow()
private val _weeklyWeightDifference = MutableStateFlow(-0.6f)
val weeklyWeightDifference: StateFlow<Float> = _weeklyWeightDifference.asStateFlow()
// Данные для вкладки "Активность"
private val _activityData = MutableStateFlow<List<WorkoutData>>(
listOf(
WorkoutData("Пн", 250),
WorkoutData("Вт", 350),
WorkoutData("Ср", 410),
WorkoutData("Чт", 280),
WorkoutData("Пт", 500),
WorkoutData("Сб", 600),
WorkoutData("Вс", 420)
)
)
val activityData: StateFlow<List<WorkoutData>> = _activityData.asStateFlow()
private val _todayCaloriesBurned = MutableStateFlow(312)
val todayCaloriesBurned: StateFlow<Int> = _todayCaloriesBurned.asStateFlow()
private val _bestWorkoutDay = MutableStateFlow<Pair<String, Int>?>(Pair("Сб", 600))
val bestWorkoutDay: StateFlow<Pair<String, Int>?> = _bestWorkoutDay.asStateFlow()
init {
loadWaterData()
loadWeightData()
loadActivityData()
}
fun loadWaterData() {
// Здесь будет логика загрузки данных о воде
// Сейчас используем тестовые данные
}
fun loadWeightData() {
// Здесь будет логика загрузки данных о весе
// Сейчас используем тестовые данные
}
fun loadActivityData() {
// Здесь будет логика загрузки данных об активности
// Сейчас используем тестовые данные
}
fun addWater(amount: Int) {
val currentAmount = _todayWaterAmount.value
_todayWaterAmount.value = currentAmount + amount
// Обновляем данные в истории
val updatedWaterData = _waterData.value.toMutableList()
if (updatedWaterData.isNotEmpty()) {
updatedWaterData[updatedWaterData.size - 1] = _todayWaterAmount.value
_waterData.value = updatedWaterData
}
}
fun logWeight(weightKg: Float) {
_currentWeight.value = weightKg
// Обновляем историю веса
val updatedWeightHistory = _weightHistory.value.toMutableList()
if (updatedWeightHistory.isNotEmpty()) {
val lastDay = updatedWeightHistory.last().first
updatedWeightHistory[updatedWeightHistory.size - 1] = lastDay to weightKg
_weightHistory.value = updatedWeightHistory
// Пересчитываем разницу за неделю
val firstWeight = updatedWeightHistory.first().second
_weeklyWeightDifference.value = weightKg - firstWeight
}
}
fun startWorkout(exerciseId: Long) {
// Здесь будет логика начала тренировки
}
}
data class WorkoutData(
val date: String,
val caloriesBurned: Int
)

View File

@@ -0,0 +1,176 @@
package kr.smartsoltech.wellshe.ui.body.tabs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DirectionsRun
import androidx.compose.material.icons.filled.LocalFireDepartment
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import kr.smartsoltech.wellshe.ui.components.InfoCard
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
/**
* Содержимое вкладки "Активность" экрана "Тело"
*/
@Composable
fun ActivityTab(
modifier: Modifier = Modifier
) {
// Данные для отображения (в реальном приложении должны поступать из ViewModel)
val caloriesBurned = "+312 ккал"
val activityDescription = "Бег 8 км/ч · 35 мин"
val activityColor = Color(0xFF4CAF50) // Зеленый для активности
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Основная карточка с активностью
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFE0F2F1).copy(alpha = 0.3f) // Светло-зеленый фон
)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Заголовок
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.DirectionsRun,
contentDescription = null,
tint = activityColor
)
Text(
text = "Активность",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = activityColor
)
}
Button(
onClick = { /*TODO*/ },
colors = ButtonDefaults.buttonColors(
containerColor = activityColor
)
) {
Icon(Icons.Default.Add, contentDescription = "Начать активность")
Spacer(modifier = Modifier.width(4.dp))
Text("Начать")
}
}
// Отображение калорий
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(vertical = 16.dp)
) {
Text(
text = caloriesBurned,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = activityDescription,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// График сожженных калорий
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.LocalFireDepartment,
contentDescription = null,
tint = Color(0xFFFF9800)
)
Text(
text = "Сожжённые калории (7 дней)",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium
)
}
// График (заглушка, в реальном приложении использовать библиотеку графиков)
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.padding(vertical = 16.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
Text(
"График сожженных калорий",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Divider()
// Аналитика
Text(
text = "Средний расход 416 ккал/день, лучший день — 600 ккал (сб)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// История активностей
InfoCard(
title = "Недавние активности",
content = "Бег 35 мин, Йога 40 мин, Плавание 30 мин, Велосипед 45 мин, Ходьба 60 мин"
)
// Рекомендации
InfoCard(
title = "Рекомендация",
content = "Добавь 1 лёгкую кардио-сессию в среду для более равномерного распределения нагрузки в течение недели."
)
}
}
@Preview(showBackground = true)
@Composable
fun ActivityTabPreview() {
WellSheTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ActivityTab(Modifier.padding(16.dp))
}
}
}

View File

@@ -0,0 +1,184 @@
package kr.smartsoltech.wellshe.ui.body.tabs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.TrendingUp
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import kr.smartsoltech.wellshe.ui.components.InfoCard
import kr.smartsoltech.wellshe.ui.components.ProgressWithLabel
import kr.smartsoltech.wellshe.ui.theme.WaterColor
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
/**
* Содержимое вкладки "Вода" экрана "Тело"
*/
@Composable
fun WaterTab(
modifier: Modifier = Modifier
) {
// Данные для отображения (в реальном приложении должны поступать из ViewModel)
val currentWater = 1800
val targetWater = 2000
val progress = currentWater.toFloat() / targetWater
val percentageText = "${(progress * 100).toInt()}% дневной нормы"
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Основная карточка с водой
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Заголовок
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.WaterDrop,
contentDescription = null,
tint = WaterColor
)
Text(
text = "Вода",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = WaterColor
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { /*TODO*/ }) {
Icon(Icons.Default.Search, contentDescription = "Поиск напитка")
Spacer(modifier = Modifier.width(4.dp))
Text("Поиск")
}
Button(onClick = { /*TODO*/ }) {
Icon(Icons.Default.Add, contentDescription = "Добавить воду")
Spacer(modifier = Modifier.width(4.dp))
Text("Выпито")
}
}
}
// Прогресс воды
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(vertical = 16.dp)
) {
Text(
text = "$currentWater / $targetWater мл",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
ProgressWithLabel(
progress = progress,
label = percentageText,
color = WaterColor
)
}
// Тренд
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.TrendingUp,
contentDescription = null,
tint = WaterColor
)
Text(
text = "Тренд за неделю",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium
)
}
// График (заглушка, в реальном приложении использовать библиотеку графиков)
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.padding(vertical = 16.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
Text(
"График пот<D0BE><D182>ебления воды",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Divider()
// Статистика
Text(
text = "Энергия из напитков: 139 ккал • Сахара: 35 г • Вода: ~2.0 л",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Недавно добавленные напитки
InfoCard(
title = "Недавно добавленные напитки",
content = "Вода 250 мл, Чай зеленый 200 мл, Кофе 150 мл, Сок яблочный 200 мл"
)
// Рекомендации
InfoCard(
title = "Рекомендация",
content = "Старайтесь пить больше воды утром. Это поможет ускорить метаболизм и увеличить энергию на весь день."
)
}
}
@Preview(showBackground = true)
@Composable
fun WaterTabPreview() {
WellSheTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
WaterTab(Modifier.padding(16.dp))
}
}
}

View File

@@ -0,0 +1,176 @@
package kr.smartsoltech.wellshe.ui.body.tabs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Scale
import androidx.compose.material.icons.filled.Spa
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import kr.smartsoltech.wellshe.ui.components.InfoCard
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
/**
* Содержимое вкладки "Вес" экрана "Тело"
*/
@Composable
fun WeightTab(
modifier: Modifier = Modifier
) {
// Данные для отображения (в реальном приложении должны поступать из ViewModel)
val currentWeight = "58.4 кг"
val weightChange = "0.6 кг за неделю"
val weightColor = Color(0xFFEC407A) // Розовый для веса
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Основная карточка с весом
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFCE4EC).copy(alpha = 0.3f) // Светло-розовый фон
)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Заголовок
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Scale,
contentDescription = null,
tint = weightColor
)
Text(
text = "Вес",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = weightColor
)
}
Button(
onClick = { /*TODO*/ },
colors = ButtonDefaults.buttonColors(
containerColor = weightColor
)
) {
Icon(Icons.Default.Add, contentDescription = "Добавить запись веса")
Spacer(modifier = Modifier.width(4.dp))
Text("Добавить")
}
}
// Отображение веса
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(vertical = 16.dp)
) {
Text(
text = currentWeight,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = weightChange,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// График динамики веса (заглушка)
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Spa,
contentDescription = null,
tint = weightColor
)
Text(
text = "Динамика веса",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium
)
}
// График (заглушка, в реальном приложении использовать библиотеку графиков)
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.padding(vertical = 16.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
Text(
"График изменения веса",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Divider()
// Прогноз
Text(
text = "Прогноз: при сохранении режима целевой вес 57.5 кг через 3 недели",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// История записей
InfoCard(
title = "История записей",
content = "Пн: 58.9 кг, Вт: 58.7 кг, Ср: 58.6 кг, Чт: 58.5 кг, Пт: 58.4 кг, Сб: 58.4 кг, Вс: 58.3 кг"
)
// Рекомендации
InfoCard(
title = "Анализ",
content = "Стабильное снижение веса показывает хороший прогресс. Для оптимального результата рекомендуется пить больше воды и добавить умеренные кардио-нагрузки."
)
}
}
@Preview(showBackground = true)
@Composable
fun WeightTabPreview() {
WellSheTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
WeightTab(Modifier.padding(16.dp))
}
}
}

View File

@@ -0,0 +1,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
)
)
}
}

View File

@@ -0,0 +1,100 @@
package kr.smartsoltech.wellshe.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocalDrink
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@Composable
fun WaterIntakeDialog(
onDismiss: () -> Unit,
onConfirm: (Int) -> Unit
) {
var waterAmount by remember { mutableStateOf("200") }
var isError by remember { mutableStateOf(false) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.LocalDrink,
contentDescription = "Добавить воду",
tint = Color(0xFF3B82F6),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Сколько воды вы выпили?",
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = waterAmount,
onValueChange = { value ->
waterAmount = value.filter { it.isDigit() }
isError = waterAmount.isEmpty() || waterAmount.toIntOrNull() == null
},
label = { Text("Количество (мл)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = isError,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (isError) {
Text(
text = "Введите корректное количество",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onDismiss) {
Text("Отмена")
}
Button(
onClick = {
val amount = waterAmount.toIntOrNull() ?: return@Button
onConfirm(amount)
},
enabled = !isError && waterAmount.isNotEmpty()
) {
Text("Добавить")
}
}
}
}
}
}

View File

@@ -0,0 +1,122 @@
package kr.smartsoltech.wellshe.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Scale
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@Composable
fun WeightInputDialog(
onDismiss: () -> Unit,
onConfirm: (Float) -> Unit,
initialWeight: Float = 60f
) {
var weightInput by remember { mutableStateOf(initialWeight.toString()) }
var isError by remember { mutableStateOf(false) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Scale,
contentDescription = "Добавить вес",
tint = Color(0xFFEC4899),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Запись веса",
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = weightInput,
onValueChange = { value ->
// Разрешаем только цифры и одну точку
val filtered = value.filter { it.isDigit() || it == '.' }
// Проверяем, что точка только одна
val dotCount = filtered.count { it == '.' }
weightInput = if (dotCount > 1) {
filtered.substring(0, filtered.lastIndexOf('.'))
} else {
filtered
}
// Проверка валидности
isError = try {
val weight = weightInput.toFloat()
weight <= 0f || weight > 300f // Разумные ограничения на вес
} catch (e: NumberFormatException) {
true
}
},
label = { Text("Вес (кг)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
isError = isError,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (isError) {
Text(
text = "Введите корректное значение веса (1-300 кг)",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onDismiss) {
Text("Отмена")
}
Button(
onClick = {
try {
val weight = weightInput.toFloat()
if (weight > 0f && weight <= 300f) {
onConfirm(weight)
}
} catch (e: NumberFormatException) {
// Ошибка уже отображается через isError
}
},
enabled = !isError && weightInput.isNotEmpty()
) {
Text("Сохранить")
}
}
}
}
}
}

View File

@@ -0,0 +1,134 @@
package kr.smartsoltech.wellshe.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@Composable
fun WorkoutSelectionDialog(
onDismiss: () -> Unit,
onConfirm: (Long) -> Unit
) {
// Тестовый список тренировок
val workouts = listOf(
Workout(1L, "Бег", "Кардио", 8.5),
Workout(2L, "Ходьба", "Кардио", 4.2),
Workout(3L, "Йога", "Растяжка", 3.0),
Workout(4L, "Силовая тренировка", "Силовая", 6.0),
Workout(5L, "Плавание", "Кардио", 7.0),
Workout(6L, "Велосипед", "Кардио", 7.5)
)
var selectedWorkout by remember { mutableStateOf<Workout?>(null) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.FitnessCenter,
contentDescription = "Выбор тренировки",
tint = Color(0xFF10B981), // Зеленый цвет
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Выберите тренировку",
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
) {
items(workouts) { workout ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = workout == selectedWorkout,
onClick = { selectedWorkout = workout }
)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = workout == selectedWorkout,
onClick = { selectedWorkout = workout }
)
Column(
modifier = Modifier.padding(start = 16.dp)
) {
Text(
text = workout.name,
fontWeight = FontWeight.Medium
)
Text(
text = "${workout.type} • ~${workout.caloriesPerMinute} ккал/мин",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
}
}
Divider()
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onDismiss) {
Text("Отмена")
}
Button(
onClick = {
selectedWorkout?.let { onConfirm(it.id) }
},
enabled = selectedWorkout != null
) {
Text("Начать")
}
}
}
}
}
}
data class Workout(
val id: Long,
val name: String,
val type: String,
val caloriesPerMinute: Double
)

View File

@@ -1,824 +1,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 ->
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
PrimaryPinkLight.copy(alpha = 0.3f),
NeutralWhite
)
)
),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
CycleOverviewCard(
currentPhase = uiState.currentPhase,
daysUntilNext = uiState.daysUntilNextPeriod,
cycleDay = uiState.currentCycleDay,
cycleLength = uiState.cycleLength
)
}
item {
CycleTrackerCard(
isPeriodActive = uiState.isPeriodActive,
onStartPeriod = viewModel::startPeriod,
onEndPeriod = viewModel::endPeriod,
onLogSymptoms = { viewModel.toggleSymptomsEdit() }
)
}
item {
if (uiState.showSymptomsEdit) {
SymptomsTrackingCard(
selectedSymptoms = uiState.todaySymptoms,
selectedMood = uiState.todayMood,
onSymptomsUpdate = viewModel::updateSymptoms,
onMoodUpdate = viewModel::updateMood,
onSave = viewModel::saveTodayData
)
}
}
item {
CyclePredictionCard(
nextPeriodDate = uiState.nextPeriodDate,
ovulationDate = uiState.ovulationDate,
fertilityWindow = uiState.fertilityWindow
)
}
item {
CycleInsightsCard(
insights = uiState.insights,
averageCycleLength = uiState.averageCycleLength
)
}
item {
PeriodHistoryCard(
recentPeriods = uiState.recentPeriods,
onPeriodClick = { /* TODO: Navigate to period details */ }
)
}
item {
Spacer(modifier = Modifier.height(80.dp))
}
}
if (uiState.error != null) {
LaunchedEffect(uiState.error) {
viewModel.clearError()
}
}
}
@Composable
private fun CycleOverviewCard(
currentPhase: String,
daysUntilNext: Int,
cycleDay: Int,
cycleLength: Int,
modifier: Modifier = Modifier
) {
val progress by animateFloatAsState(
targetValue = if (cycleLength > 0) (cycleDay.toFloat() / cycleLength).coerceIn(0f, 1f) else 0f,
animationSpec = tween(durationMillis = 1000)
)
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
shape = RoundedCornerShape(20.dp)
) {
Column( Column(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxSize()
.padding(24.dp), .padding(paddingValues)
horizontalAlignment = Alignment.CenterHorizontally .padding(16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( // Календарь цикла
text = "Текущий цикл", CycleCalendarCard(
style = MaterialTheme.typography.headlineSmall.copy( month = uiState.month,
fontWeight = FontWeight.Bold, onPrev = { viewModel.prevMonth() },
color = TextPrimary onNext = { viewModel.nextMonth() },
) forecast = uiState.forecast
)
Spacer(modifier = Modifier.height(24.dp))
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center
) {
CycleProgressIndicator(
progress = progress,
currentPhase = currentPhase,
modifier = Modifier.fillMaxSize()
)
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "День $cycleDay",
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = PrimaryPink
)
)
Text(
text = "из $cycleLength дней",
style = MaterialTheme.typography.bodyMedium.copy(
color = TextSecondary
)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = currentPhase,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
color = PrimaryPink
)
)
}
}
Spacer(modifier = Modifier.height(20.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = PrimaryPinkLight.copy(alpha = 0.1f)
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (daysUntilNext > 0) {
"До следующих месячных"
} else {
"Месячные уже начались"
},
style = MaterialTheme.typography.bodyMedium.copy(
color = TextSecondary
)
)
Text(
text = if (daysUntilNext > 0) {
"$daysUntilNext дней"
} else {
"Отметьте начало"
},
style = MaterialTheme.typography.headlineSmall.copy(
fontWeight = FontWeight.Bold,
color = PrimaryPink
)
)
}
}
}
}
}
@Composable
private fun CycleProgressIndicator(
progress: Float,
currentPhase: String,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier) {
val center = this.center
val radius = size.minDimension / 2 - 20.dp.toPx()
val strokeWidth = 12.dp.toPx()
// Фон круга
drawCircle(
color = PrimaryPinkLight.copy(alpha = 0.3f),
radius = radius,
center = center,
style = androidx.compose.ui.graphics.drawscope.Stroke(width = strokeWidth)
)
// Прогресс-дуга
val sweepAngle = 360f * progress
drawArc(
brush = Brush.sweepGradient(
colors = listOf(
PrimaryPink,
PrimaryPinkDark
)
),
startAngle = -90f,
sweepAngle = sweepAngle,
useCenter = false,
style = androidx.compose.ui.graphics.drawscope.Stroke(
width = strokeWidth,
cap = androidx.compose.ui.graphics.StrokeCap.Round
),
topLeft = Offset(center.x - radius, center.y - radius),
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2)
)
// Индикаторы фаз цикла
drawPhaseIndicators(center, radius, strokeWidth)
}
}
private fun DrawScope.drawPhaseIndicators(center: Offset, radius: Float, strokeWidth: Float) {
val phases = listOf(
Triple(0f, "М", Color(0xFFE91E63)), // Менструация
Triple(90f, "Ф", Color(0xFF9C27B0)), // Фолликулярная
Triple(180f, "О", Color(0xFF673AB7)), // Овуляция
Triple(270f, "Л", Color(0xFF3F51B5)) // Лютеиновая
)
phases.forEach { (angle, label, color) ->
val angleRad = Math.toRadians(angle.toDouble())
val x = center.x + (radius + strokeWidth / 2) * cos(angleRad).toFloat()
val y = center.y + (radius + strokeWidth / 2) * sin(angleRad).toFloat()
drawCircle(
color = color,
radius = 8.dp.toPx(),
center = Offset(x, y)
)
}
}
@Composable
private fun CycleTrackerCard(
isPeriodActive: Boolean,
onStartPeriod: () -> Unit,
onEndPeriod: () -> Unit,
onLogSymptoms: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Text(
text = "Отслеживание",
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
color = TextPrimary
),
modifier = Modifier.padding(bottom = 16.dp)
) )
// Карточки с прогнозом
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 = uiState.forecast?.nextPeriodStart?.format(
modifier = Modifier.weight(1f), DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
colors = ButtonDefaults.buttonColors( ) ?: "",
containerColor = Color(0xFFFF5722) tone = Color(0xFF2196F3),
), modifier = Modifier.weight(1f)
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( StatCard(
onClick = onLogSymptoms, title = "Овуляция",
modifier = Modifier.weight(1f), value = uiState.forecast?.nextOvulation?.format(
shape = RoundedCornerShape(12.dp) DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
) { ) ?: "",
Icon( tone = Color(0xFF4CAF50),
imageVector = Icons.Default.Assignment, modifier = Modifier.weight(1f)
contentDescription = null, )
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Симптомы")
}
} }
// Быстрые действия
QuickActionsCard(
onMarkStart = { viewModel.markPeriodStart() },
onMarkEnd = { viewModel.markPeriodEnd() },
onAddSymptom = { viewModel.addSymptom() },
onAddNote = { viewModel.addNote() }
)
// Информационные карточки
InfoCard(
title = "Симптомы сегодня",
content = uiState.todaySymptoms
)
InfoCard(
title = "Прогноз недели",
content = uiState.weekInsight
)
} }
} }
} }
@Preview(showBackground = true)
@Composable @Composable
private fun SymptomsTrackingCard( fun CycleScreenPreview() {
selectedSymptoms: List<String>, val previewSettings = CycleSettings(
selectedMood: String, baselineLength = 28,
onSymptomsUpdate: (List<String>) -> Unit, periodLength = 5,
onMoodUpdate: (String) -> Unit, lutealDays = 14,
onSave: () -> Unit, lastPeriodStart = LocalDate.of(2025, 10, 1)
modifier: Modifier = Modifier )
) { val previewForecast = computeForecast(previewSettings)
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Text(
text = "Симптомы и настроение",
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
color = TextPrimary
),
modifier = Modifier.padding(bottom = 16.dp)
)
// Симптомы val previewState = CycleViewModel.UiState(
Text( month = YearMonth.now(),
text = "Симптомы", forecast = previewForecast,
style = MaterialTheme.typography.titleMedium.copy( todaySymptoms = "Лёгкая усталость, аппетит выше обычного",
fontWeight = FontWeight.Medium, weekInsight = "ПМС с 13 окт; окно фертильности: 29 сен04 окт",
color = TextPrimary cycleSettings = previewSettings
), )
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)
)
WellSheTheme {
Surface {
Column( Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
PredictionItem( CycleCalendarCard(
icon = Icons.Default.CalendarMonth, month = previewState.month,
title = "Следующие месячные", onPrev = { },
date = nextPeriodDate, onNext = { },
color = PrimaryPink forecast = previewState.forecast
) )
PredictionItem( Row(
icon = Icons.Default.Favorite, modifier = Modifier.fillMaxWidth(),
title = "Овуляция", horizontalArrangement = Arrangement.spacedBy(12.dp)
date = ovulationDate, ) {
color = Color(0xFF9C27B0) StatCard(
) title = "След. менструация",
value = previewState.forecast?.nextPeriodStart?.format(
if (fertilityWindow != null) { DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
Row( ) ?: "",
modifier = Modifier.fillMaxWidth(), tone = Color(0xFF2196F3),
verticalAlignment = Alignment.CenterVertically modifier = Modifier.weight(1f)
) {
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) { StatCard(
Text( title = "Овуляция",
text = "Средняя длина цикла: %.1f дней".format(averageCycleLength), value = previewState.forecast?.nextOvulation?.format(
style = MaterialTheme.typography.bodyMedium.copy( DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
color = TextPrimary, ) ?: "",
fontWeight = FontWeight.Medium tone = Color(0xFF4CAF50),
), modifier = Modifier.weight(1f)
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
)
)
}
} }
QuickActionsCard(
onMarkStart = { },
onMarkEnd = { },
onAddSymptom = { },
onAddNote = { }
)
InfoCard(
title = "Симптомы сегодня",
content = previewState.todaySymptoms
)
InfoCard(
title = "Прогноз недели",
content = previewState.weekInsight
)
} }
} }
} }
} }
@Preview(showBackground = true, locale = "ru")
@Composable @Composable
private fun PeriodHistoryCard( fun CycleScreenPreviewRussian() {
recentPeriods: List<CyclePeriodEntity>, CycleScreenPreview()
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))
}
}
}
}
}
} }
@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
private fun PeriodHistoryItem( fun CycleScreenPreviewDark() {
period: CyclePeriodEntity, CycleScreenPreview()
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.CalendarMonth,
contentDescription = null,
tint = PrimaryPink,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = period.startDate.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")),
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Medium,
color = TextPrimary
)
)
val duration = if (period.endDate != null) {
ChronoUnit.DAYS.between(period.startDate, period.endDate) + 1
} else {
null
}
Text(
text = if (duration != null) {
"Продолжительность: $duration дней"
} else {
"В процессе"
},
style = MaterialTheme.typography.bodySmall.copy(
color = TextSecondary
)
)
}
Text(
text = period.flow,
style = MaterialTheme.typography.bodySmall.copy(
color = TextSecondary
)
)
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Просмотреть",
tint = TextSecondary,
modifier = Modifier.size(20.dp)
)
}
} }

View File

@@ -0,0 +1,93 @@
package kr.smartsoltech.wellshe.ui.cycle
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kr.smartsoltech.wellshe.model.CycleSettings
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
@Composable
fun CycleSettingsDialog(
initialSettings: CycleSettings,
onSave: (CycleSettings) -> Unit,
onDismiss: () -> Unit
) {
var baselineLength by remember { mutableStateOf(initialSettings.baselineLength.toString()) }
var periodLength by remember { mutableStateOf(initialSettings.periodLength.toString()) }
var lutealDays by remember { mutableStateOf(initialSettings.lutealDays.toString()) }
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
var lastPeriodStartStr by remember {
mutableStateOf(initialSettings.lastPeriodStart.format(formatter))
}
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
Button(onClick = {
try {
val newSettings = CycleSettings(
baselineLength = baselineLength.toIntOrNull() ?: initialSettings.baselineLength,
periodLength = periodLength.toIntOrNull() ?: initialSettings.periodLength,
lutealDays = lutealDays.toIntOrNull() ?: initialSettings.lutealDays,
lastPeriodStart = try {
LocalDate.parse(lastPeriodStartStr, formatter)
} catch (e: DateTimeParseException) {
initialSettings.lastPeriodStart
}
)
onSave(newSettings)
} catch (e: Exception) {
// В случае ошибки сохраняем исходные настройки
onSave(initialSettings)
}
}) {
Text("Сохранить")
}
},
dismissButton = {
Button(onClick = onDismiss) { Text("Отмена") }
},
title = { Text("Настройки цикла") },
text = {
Column {
OutlinedTextField(
value = baselineLength,
onValueChange = { baselineLength = it },
label = { Text("Длина цикла (дней)") },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
OutlinedTextField(
value = periodLength,
onValueChange = { periodLength = it },
label = { Text("Длина месячных (дней)") },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
OutlinedTextField(
value = lutealDays,
onValueChange = { lutealDays = it },
label = { Text("Лютеиновая фаза (дней)") },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
OutlinedTextField(
value = lastPeriodStartStr,
onValueChange = { lastPeriodStartStr = it },
label = { Text("Первый день последних месячных (гггг-мм-дд)") },
modifier = Modifier.fillMaxWidth()
)
}
}
)
}

View File

@@ -0,0 +1,41 @@
package kr.smartsoltech.wellshe.ui.cycle
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
import kr.smartsoltech.wellshe.model.CycleSettings
import java.time.LocalDate
data class CycleUiState(
// Основные параметры цикла
val currentPhase: String = "Фолликулярная",
val currentCycleDay: Int = 1,
val cycleLength: Int = 28,
val daysUntilNextPeriod: Int = 0,
val isPeriodActive: Boolean = false,
// Прогнозы
val nextPeriodDate: LocalDate? = null,
val ovulationDate: LocalDate? = null,
val fertilityWindow: Pair<LocalDate, LocalDate>? = null,
// Симптомы и настроение сегодня
val todaySymptoms: List<String> = emptyList(),
val todayMood: String = "",
val showSymptomsEdit: Boolean = false,
// Аналитика
val averageCycleLength: Float = 0f,
val insights: List<String> = emptyList(),
val recentPeriods: List<CyclePeriodEntity> = emptyList(),
// Параметры цикла пользователя
val cycleSettings: CycleSettings = CycleSettings(
baselineLength = 28,
periodLength = 5,
lutealDays = 14,
lastPeriodStart = LocalDate.now().minusDays(14)
),
// Состояние загрузки и ошибки
val isLoading: Boolean = false,
val error: String? = null
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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 "Пометить как атипичный")
}
}
}
}
}
}
}
}
}
// Для полной реализации всех секций потребуется продолжить в новом файле
// Из-за ограничений размера файла

View File

@@ -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("Импортировать")
}
}
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 toggleStepTracking() {
viewModelScope.launch {
val isTracking = _uiState.value.isTrackingSteps
if (isTracking) {
repository.stopStepTracking()
} else {
repository.startStepTracking()
}
_uiState.value = _uiState.value.copy(isTrackingSteps = !isTracking)
}
}
fun startWorkout(type: String, notes: String = "") {
viewModelScope.launch {
val workout = WorkoutSession(
type = type,
date = LocalDate.now(),
startTime = LocalDateTime.now(),
notes = notes
)
repository.startWorkout(workout)
loadFitnessData() // Перезагружаем данные
}
}
fun endWorkout(workoutId: Long, duration: Int, caloriesBurned: Int, distance: Float = 0f) {
viewModelScope.launch {
repository.endWorkout(workoutId, duration, caloriesBurned, distance)
loadFitnessData() // Перезагружаем данные
}
}
fun startStepTracking() { fun startStepTracking() {
viewModelScope.launch { viewModelScope.launch {
try { try {
_uiState.value = _uiState.value.copy(isTrackingSteps = true)
repository.startStepTracking() repository.startStepTracking()
_uiState.value = _uiState.value.copy(isTrackingSteps = true)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message) _uiState.value = _uiState.value.copy(error = e.message)
} }
} }
} }
fun stopStepTracking() {
viewModelScope.launch {
try {
repository.stopStepTracking()
_uiState.value = _uiState.value.copy(isTrackingSteps = false)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
fun startWorkout(workoutType: String) {
viewModelScope.launch {
try {
val workout = WorkoutSession(
id = 0,
type = workoutType,
date = LocalDate.now(),
startTime = LocalDateTime.now(),
duration = 0,
caloriesBurned = 0,
distance = 0f
)
repository.startWorkout(workout)
loadFitnessData() // Перезагружаем данные
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
fun endWorkout(workoutId: Long, duration: Int, caloriesBurned: Int, distance: Float) {
viewModelScope.launch {
try {
repository.endWorkout(workoutId, duration, caloriesBurned, distance)
loadFitnessData() // Перезагружаем данные
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
fun updateSteps(steps: Int) {
viewModelScope.launch {
try {
repository.updateTodaySteps(steps)
val calories = calculateCaloriesFromSteps(steps)
val distance = calculateDistanceFromSteps(steps)
_uiState.value = _uiState.value.copy(
todaySteps = steps,
caloriesBurned = calories,
distance = distance
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
private fun calculateCaloriesFromSteps(steps: Int): Int {
// Приблизительная формула: 1 шаг = 0.04 калории
return (steps * 0.04).toInt()
}
private fun calculateDistanceFromSteps(steps: Int): Float {
// Приблизительная формула: 1 шаг = 0.8 метра
return (steps * 0.8f) / 1000f // конвертируем в километры
}
fun clearError() { 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 // Переводим в км
}
} }

View File

@@ -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) {
actions = { Icon(Icons.Default.MonitorWeight, contentDescription = null)
IconButton(onClick = { viewModel.toggleEditMode() }) { Spacer(Modifier.width(8.dp))
Icon( Text("Вес")
imageVector = if (uiState.isEditMode) Icons.Default.Save else Icons.Default.Edit,
contentDescription = if (uiState.isEditMode) "Сохранить" else "Редактировать",
tint = SuccessGreen
)
} }
}, Button(onClick = onSport) {
colors = TopAppBarDefaults.topAppBarColors( Icon(Icons.Default.FitnessCenter, contentDescription = null)
containerColor = NeutralWhite.copy(alpha = 0.95f) Spacer(Modifier.width(8.dp))
) Text("Спорт")
) }
}
}
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
} )
} )
}
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(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Заголовок
Text(
text = "Профиль",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
// Карточка целей и единиц измерения
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = ProfileTabColor.copy(alpha = 0.3f)
) )
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
if (uiState.isLoading) { Column(
Box( modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Цели и единицы измерения",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
// Цель воды
Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
CircularProgressIndicator() 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
)
)
} }
} else {
ProfileContent( // Единицы веса
user = uiState.user, Row(
onUpdateProfile = { user -> modifier = Modifier.fillMaxWidth(),
viewModel.updateProfile(user) 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("Сохранить")
}
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("Экспорт настроек")
}
}
}
}
/**
* Селектор единиц измерения
*/
@Composable
fun UnitSelector(
options: List<String>,
selectedOption: String,
onOptionSelected: (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
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
} }
) )
} }
uiState.error?.let { error ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = error,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
} }
} }
} }
/**
* Данные для интеграций
*/
data class Integration(
val title: String,
val enabled: Boolean
)
@Preview(showBackground = true)
@Composable @Composable
private fun ProfileContent( fun ProfileScreenPreview() {
user: kr.smartsoltech.wellshe.domain.model.User, WellSheTheme {
onUpdateProfile: (kr.smartsoltech.wellshe.domain.model.User) -> Unit Surface(
) { modifier = Modifier.fillMaxSize(),
var name by remember { mutableStateOf(user.name) } color = MaterialTheme.colorScheme.background
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(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( ProfileScreen()
text = "Основная информация",
style = MaterialTheme.typography.headlineSmall
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Имя") },
modifier = Modifier.fillMaxWidth()
)
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("Сохранить")
}
}
}
Card {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Цели",
style = MaterialTheme.typography.headlineSmall
)
Text("Вода: ${user.dailyWaterGoal} л/день")
Text("Шаги: ${user.dailyStepsGoal} шагов/день")
Text("Калории: ${user.dailyCaloriesGoal} ккал/день")
Text("Сон: ${user.dailySleepGoal} часов/день")
} }
} }
} }

View File

@@ -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,70 +422,108 @@ 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,
subtitle: String,
value: Int,
onValueChange: (Int) -> Unit,
suffix: String,
modifier: Modifier = Modifier
) {
val keyboardController = LocalSoftwareKeyboardController.current
var textValue by remember(value) { mutableStateOf(value.toString()) }
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Medium,
color = TextPrimary
)
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium.copy(
color = TextSecondary
),
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() }
)
)
}
}
@Composable
private fun SettingsDecimalField(
title: String, title: String,
subtitle: String, subtitle: String,
value: Float, value: Float,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int,
onValueChange: (Float) -> Unit, onValueChange: (Float) -> Unit,
valueFormatter: (Float) -> String, suffix: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column( val keyboardController = LocalSoftwareKeyboardController.current
modifier = modifier.fillMaxWidth() var textValue by remember(value) { mutableStateOf("%.1f".format(value)) }
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Medium,
color = TextPrimary
)
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall.copy(
color = TextSecondary
)
)
}
Text( Column(modifier = modifier.fillMaxWidth()) {
text = valueFormatter(value), Text(
style = MaterialTheme.typography.bodyMedium.copy( text = title,
fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge.copy(
color = PrimaryPink fontWeight = FontWeight.Medium,
) color = TextPrimary
) )
} )
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium.copy(
color = TextSecondary
),
modifier = Modifier.padding(bottom = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField(
value = textValue,
Slider( onValueChange = { newValue ->
value = value, textValue = newValue
onValueChange = onValueChange, newValue.toFloatOrNull()?.let { floatValue ->
valueRange = valueRange, onValueChange(floatValue)
steps = steps, }
colors = SliderDefaults.colors( },
thumbColor = PrimaryPink, suffix = { Text(suffix) },
activeTrackColor = PrimaryPink, singleLine = true,
inactiveTrackColor = Color.Gray.copy(alpha = 0.3f) 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)
) )
} }
} }

View File

@@ -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,61 +100,74 @@ class SettingsViewModel @Inject constructor(
} }
} }
// Настройки цикла
fun updateCycleLength(length: Int) { fun updateCycleLength(length: Int) {
viewModelScope.launch { if (length in 21..35) {
try { viewModelScope.launch {
repository.updateCycleLength(length) try {
_uiState.value = _uiState.value.copy(cycleLength = length) repository.updateCycleLength(length)
} catch (e: Exception) { _uiState.value = _uiState.value.copy(cycleLength = length)
_uiState.value = _uiState.value.copy(error = e.message) } catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
} }
} }
} }
fun updatePeriodLength(length: Int) { fun updatePeriodLength(length: Int) {
viewModelScope.launch { if (length in 3..8) {
try { viewModelScope.launch {
repository.updatePeriodLength(length) try {
_uiState.value = _uiState.value.copy(periodLength = length) repository.updatePeriodLength(length)
} catch (e: Exception) { _uiState.value = _uiState.value.copy(periodLength = length)
_uiState.value = _uiState.value.copy(error = e.message) } catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
} }
} }
} }
// Цели
fun updateWaterGoal(goal: Float) { fun updateWaterGoal(goal: Float) {
viewModelScope.launch { if (goal in 1.5f..4.0f) {
try { viewModelScope.launch {
repository.updateWaterGoal(goal) try {
_uiState.value = _uiState.value.copy(waterGoal = goal) repository.updateWaterGoal(goal)
} catch (e: Exception) { _uiState.value = _uiState.value.copy(waterGoal = goal)
_uiState.value = _uiState.value.copy(error = e.message) } catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
} }
} }
} }
fun updateStepsGoal(goal: Int) { fun updateStepsGoal(goal: Int) {
viewModelScope.launch { if (goal in 5000..20000) {
try { viewModelScope.launch {
repository.updateStepsGoal(goal) try {
_uiState.value = _uiState.value.copy(stepsGoal = goal) repository.updateStepsGoal(goal)
} catch (e: Exception) { _uiState.value = _uiState.value.copy(stepsGoal = goal)
_uiState.value = _uiState.value.copy(error = e.message) } catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
} }
} }
} }
fun updateSleepGoal(goal: Float) { fun updateSleepGoal(goal: Float) {
viewModelScope.launch { if (goal in 6.0f..10.0f) {
try { viewModelScope.launch {
repository.updateSleepGoal(goal) try {
_uiState.value = _uiState.value.copy(sleepGoal = goal) repository.updateSleepGoal(goal)
} catch (e: Exception) { _uiState.value = _uiState.value.copy(sleepGoal = goal)
_uiState.value = _uiState.value.copy(error = e.message) } catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
} }
} }
} }
// Внешний вид
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)
} }

View File

@@ -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) // Зелёный для активности

View File

@@ -0,0 +1,10 @@
package kr.smartsoltech.wellshe.ui.theme
import androidx.compose.ui.graphics.Color
// Этот файл больше не используется - все цвета перенесены в Color.kt
// Файл оставлен только для обратной совместимости и будет удален в будущих версиях
@Deprecated("Используйте цвета из Color.kt вместо этого файла")
object CustomColorsDeprecated {
// Пустой объект для предотвращения ошибок при компиляции
}

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://jitpack.io") } // Добавляем репозиторий JitPack
} }
} }