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>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-10-12T11:23:35.923427438Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=R3CT80VPBQZ" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

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"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Добавляем путь для экспорта схемы Room
javaCompileOptions {
annotationProcessorOptions {
arguments += mapOf(
"room.schemaLocation" to "$projectDir/schemas",
"room.incremental" to "true"
)
}
}
}
buildTypes {
@@ -67,6 +77,11 @@ dependencies {
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("com.google.code.gson:gson:2.10.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
implementation("com.squareup.moshi:moshi:1.15.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
implementation("com.squareup.moshi:moshi-adapters:1.15.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
testImplementation(libs.junit)
testImplementation("io.mockk:mockk:1.13.8")

File diff suppressed because it is too large Load Diff

View File

@@ -5,30 +5,79 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import kr.smartsoltech.wellshe.data.entity.*
import kr.smartsoltech.wellshe.data.dao.*
import kr.smartsoltech.wellshe.data.converter.DateConverters
import java.time.LocalDate
import androidx.room.TypeConverter
@Database(
entities = [
// Основные сущности
WaterLogEntity::class,
WorkoutEntity::class,
SleepLogEntity::class,
CyclePeriodEntity::class,
HealthRecordEntity::class,
WorkoutEntity::class,
CalorieEntity::class,
StepsEntity::class,
UserProfileEntity::class
UserProfileEntity::class,
WorkoutSession::class,
WorkoutSessionParam::class,
WorkoutEvent::class,
CyclePeriodEntity::class,
HealthRecordEntity::class,
// Новые сущности модуля "Настройки цикла"
CycleSettingsEntity::class,
CycleHistoryEntity::class,
CycleForecastEntity::class,
// Дополнительные сущности из BodyEntities.kt
Nutrient::class,
Beverage::class,
BeverageServing::class,
BeverageServingNutrient::class,
WaterLog::class,
BeverageLog::class,
BeverageLogNutrient::class,
WeightLog::class,
Exercise::class,
ExerciseParam::class,
ExerciseFormula::class,
ExerciseFormulaVar::class,
CatalogVersion::class
],
version = 1,
exportSchema = false
version = 2,
exportSchema = true
)
@TypeConverters(DateConverters::class)
@TypeConverters(LocalDateConverter::class, InstantConverter::class, StringListConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun waterLogDao(): WaterLogDao
abstract fun workoutDao(): WorkoutDao
abstract fun sleepLogDao(): SleepLogDao
abstract fun cyclePeriodDao(): CyclePeriodDao
abstract fun healthRecordDao(): HealthRecordDao
abstract fun workoutDao(): WorkoutDao
abstract fun calorieDao(): CalorieDao
abstract fun stepsDao(): StepsDao
abstract fun userProfileDao(): UserProfileDao
abstract fun cyclePeriodDao(): CyclePeriodDao
abstract fun healthRecordDao(): HealthRecordDao
// Новые DAO для модуля "Настройки цикла"
abstract fun cycleSettingsDao(): CycleSettingsDao
abstract fun cycleHistoryDao(): CycleHistoryDao
abstract fun cycleForecastDao(): CycleForecastDao
// Дополнительные DAO для repo
abstract fun beverageLogDao(): BeverageLogDao
abstract fun beverageLogNutrientDao(): BeverageLogNutrientDao
abstract fun beverageServingNutrientDao(): BeverageServingNutrientDao
abstract fun weightLogDao(): WeightLogDao
abstract fun workoutSessionDao(): WorkoutSessionDao
abstract fun workoutSessionParamDao(): WorkoutSessionParamDao
abstract fun workoutEventDao(): WorkoutEventDao
abstract fun exerciseDao(): ExerciseDao
abstract fun exerciseFormulaDao(): ExerciseFormulaDao
abstract fun exerciseFormulaVarDao(): ExerciseFormulaVarDao
}
class LocalDateConverter {
@TypeConverter
fun fromTimestamp(value: Long?): LocalDate? = value?.let { java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault()).toLocalDate() }
@TypeConverter
fun dateToTimestamp(date: LocalDate?): Long? = date?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
}

View File

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

View File

@@ -0,0 +1,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 java.time.LocalDate
@Dao
interface WaterLogDao {
@Query("SELECT * FROM water_logs WHERE date = :date ORDER BY timestamp DESC")
fun getWaterLogsForDate(date: LocalDate): Flow<List<WaterLogEntity>>
@Query("SELECT SUM(amount) FROM water_logs WHERE date = :date")
suspend fun getTotalWaterForDate(date: LocalDate): Int?
@Insert
suspend fun insertWaterLog(waterLog: WaterLogEntity)
@Delete
suspend fun deleteWaterLog(waterLog: WaterLogEntity)
@Query("SELECT * FROM water_logs WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
fun getWaterLogsForPeriod(startDate: LocalDate, endDate: LocalDate): Flow<List<WaterLogEntity>>
}
@Dao
interface CyclePeriodDao {
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC")
fun getAllPeriods(): Flow<List<CyclePeriodEntity>>
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT 1")
suspend fun getLastPeriod(): CyclePeriodEntity?
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT 1")
fun getCurrentPeriod(): Flow<CyclePeriodEntity?>
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT :limit")
fun getRecentPeriods(limit: Int): Flow<List<CyclePeriodEntity>>
@Insert
suspend fun insertPeriod(period: CyclePeriodEntity)
@Update
suspend fun updatePeriod(period: CyclePeriodEntity)
@Delete
suspend fun deletePeriod(period: CyclePeriodEntity)
@Query("SELECT * FROM cycle_periods WHERE startDate BETWEEN :startDate AND :endDate")
fun getPeriodsInRange(startDate: LocalDate, endDate: LocalDate): Flow<List<CyclePeriodEntity>>
}
@Dao
interface SleepLogDao {
@Query("SELECT * FROM sleep_logs WHERE date = :date")

View File

@@ -1,36 +1,29 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
import java.time.LocalDate
@Dao
interface HealthRecordDao {
@Query("SELECT * FROM health_records WHERE date = :date")
suspend fun getHealthRecordForDate(date: LocalDate): HealthRecordEntity?
@Query("SELECT * FROM health_records ORDER BY date DESC LIMIT :limit")
fun getRecentHealthRecords(limit: Int = 30): Flow<List<HealthRecordEntity>>
@Query("SELECT * FROM health_records ORDER BY date DESC")
suspend fun getAll(): List<HealthRecordEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertHealthRecord(record: HealthRecordEntity)
suspend fun insert(record: HealthRecordEntity): Long
@Update
suspend fun updateHealthRecord(record: HealthRecordEntity)
suspend fun update(record: HealthRecordEntity)
@Delete
suspend fun deleteHealthRecord(record: HealthRecordEntity)
suspend fun delete(record: HealthRecordEntity)
@Query("DELETE FROM health_records WHERE id = :id")
suspend fun deleteHealthRecordById(id: Long)
@Query("SELECT * FROM health_records WHERE date = :date LIMIT 1")
suspend fun getByDate(date: LocalDate): HealthRecordEntity?
@Query("SELECT * FROM health_records WHERE date BETWEEN :startDate AND :endDate ORDER BY date")
suspend fun getHealthRecordsInRange(startDate: LocalDate, endDate: LocalDate): List<HealthRecordEntity>
@Query("SELECT * FROM health_records ORDER BY date DESC")
fun getAllFlow(): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>>
@Query("SELECT AVG(weight) FROM health_records WHERE weight IS NOT NULL AND date BETWEEN :startDate AND :endDate")
suspend fun getAverageWeight(startDate: LocalDate, endDate: LocalDate): Float?
@Query("SELECT AVG(heartRate) FROM health_records WHERE heartRate IS NOT NULL AND date BETWEEN :startDate AND :endDate")
suspend fun getAverageHeartRate(startDate: LocalDate, endDate: LocalDate): Float?
@Query("SELECT * FROM health_records WHERE date = :date LIMIT 1")
fun getByDateFlow(date: LocalDate): kotlinx.coroutines.flow.Flow<HealthRecordEntity?>
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,18 +13,6 @@ data class WaterLogEntity(
val timestamp: Long = System.currentTimeMillis()
)
@Entity(tableName = "cycle_periods")
data class CyclePeriodEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val startDate: LocalDate,
val endDate: LocalDate?,
val cycleLength: Int = 28,
val flow: String = "medium", // light, medium, heavy
val symptoms: String = "", // JSON строка симптомов
val mood: String = "neutral"
)
@Entity(tableName = "sleep_logs")
data class SleepLogEntity(
@PrimaryKey(autoGenerate = true)
@@ -37,23 +25,6 @@ data class SleepLogEntity(
val notes: String = ""
)
@Entity(tableName = "health_records")
data class HealthRecordEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val date: LocalDate,
val weight: Float? = null,
val heartRate: Int? = null,
val bloodPressureS: Int? = null, // систолическое
val bloodPressureD: Int? = null, // диастолическое
val temperature: Float? = null,
val mood: String = "neutral",
val energyLevel: Int = 5, // 1-10
val stressLevel: Int = 5, // 1-10
val symptoms: String = "", // JSON строка симптомов
val notes: String = ""
)
@Entity(tableName = "workouts")
data class WorkoutEntity(
@PrimaryKey(autoGenerate = true)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kr.smartsoltech.wellshe.data.dao.*
import kr.smartsoltech.wellshe.data.entity.*
import kr.smartsoltech.wellshe.domain.model.*
import kr.smartsoltech.wellshe.domain.model.AppSettings
import kr.smartsoltech.wellshe.domain.model.FitnessData
import kr.smartsoltech.wellshe.domain.model.User
import kr.smartsoltech.wellshe.domain.model.WaterIntake
import kr.smartsoltech.wellshe.domain.model.WorkoutSession
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
@@ -68,18 +73,19 @@ class WellSheRepository @Inject constructor(
}
fun getWaterIntakeForDate(date: LocalDate): Flow<List<WaterIntake>> {
return waterLogDao.getWaterLogsForDate(date).map { entities ->
entities.map { entity ->
return flow {
val entities = waterLogDao.getWaterLogsForDate(date)
emit(entities.map { entity ->
WaterIntake(
id = entity.id,
date = entity.date,
time = LocalTime.ofInstant(
java.time.Instant.ofEpochMilli(entity.timestamp),
java.time.ZoneId.systemDefault()
time = LocalTime.of(
(entity.timestamp % (24 * 60 * 60 * 1000) / (60 * 60 * 1000)).toInt(),
((entity.timestamp % (60 * 60 * 1000)) / (60 * 1000)).toInt()
),
amount = entity.amount / 1000f // конвертируем в литры
)
}
})
}
}
@@ -189,23 +195,32 @@ class WellSheRepository @Inject constructor(
// =================
suspend fun addPeriod(startDate: LocalDate, endDate: LocalDate?, flow: String, symptoms: List<String>, mood: String) {
cyclePeriodDao.insertPeriod(
CyclePeriodEntity(
startDate = startDate,
val period = CyclePeriodEntity(
startDate = startDate,
endDate = endDate,
flow = flow,
symptoms = symptoms,
mood = mood
)
cyclePeriodDao.insert(period)
}
suspend fun updatePeriod(periodId: Long, endDate: LocalDate?, flow: String, symptoms: List<String>, mood: String) {
val periods = cyclePeriodDao.getAll()
val existingPeriod = periods.firstOrNull { it.id == periodId }
if (existingPeriod != null) {
val updatedPeriod = existingPeriod.copy(
endDate = endDate,
flow = flow,
symptoms = symptoms.joinToString(","),
symptoms = symptoms,
mood = mood
)
)
cyclePeriodDao.update(updatedPeriod)
}
}
fun getCurrentCyclePeriod(): Flow<CyclePeriodEntity?> {
return cyclePeriodDao.getCurrentPeriod()
}
fun getRecentPeriods(): Flow<List<CyclePeriodEntity>> {
return cyclePeriodDao.getRecentPeriods(6)
suspend fun getRecentPeriods(): List<CyclePeriodEntity> {
return cyclePeriodDao.getAll().take(6)
}
// =================
@@ -281,13 +296,34 @@ class WellSheRepository @Inject constructor(
// ЗДОРОВЬЕ
// =================
fun getTodayHealthData(): Flow<HealthRecordEntity?> {
// TODO: Реализовать получение данных о здоровье за сегодня
return flowOf(null)
fun getTodayHealthData(): kotlinx.coroutines.flow.Flow<HealthRecordEntity?> {
val today = LocalDate.now()
return healthRecordDao.getByDateFlow(today)
}
suspend fun updateHealthRecord(record: HealthRecord) {
// TODO: Реализовать обновление записи о здоровье
fun getAllHealthRecords(): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>> {
return healthRecordDao.getAllFlow()
}
fun getRecentHealthRecords(limit: Int = 10): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>> {
return healthRecordDao.getAllFlow().map { records: List<HealthRecordEntity> ->
records.sortedByDescending { r -> r.date }.take(limit)
}
}
suspend fun saveHealthRecord(record: HealthRecordEntity) {
if (record.id != 0L) {
healthRecordDao.update(record)
} else {
healthRecordDao.insert(record)
}
}
suspend fun deleteHealthRecord(recordId: Long) {
val record = healthRecordDao.getAll().firstOrNull { it.id == recordId }
if (record != null) {
healthRecordDao.delete(record)
}
}
// =================
@@ -322,7 +358,9 @@ class WellSheRepository @Inject constructor(
}
fun getWaterLogsForDate(date: LocalDate): Flow<List<WaterLogEntity>> {
return waterLogDao.getWaterLogsForDate(date)
return flow {
emit(waterLogDao.getWaterLogsForDate(date))
}
}
}

View File

@@ -10,6 +10,10 @@ import dagger.hilt.components.SingletonComponent
import kr.smartsoltech.wellshe.data.AppDatabase
import kr.smartsoltech.wellshe.data.datastore.DataStoreManager
import kr.smartsoltech.wellshe.data.dao.*
import kr.smartsoltech.wellshe.data.repo.DrinkLogger
import kr.smartsoltech.wellshe.data.repo.WeightRepository
import kr.smartsoltech.wellshe.data.repo.WorkoutService
import kr.smartsoltech.wellshe.data.MIGRATION_1_2
import javax.inject.Singleton
@Module
@@ -28,7 +32,10 @@ object AppModule {
context,
AppDatabase::class.java,
"well_she_db"
).fallbackToDestructiveMigration().build()
)
.addMigrations(MIGRATION_1_2)
.fallbackToDestructiveMigration()
.build()
// DAO providers
@Provides
@@ -55,6 +62,64 @@ object AppModule {
@Provides
fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao()
// DAO для BodyRepo
@Provides
fun provideBeverageLogDao(database: AppDatabase): BeverageLogDao = database.beverageLogDao()
@Provides
fun provideBeverageLogNutrientDao(database: AppDatabase): BeverageLogNutrientDao = database.beverageLogNutrientDao()
@Provides
fun provideBeverageServingNutrientDao(database: AppDatabase): BeverageServingNutrientDao = database.beverageServingNutrientDao()
@Provides
fun provideWeightLogDao(database: AppDatabase): WeightLogDao = database.weightLogDao()
@Provides
fun provideWorkoutSessionDao(database: AppDatabase): WorkoutSessionDao = database.workoutSessionDao()
@Provides
fun provideWorkoutSessionParamDao(database: AppDatabase): WorkoutSessionParamDao = database.workoutSessionParamDao()
@Provides
fun provideWorkoutEventDao(database: AppDatabase): WorkoutEventDao = database.workoutEventDao()
@Provides
fun provideExerciseDao(database: AppDatabase): ExerciseDao = database.exerciseDao()
@Provides
fun provideExerciseFormulaDao(database: AppDatabase): ExerciseFormulaDao = database.exerciseFormulaDao()
@Provides
fun provideExerciseFormulaVarDao(database: AppDatabase): ExerciseFormulaVarDao = database.exerciseFormulaVarDao()
// Repo providers
@Provides
@Singleton
fun provideDrinkLogger(
waterLogDao: WaterLogDao,
beverageLogDao: BeverageLogDao,
beverageLogNutrientDao: BeverageLogNutrientDao,
servingNutrientDao: BeverageServingNutrientDao
): DrinkLogger = DrinkLogger(waterLogDao, beverageLogDao, beverageLogNutrientDao, servingNutrientDao)
@Provides
@Singleton
fun provideWeightRepository(weightLogDao: WeightLogDao): WeightRepository =
WeightRepository(weightLogDao)
@Provides
@Singleton
fun provideWorkoutService(
sessionDao: WorkoutSessionDao,
paramDao: WorkoutSessionParamDao,
eventDao: WorkoutEventDao,
weightRepo: WeightRepository,
formulaDao: ExerciseFormulaDao,
formulaVarDao: ExerciseFormulaVarDao,
exerciseDao: ExerciseDao
): WorkoutService = WorkoutService(sessionDao, paramDao, eventDao, weightRepo, formulaDao, formulaVarDao, exerciseDao)
// Repository
@Provides
@Singleton

View File

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

View File

@@ -1,19 +1,34 @@
package kr.smartsoltech.wellshe.domain.analytics
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
import kr.smartsoltech.wellshe.data.entity.CycleStatsEntity
import java.time.LocalDate
import java.time.ZoneId
data class CycleForecast(
val nextStart: Long?,
val fertileWindow: Pair<Long, Long>?,
val confidence: String
)
data class CycleStats(
val avgCycle: Int,
val variance: Double,
val lutealLen: Int
)
object CycleAnalytics {
/**
* Прогноз следующей менструации и фертильного окна
* @param periods список последних периодов
* @param stats статистика цикла (вычисляется автоматически)
* @param statsEntity статистика цикла из базы (опционально)
* @return прогноз: дата, фертильное окно, доверие
*/
fun forecast(periods: List<CyclePeriodEntity>, stats: CycleStats? = null): CycleForecast {
fun forecast(periods: List<CyclePeriodEntity>, statsEntity: CycleStatsEntity? = null): CycleForecast {
if (periods.isEmpty()) return CycleForecast(null, null, "низкая")
val calculatedStats = stats ?: calculateStats(periods)
val calculatedStats = calculateStats(periods)
val lastPeriod = periods.first()
val lastStartDate = lastPeriod.startDate
val lastStartTs = lastStartDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000
@@ -49,27 +64,53 @@ object CycleAnalytics {
val cycleLengths = periods.take(periods.size - 1).mapIndexed { index, period ->
val nextPeriod = periods[index + 1]
java.time.temporal.ChronoUnit.DAYS.between(nextPeriod.startDate, period.startDate).toInt()
}.filter { it > 0 }
if (cycleLengths.isEmpty()) {
return CycleStats(avgCycle = 28, variance = 5.0, lutealLen = 14)
}
val avgCycle = cycleLengths.average().toInt()
val variance = cycleLengths.map { (it - avgCycle) * (it - avgCycle) }.average()
val variance = if (cycleLengths.size > 1) {
cycleLengths.map { (it - avgCycle) * (it - avgCycle) }.average()
} else {
5.0
}
return CycleStats(
avgCycle = avgCycle,
variance = variance,
lutealLen = 14 // стандартная лютеиновая фаза
)
// Примерная лютеиновая фаза (обычно 14 дней)
val lutealLen = 14
return CycleStats(avgCycle, variance, lutealLen)
}
/**
* Анализ регулярности цикла
*/
fun analyzeRegularity(periods: List<CyclePeriodEntity>): String {
val stats = calculateStats(periods)
return when {
stats.variance < 2 -> "Очень регулярный"
stats.variance < 5 -> "Регулярный"
stats.variance < 10 -> "Умеренно регулярный"
else -> "Нерегулярный"
}
}
/**
* Предсказание следующих дат
*/
fun predictNextPeriods(periods: List<CyclePeriodEntity>, count: Int = 3): List<LocalDate> {
if (periods.isEmpty()) return emptyList()
val stats = calculateStats(periods)
val lastPeriod = periods.first()
val predictions = mutableListOf<LocalDate>()
for (i in 1..count) {
val nextDate = lastPeriod.startDate.plusDays((stats.avgCycle * i).toLong())
predictions.add(nextDate)
}
return predictions
}
}
data class CycleForecast(
val nextStart: Long?,
val fertileWindow: Pair<Long, Long>?,
val confidence: String
)
data class CycleStats(
val avgCycle: Int,
val variance: Double,
val lutealLen: Int
)

View File

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

View File

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

View File

@@ -0,0 +1,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
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
import kr.smartsoltech.wellshe.ui.theme.*
import kr.smartsoltech.wellshe.model.CycleForecast
import kr.smartsoltech.wellshe.model.CycleSettings
import kr.smartsoltech.wellshe.model.computeForecast
import kr.smartsoltech.wellshe.ui.components.InfoCard
import kr.smartsoltech.wellshe.ui.components.StatCard
import kr.smartsoltech.wellshe.ui.cycle.components.CycleCalendarCard
import kr.smartsoltech.wellshe.ui.cycle.components.QuickActionsCard
import kr.smartsoltech.wellshe.ui.theme.WaterColor
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import kotlin.math.cos
import kotlin.math.sin
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CycleScreen(
modifier: Modifier = Modifier,
viewModel: CycleViewModel = hiltViewModel(),
onNavigateBack: () -> Boolean
viewModel: CycleViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val scrollState = rememberScrollState()
// Загружаем данные при первом запуске
LaunchedEffect(Unit) {
viewModel.loadCycleData()
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
PrimaryPinkLight.copy(alpha = 0.3f),
NeutralWhite
)
)
),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
CycleOverviewCard(
currentPhase = uiState.currentPhase,
daysUntilNext = uiState.daysUntilNextPeriod,
cycleDay = uiState.currentCycleDay,
cycleLength = uiState.cycleLength
)
}
item {
CycleTrackerCard(
isPeriodActive = uiState.isPeriodActive,
onStartPeriod = viewModel::startPeriod,
onEndPeriod = viewModel::endPeriod,
onLogSymptoms = { viewModel.toggleSymptomsEdit() }
)
}
item {
if (uiState.showSymptomsEdit) {
SymptomsTrackingCard(
selectedSymptoms = uiState.todaySymptoms,
selectedMood = uiState.todayMood,
onSymptomsUpdate = viewModel::updateSymptoms,
onMoodUpdate = viewModel::updateMood,
onSave = viewModel::saveTodayData
)
}
}
item {
CyclePredictionCard(
nextPeriodDate = uiState.nextPeriodDate,
ovulationDate = uiState.ovulationDate,
fertilityWindow = uiState.fertilityWindow
)
}
item {
CycleInsightsCard(
insights = uiState.insights,
averageCycleLength = uiState.averageCycleLength
)
}
item {
PeriodHistoryCard(
recentPeriods = uiState.recentPeriods,
onPeriodClick = { /* TODO: Navigate to period details */ }
)
}
item {
Spacer(modifier = Modifier.height(80.dp))
}
}
if (uiState.error != null) {
LaunchedEffect(uiState.error) {
viewModel.clearError()
}
}
}
@Composable
private fun CycleOverviewCard(
currentPhase: String,
daysUntilNext: Int,
cycleDay: Int,
cycleLength: Int,
modifier: Modifier = Modifier
) {
val progress by animateFloatAsState(
targetValue = if (cycleLength > 0) (cycleDay.toFloat() / cycleLength).coerceIn(0f, 1f) else 0f,
animationSpec = tween(durationMillis = 1000)
)
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
shape = RoundedCornerShape(20.dp)
) {
Scaffold { paddingValues ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Текущий цикл",
style = MaterialTheme.typography.headlineSmall.copy(
fontWeight = FontWeight.Bold,
color = TextPrimary
)
)
Spacer(modifier = Modifier.height(24.dp))
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center
) {
CycleProgressIndicator(
progress = progress,
currentPhase = currentPhase,
modifier = Modifier.fillMaxSize()
)
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "День $cycleDay",
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = PrimaryPink
)
)
Text(
text = "из $cycleLength дней",
style = MaterialTheme.typography.bodyMedium.copy(
color = TextSecondary
)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = currentPhase,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
color = PrimaryPink
)
)
}
}
Spacer(modifier = Modifier.height(20.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = PrimaryPinkLight.copy(alpha = 0.1f)
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
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)
// Календарь цикла
CycleCalendarCard(
month = uiState.month,
onPrev = { viewModel.prevMonth() },
onNext = { viewModel.nextMonth() },
forecast = uiState.forecast
)
// Карточки с прогнозом
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (isPeriodActive) {
Button(
onClick = onEndPeriod,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF5722)
),
shape = RoundedCornerShape(12.dp)
) {
Icon(
imageVector = Icons.Default.Stop,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Завершить")
}
} else {
Button(
onClick = onStartPeriod,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryPink
),
shape = RoundedCornerShape(12.dp)
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Начать месячные")
}
}
StatCard(
title = "След. менструация",
value = uiState.forecast?.nextPeriodStart?.format(
DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
) ?: "",
tone = Color(0xFF2196F3),
modifier = Modifier.weight(1f)
)
OutlinedButton(
onClick = onLogSymptoms,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp)
) {
Icon(
imageVector = Icons.Default.Assignment,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Симптомы")
}
StatCard(
title = "Овуляция",
value = uiState.forecast?.nextOvulation?.format(
DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
) ?: "",
tone = Color(0xFF4CAF50),
modifier = Modifier.weight(1f)
)
}
// Быстрые действия
QuickActionsCard(
onMarkStart = { viewModel.markPeriodStart() },
onMarkEnd = { viewModel.markPeriodEnd() },
onAddSymptom = { viewModel.addSymptom() },
onAddNote = { viewModel.addNote() }
)
// Информационные карточки
InfoCard(
title = "Симптомы сегодня",
content = uiState.todaySymptoms
)
InfoCard(
title = "Прогноз недели",
content = uiState.weekInsight
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun SymptomsTrackingCard(
selectedSymptoms: List<String>,
selectedMood: String,
onSymptomsUpdate: (List<String>) -> Unit,
onMoodUpdate: (String) -> Unit,
onSave: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Text(
text = "Симптомы и настроение",
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
color = TextPrimary
),
modifier = Modifier.padding(bottom = 16.dp)
)
fun CycleScreenPreview() {
val previewSettings = CycleSettings(
baselineLength = 28,
periodLength = 5,
lutealDays = 14,
lastPeriodStart = LocalDate.of(2025, 10, 1)
)
val previewForecast = computeForecast(previewSettings)
// Симптомы
Text(
text = "Симптомы",
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Medium,
color = TextPrimary
),
modifier = Modifier.padding(bottom = 8.dp)
)
val symptoms = listOf(
"Боли в животе", "Головная боль", "Тошнота", "Вздутие",
"Усталость", "Раздражительность", "Боли в спине", "Акне"
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 16.dp)
) {
items(symptoms) { symptom ->
FilterChip(
onClick = {
val newSymptoms = if (selectedSymptoms.contains(symptom)) {
selectedSymptoms - symptom
} else {
selectedSymptoms + symptom
}
onSymptomsUpdate(newSymptoms)
},
label = { Text(symptom, style = MaterialTheme.typography.bodySmall) },
selected = selectedSymptoms.contains(symptom),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = PrimaryPink,
selectedLabelColor = NeutralWhite
)
)
}
}
// Настроение
Text(
text = "Настроение",
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Medium,
color = TextPrimary
),
modifier = Modifier.padding(bottom = 8.dp)
)
val moods = listOf("Отличное", "Хорошее", "Нейтральное", "Плохое", "Ужасное")
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 16.dp)
) {
items(moods) { mood ->
FilterChip(
onClick = { onMoodUpdate(mood) },
label = { Text(mood) },
selected = selectedMood == mood,
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = PrimaryPink,
selectedLabelColor = NeutralWhite
)
)
}
}
Button(
onClick = onSave,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryPink
),
shape = RoundedCornerShape(12.dp)
) {
Text("Сохранить")
}
}
}
}
@Composable
private fun CyclePredictionCard(
nextPeriodDate: LocalDate?,
ovulationDate: LocalDate?,
fertilityWindow: Pair<LocalDate, LocalDate>?,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Text(
text = "Прогнозы",
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
color = TextPrimary
),
modifier = Modifier.padding(bottom = 16.dp)
)
val previewState = CycleViewModel.UiState(
month = YearMonth.now(),
forecast = previewForecast,
todaySymptoms = "Лёгкая усталость, аппетит выше обычного",
weekInsight = "ПМС с 13 окт; окно фертильности: 29 сен04 окт",
cycleSettings = previewSettings
)
WellSheTheme {
Surface {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
PredictionItem(
icon = Icons.Default.CalendarMonth,
title = "Следующие месячные",
date = nextPeriodDate,
color = PrimaryPink
CycleCalendarCard(
month = previewState.month,
onPrev = { },
onNext = { },
forecast = previewState.forecast
)
PredictionItem(
icon = Icons.Default.Favorite,
title = "Овуляция",
date = ovulationDate,
color = Color(0xFF9C27B0)
)
if (fertilityWindow != null) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Spa,
contentDescription = null,
tint = Color(0xFF4CAF50),
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "Период фертильности",
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Medium,
color = TextPrimary
)
)
Text(
text = "${fertilityWindow.first.format(DateTimeFormatter.ofPattern("dd.MM"))} - ${fertilityWindow.second.format(DateTimeFormatter.ofPattern("dd.MM"))}",
style = MaterialTheme.typography.bodyMedium.copy(
color = Color(0xFF4CAF50),
fontWeight = FontWeight.Bold
)
)
}
}
}
}
}
}
}
@Composable
private fun PredictionItem(
icon: ImageVector,
title: String,
date: LocalDate?,
color: Color,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Medium,
color = TextPrimary
)
)
Text(
text = date?.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) ?: "Недостаточно данных",
style = MaterialTheme.typography.bodyMedium.copy(
color = if (date != null) color else TextSecondary,
fontWeight = if (date != null) FontWeight.Bold else FontWeight.Normal
)
)
}
}
}
@Composable
private fun CycleInsightsCard(
insights: List<String>,
averageCycleLength: Float,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(
containerColor = PrimaryPinkLight.copy(alpha = 0.1f)
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 16.dp)
) {
Icon(
imageVector = Icons.Default.Analytics,
contentDescription = null,
tint = PrimaryPink,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Анализ цикла",
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
color = TextPrimary
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatCard(
title = "След. менструация",
value = previewState.forecast?.nextPeriodStart?.format(
DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
) ?: "",
tone = Color(0xFF2196F3),
modifier = Modifier.weight(1f)
)
)
}
if (averageCycleLength > 0) {
Text(
text = "Средняя длина цикла: %.1f дней".format(averageCycleLength),
style = MaterialTheme.typography.bodyMedium.copy(
color = TextPrimary,
fontWeight = FontWeight.Medium
),
modifier = Modifier.padding(bottom = 12.dp)
)
}
if (insights.isEmpty()) {
Text(
text = "Отслеживайте цикл несколько месяцев для получения персональных рекомендаций.",
style = MaterialTheme.typography.bodyMedium.copy(
color = TextPrimary
StatCard(
title = "Овуляция",
value = previewState.forecast?.nextOvulation?.format(
DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
) ?: "",
tone = Color(0xFF4CAF50),
modifier = Modifier.weight(1f)
)
)
} else {
insights.forEach { insight ->
Row(
modifier = Modifier.padding(vertical = 4.dp)
) {
Icon(
imageVector = Icons.Default.Circle,
contentDescription = null,
tint = PrimaryPink,
modifier = Modifier.size(8.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = insight,
style = MaterialTheme.typography.bodyMedium.copy(
color = TextPrimary
)
)
}
}
QuickActionsCard(
onMarkStart = { },
onMarkEnd = { },
onAddSymptom = { },
onAddNote = { }
)
InfoCard(
title = "Симптомы сегодня",
content = previewState.todaySymptoms
)
InfoCard(
title = "Прогноз недели",
content = previewState.weekInsight
)
}
}
}
}
@Preview(showBackground = true, locale = "ru")
@Composable
private fun PeriodHistoryCard(
recentPeriods: List<CyclePeriodEntity>,
onPeriodClick: (CyclePeriodEntity) -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Text(
text = "История циклов",
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
color = TextPrimary
),
modifier = Modifier.padding(bottom = 16.dp)
)
if (recentPeriods.isEmpty()) {
Text(
text = "Пока нет записей о циклах",
style = MaterialTheme.typography.bodyMedium.copy(
color = TextSecondary
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
} else {
recentPeriods.take(3).forEach { period ->
PeriodHistoryItem(
period = period,
onClick = { onPeriodClick(period) }
)
if (period != recentPeriods.last()) {
Spacer(modifier = Modifier.height(12.dp))
}
}
}
}
}
fun CycleScreenPreviewRussian() {
CycleScreenPreview()
}
@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PeriodHistoryItem(
period: CyclePeriodEntity,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.CalendarMonth,
contentDescription = null,
tint = PrimaryPink,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = period.startDate.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")),
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Medium,
color = TextPrimary
)
)
val duration = if (period.endDate != null) {
ChronoUnit.DAYS.between(period.startDate, period.endDate) + 1
} else {
null
}
Text(
text = if (duration != null) {
"Продолжительность: $duration дней"
} else {
"В процессе"
},
style = MaterialTheme.typography.bodySmall.copy(
color = TextSecondary
)
)
}
Text(
text = period.flow,
style = MaterialTheme.typography.bodySmall.copy(
color = TextSecondary
)
)
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Просмотреть",
tint = TextSecondary,
modifier = Modifier.size(20.dp)
)
}
fun CycleScreenPreviewDark() {
CycleScreenPreview()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.verticalScroll
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.runtime.*
import androidx.compose.ui.Alignment
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.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)
@Composable
fun ProfileScreen(
onNavigateBack: () -> Unit,
viewModel: ProfileViewModel = hiltViewModel()
modifier: Modifier = Modifier
) {
val uiState by viewModel.uiState.collectAsState()
val scrollState = rememberScrollState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Профиль") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Назад")
}
}
// Состояния для различных настроек
var units by remember {
mutableStateOf(
mapOf(
"weight" to "кг",
"speed" to "км/ч",
"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) {
Box(
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Цели и единицы измерения",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
// Цель воды
Row(
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,
onUpdateProfile = { user ->
viewModel.updateProfile(user)
// Единицы веса
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Вес",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
UnitSelector(
options = listOf("кг", "lb"),
selectedOption = units["weight"] ?: "кг",
onOptionSelected = { units = units.toMutableMap().apply { this["weight"] = it } }
)
}
// Единицы скорости
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Скорость",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
UnitSelector(
options = listOf("км/ч", "mph"),
selectedOption = units["speed"] ?: "км/ч",
onOptionSelected = { units = units.toMutableMap().apply { this["speed"] = it } }
)
}
// Единицы температуры
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Температура",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
UnitSelector(
options = listOf("°C", "°F"),
selectedOption = units["temp"] ?: "°C",
onOptionSelected = { units = units.toMutableMap().apply { this["temp"] = it } }
)
}
}
}
// Карточка уведомлений цикла
InfoCard(
title = "Уведомления цикла",
content = "Управляются в разделе Настройки → Цикл."
)
// Карточка приватности и безопасности
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Приватность и безопасность",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
ToggleRow(
label = "Блокировка по биометрии",
checked = privacy["biometrics"] ?: false,
onCheckedChange = { privacy = privacy.toMutableMap().apply { this["biometrics"] = it } }
)
ToggleRow(
label = "Анонимная аналитика",
checked = privacy["analytics"] ?: false,
onCheckedChange = { privacy = privacy.toMutableMap().apply { this["analytics"] = it } }
)
}
}
// Карточка интеграций
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Интеграции",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
integrations.forEachIndexed { index, integration ->
ToggleRow(
label = integration.title,
checked = integration.enabled,
onCheckedChange = { isEnabled ->
integrations = integrations.toMutableList().apply {
this[index] = Integration(integration.title, isEnabled)
}
}
)
}
}
}
// Кнопки действий
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { /* TODO */ },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Сохранить")
}
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
private fun ProfileContent(
user: kr.smartsoltech.wellshe.domain.model.User,
onUpdateProfile: (kr.smartsoltech.wellshe.domain.model.User) -> Unit
) {
var name by remember { mutableStateOf(user.name) }
var email by remember { mutableStateOf(user.email) }
var age by remember { mutableStateOf(user.age.toString()) }
var height by remember { mutableStateOf(user.height.toString()) }
var weight by remember { mutableStateOf(user.weight.toString()) }
Card {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
fun ProfileScreenPreview() {
WellSheTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Text(
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} часов/день")
ProfileScreen()
}
}
}

View File

@@ -5,6 +5,8 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.filled.*
import androidx.compose.material3.*
@@ -14,7 +16,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kr.smartsoltech.wellshe.ui.theme.*
@@ -197,30 +202,30 @@ private fun CycleSettingsCard(
modifier: Modifier = Modifier
) {
SettingsCard(
title = "Настройки цикла",
title = "Настройки менструального цикла",
icon = Icons.Default.CalendarMonth,
modifier = modifier
) {
SettingsSliderItem(
SettingsNumberField(
title = "Длина цикла",
subtitle = "Количество дней в цикле",
value = cycleLength.toFloat(),
valueRange = 21f..35f,
steps = 13,
onValueChange = { onCycleLengthChange(it.toInt()) },
valueFormatter = { "${it.toInt()} дней" }
subtitle = "Количество дней в цикле (21-35)",
value = cycleLength,
onValueChange = { value ->
if (value in 21..35) onCycleLengthChange(value)
},
suffix = "дней"
)
Spacer(modifier = Modifier.height(20.dp))
SettingsSliderItem(
SettingsNumberField(
title = "Длина менструации",
subtitle = "Количество дней менструации",
value = periodLength.toFloat(),
valueRange = 3f..8f,
steps = 4,
onValueChange = { onPeriodLengthChange(it.toInt()) },
valueFormatter = { "${it.toInt()} дней" }
subtitle = "Количество дней менструации (3-8)",
value = periodLength,
onValueChange = { value ->
if (value in 3..8) onPeriodLengthChange(value)
},
suffix = "дней"
)
}
}
@@ -240,38 +245,38 @@ private fun GoalsSettingsCard(
icon = Icons.Default.TrackChanges,
modifier = modifier
) {
SettingsSliderItem(
SettingsDecimalField(
title = "Цель по воде",
subtitle = "Количество воды в день",
subtitle = "Количество воды в день (1.5-4.0 л)",
value = waterGoal,
valueRange = 1.5f..4.0f,
steps = 24,
onValueChange = onWaterGoalChange,
valueFormatter = { "%.1f л".format(it) }
onValueChange = { value ->
if (value in 1.5f..4.0f) onWaterGoalChange(value)
},
suffix = "л"
)
Spacer(modifier = Modifier.height(20.dp))
SettingsSliderItem(
SettingsNumberField(
title = "Цель по шагам",
subtitle = "Количество шагов в день",
value = stepsGoal.toFloat(),
valueRange = 5000f..20000f,
steps = 29,
onValueChange = { onStepsGoalChange(it.toInt()) },
valueFormatter = { "${(it/1000).toInt()}k шагов" }
subtitle = "Количество шагов в день (5000-20000)",
value = stepsGoal,
onValueChange = { value ->
if (value in 5000..20000) onStepsGoalChange(value)
},
suffix = "шагов"
)
Spacer(modifier = Modifier.height(20.dp))
SettingsSliderItem(
SettingsDecimalField(
title = "Цель по сну",
subtitle = "Количество часов сна",
subtitle = "Количество часов сна (6-10 часов)",
value = sleepGoal,
valueRange = 6.0f..10.0f,
steps = 7,
onValueChange = onSleepGoalChange,
valueFormatter = { "%.1f часов".format(it) }
onValueChange = { value ->
if (value in 6.0f..10.0f) onSleepGoalChange(value)
},
suffix = "часов"
)
}
}
@@ -327,15 +332,17 @@ private fun DataManagementCard(
Spacer(modifier = Modifier.height(16.dp))
SettingsActionItem(
title = "Очистить все данные",
subtitle = "Удалить все сохраненные данные",
title = "Очистить данные",
subtitle = "Удалить все данные приложения",
icon = Icons.Default.DeleteForever,
onClick = onClearData,
isDestructive = true
textColor = Color(0xFFFF5722)
)
}
}
// Компоненты настроек
@Composable
private fun SettingsCard(
title: String,
@@ -356,7 +363,7 @@ private fun SettingsCard(
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 16.dp)
modifier = Modifier.padding(bottom = 20.dp)
) {
Icon(
imageVector = icon,
@@ -369,7 +376,7 @@ private fun SettingsCard(
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
color = TextPrimary
)
@@ -405,7 +412,7 @@ private fun SettingsSwitchItem(
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall.copy(
style = MaterialTheme.typography.bodyMedium.copy(
color = TextSecondary
)
)
@@ -415,70 +422,108 @@ private fun SettingsSwitchItem(
checked = isChecked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = NeutralWhite,
checkedTrackColor = PrimaryPink,
uncheckedThumbColor = NeutralWhite,
uncheckedTrackColor = Color.Gray.copy(alpha = 0.3f)
checkedThumbColor = PrimaryPink,
checkedTrackColor = PrimaryPinkLight
)
)
}
}
@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,
subtitle: String,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int,
onValueChange: (Float) -> Unit,
valueFormatter: (Float) -> String,
suffix: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth()
) {
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
)
)
}
val keyboardController = LocalSoftwareKeyboardController.current
var textValue by remember(value) { mutableStateOf("%.1f".format(value)) }
Text(
text = valueFormatter(value),
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
color = PrimaryPink
)
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)
)
Spacer(modifier = Modifier.height(8.dp))
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
steps = steps,
colors = SliderDefaults.colors(
thumbColor = PrimaryPink,
activeTrackColor = PrimaryPink,
inactiveTrackColor = Color.Gray.copy(alpha = 0.3f)
OutlinedTextField(
value = textValue,
onValueChange = { newValue ->
textValue = newValue
newValue.toFloatOrNull()?.let { floatValue ->
onValueChange(floatValue)
}
},
suffix = { Text(suffix) },
singleLine = true,
modifier = Modifier.width(120.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { keyboardController?.hide() }
)
)
}
@@ -490,7 +535,7 @@ private fun SettingsActionItem(
subtitle: String,
icon: ImageVector,
onClick: () -> Unit,
isDestructive: Boolean = false,
textColor: Color = TextPrimary,
modifier: Modifier = Modifier
) {
Row(
@@ -503,7 +548,7 @@ private fun SettingsActionItem(
Icon(
imageVector = icon,
contentDescription = null,
tint = if (isDestructive) Color(0xFFE53E3E) else PrimaryPink,
tint = textColor,
modifier = Modifier.size(24.dp)
)
@@ -516,22 +561,21 @@ private fun SettingsActionItem(
text = title,
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Medium,
color = if (isDestructive) Color(0xFFE53E3E) else TextPrimary
color = textColor
)
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall.copy(
color = if (isDestructive) Color(0xFFE53E3E).copy(alpha = 0.7f) else TextSecondary
style = MaterialTheme.typography.bodyMedium.copy(
color = if (textColor == TextPrimary) TextSecondary else textColor.copy(alpha = 0.7f)
)
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Выполнить",
tint = TextSecondary,
modifier = Modifier.size(20.dp)
contentDescription = null,
tint = NeutralGray
)
}
}

View File

@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.data.repository.WellSheRepository
import javax.inject.Inject
@@ -37,7 +38,12 @@ class SettingsViewModel @Inject constructor(
_uiState.value = _uiState.value.copy(isLoading = true)
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(
isWaterReminderEnabled = settings.isWaterReminderEnabled,
isCycleReminderEnabled = settings.isCycleReminderEnabled,
@@ -60,6 +66,7 @@ class SettingsViewModel @Inject constructor(
}
}
// Уведомления
fun toggleWaterReminder(enabled: Boolean) {
viewModelScope.launch {
try {
@@ -93,61 +100,74 @@ class SettingsViewModel @Inject constructor(
}
}
// Настройки цикла
fun updateCycleLength(length: Int) {
viewModelScope.launch {
try {
repository.updateCycleLength(length)
_uiState.value = _uiState.value.copy(cycleLength = length)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
if (length in 21..35) {
viewModelScope.launch {
try {
repository.updateCycleLength(length)
_uiState.value = _uiState.value.copy(cycleLength = length)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
}
fun updatePeriodLength(length: Int) {
viewModelScope.launch {
try {
repository.updatePeriodLength(length)
_uiState.value = _uiState.value.copy(periodLength = length)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
if (length in 3..8) {
viewModelScope.launch {
try {
repository.updatePeriodLength(length)
_uiState.value = _uiState.value.copy(periodLength = length)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
}
// Цели
fun updateWaterGoal(goal: Float) {
viewModelScope.launch {
try {
repository.updateWaterGoal(goal)
_uiState.value = _uiState.value.copy(waterGoal = goal)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
if (goal in 1.5f..4.0f) {
viewModelScope.launch {
try {
repository.updateWaterGoal(goal)
_uiState.value = _uiState.value.copy(waterGoal = goal)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
}
fun updateStepsGoal(goal: Int) {
viewModelScope.launch {
try {
repository.updateStepsGoal(goal)
_uiState.value = _uiState.value.copy(stepsGoal = goal)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
if (goal in 5000..20000) {
viewModelScope.launch {
try {
repository.updateStepsGoal(goal)
_uiState.value = _uiState.value.copy(stepsGoal = goal)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
}
fun updateSleepGoal(goal: Float) {
viewModelScope.launch {
try {
repository.updateSleepGoal(goal)
_uiState.value = _uiState.value.copy(sleepGoal = goal)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
if (goal in 6.0f..10.0f) {
viewModelScope.launch {
try {
repository.updateSleepGoal(goal)
_uiState.value = _uiState.value.copy(sleepGoal = goal)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}
}
// Внешний вид
fun toggleTheme(isDark: Boolean) {
viewModelScope.launch {
try {
@@ -159,11 +179,12 @@ class SettingsViewModel @Inject constructor(
}
}
// Управление данными
fun exportData() {
viewModelScope.launch {
try {
repository.exportUserData()
// TODO: Показать уведомление об успешном экспорте
// Показать сообщение об успехе
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
@@ -174,7 +195,7 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch {
try {
repository.importUserData()
loadSettings() // Перезагружаем настройки
loadSettings() // Перезагрузить настройки
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
@@ -185,7 +206,8 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch {
try {
repository.clearAllUserData()
loadSettings() // Перезагружаем настройки
// Сбросить на дефолтные значения
_uiState.value = SettingsUiState()
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}

View File

@@ -49,3 +49,21 @@ val ChartPurple = Color(0xFF9C27B0)
val ChartGreen = Color(0xFF4CAF50)
val ChartOrange = Color(0xFFFF9800)
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.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = PrimaryPink,
secondary = AccentPurple,
tertiary = SecondaryBlue,
background = NeutralBlack,
surface = NeutralDarkGray,
onPrimary = NeutralWhite,
onSecondary = NeutralWhite,
onTertiary = NeutralWhite,
onBackground = NeutralWhite,
onSurface = NeutralWhite,
// Основная светлая цветовая схема
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF2196F3), // Основной синий цвет приложения
onPrimary = Color.White,
primaryContainer = Color(0xFFE3F2FD), // Светло-синий для карточек
onPrimaryContainer = Color(0xFF0D47A1),
secondary = Color(0xFF4CAF50), // Зеленый для акцентов
onSecondary = Color.White,
secondaryContainer = Color(0xFFE8F5E9),
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,
secondary = AccentPurple,
tertiary = SecondaryBlue,
background = NeutralWhite,
surface = NeutralLightGray,
onPrimary = NeutralWhite,
onSecondary = NeutralWhite,
onTertiary = NeutralWhite,
onBackground = NeutralDarkGray,
onSurface = NeutralDarkGray,
// Темная цветовая схема
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF90CAF9),
onPrimary = Color(0xFF0D47A1),
primaryContainer = Color(0xFF1565C0),
onPrimaryContainer = Color(0xFFE3F2FD),
secondary = Color(0xFFA5D6A7),
onSecondary = Color(0xFF1B5E20),
secondaryContainer = Color(0xFF2E7D32),
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
fun WellSheTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
@@ -53,22 +81,23 @@ fun WellSheTheme(
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
window.statusBarColor = colorScheme.background.toArgb() // Прозрачный статусбар
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = Shapes,
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.unit.sp
// Set of Material typography styles to start with
// Типографика в соответствии с Material 3 и дизайном веб-прототипа
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
// Заголовки
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.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(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
// Заголовки дисплейные
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
headlineSmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.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
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
import kr.smartsoltech.wellshe.data.entity.CycleStatsEntity
import org.junit.Assert.*
import org.junit.Test
import java.time.LocalDate
class CycleAnalyticsTest {
@Test
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 stats = CycleStatsEntity(avgCycle = 28, variance = 1, lutealLen = 14)
val forecast = CycleAnalytics.forecast(periods, stats)
val periods = 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 forecast = CycleAnalytics.forecast(periods, null)
assertEquals("высокая", forecast.confidence)
assertNotNull(forecast.nextStart)
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
fun testSleepDebt() {
val logs = listOf(
SleepLogEntity(id = 0, startTs = 1000, endTs = 1000 + 8 * 3600_000, quality = 5),
SleepLogEntity(id = 0, startTs = 2000, endTs = 2000 + 7 * 3600_000, quality = 4)
SleepLogEntity(
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)
assertEquals(1, debt)

View File

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