Merge pull request 'emergency' (#2) from emergency into main
Reviewed-on: #2
This commit is contained in:
123
.idea/codeStyles/Project.xml
generated
Normal file
123
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,123 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@@ -4,6 +4,14 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-10-16T05:53:10.409373833Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=LGMG600S9b4da66b" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
|
||||
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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 {
|
||||
@@ -37,6 +47,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
viewBinding = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.14"
|
||||
@@ -67,6 +78,26 @@ 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")
|
||||
|
||||
// Retrofit зависимости
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// Fragment dependencies
|
||||
implementation("androidx.fragment:fragment-ktx:1.6.2")
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
|
||||
|
||||
// ViewBinding
|
||||
implementation("androidx.databinding:databinding-runtime:8.2.2")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation("io.mockk:mockk:1.13.8")
|
||||
|
||||
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/10.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/10.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/11.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/11.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/2.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/2.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/3.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/3.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/4.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/4.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/5.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/5.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/7.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/7.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/8.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/8.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/9.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/9.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.WellShe">
|
||||
|
||||
<activity
|
||||
|
||||
@@ -1,22 +1,69 @@
|
||||
package kr.smartsoltech.wellshe
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kr.smartsoltech.wellshe.ui.navigation.WellSheNavigation
|
||||
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
|
||||
import kr.smartsoltech.wellshe.ui.navigation.AppNavGraph
|
||||
import kr.smartsoltech.wellshe.ui.navigation.BottomNavigation
|
||||
import kr.smartsoltech.wellshe.ui.navigation.BottomNavItem
|
||||
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
WellSheTheme {
|
||||
WellSheNavigation()
|
||||
|
||||
try {
|
||||
setContent {
|
||||
WellSheTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
// Получаем AuthViewModel для управления авторизацией
|
||||
val authViewModel: AuthViewModel = viewModel()
|
||||
|
||||
// Получаем текущий маршрут для определения показа нижней навигации
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
// Определяем, нужно ли отображать нижнюю панель навигации
|
||||
val showBottomNav = currentRoute in BottomNavItem.items.map { it.route }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showBottomNav) {
|
||||
BottomNavigation(navController = navController)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
// Навигационный граф приложения с передачей authViewModel
|
||||
AppNavGraph(
|
||||
navController = navController,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
authViewModel = authViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d("MainActivity", "Activity started successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error in onCreate: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = 11,
|
||||
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()
|
||||
}
|
||||
|
||||
21
app/src/main/java/kr/smartsoltech/wellshe/data/Converters.kt
Normal file
21
app/src/main/java/kr/smartsoltech/wellshe/data/Converters.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package kr.smartsoltech.wellshe.data
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import java.time.Instant
|
||||
|
||||
class InstantConverter {
|
||||
@TypeConverter
|
||||
fun fromTimestamp(value: Long?): Instant? = value?.let { Instant.ofEpochMilli(it) }
|
||||
@TypeConverter
|
||||
fun instantToTimestamp(instant: Instant?): Long? = instant?.toEpochMilli()
|
||||
}
|
||||
|
||||
class StringListConverter {
|
||||
@TypeConverter
|
||||
fun fromString(value: String?): List<String>? = value?.let {
|
||||
if (it.isEmpty()) emptyList() else it.split("||")
|
||||
}
|
||||
@TypeConverter
|
||||
fun listToString(list: List<String>?): String = list?.joinToString("||") ?: ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,776 @@
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Миграция базы данных с версии 2 на версию 3.
|
||||
* Исправляет проблему с типом данных столбца date в таблице water_logs.
|
||||
*/
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Создаем временную таблицу с правильными типами данных
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `water_logs_new` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`date` INTEGER NOT NULL,
|
||||
`amount` INTEGER NOT NULL,
|
||||
`timestamp` INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
|
||||
// Для этого используем SQLite функцию strftime для преобразования строковой даты в timestamp
|
||||
database.execSQL(
|
||||
"""
|
||||
INSERT INTO water_logs_new (id, date, amount, timestamp)
|
||||
SELECT id,
|
||||
CASE
|
||||
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
|
||||
ELSE strftime('%s', 'now') * 1000
|
||||
END as date_int,
|
||||
amount,
|
||||
timestamp
|
||||
FROM water_logs
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Удаляем старую таблицу
|
||||
database.execSQL("DROP TABLE water_logs")
|
||||
|
||||
// Переименовываем новую таблицу в старое имя
|
||||
database.execSQL("ALTER TABLE water_logs_new RENAME TO water_logs")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Миграция базы данных с версии 3 на версию 4.
|
||||
* Исправляет проблему с типом данных столбца date в таблице sleep_logs.
|
||||
*/
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Создаем временную таблицу с правильными типами данных
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `sleep_logs_new` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`date` INTEGER NOT NULL,
|
||||
`bedTime` TEXT NOT NULL,
|
||||
`wakeTime` TEXT NOT NULL,
|
||||
`duration` REAL NOT NULL,
|
||||
`quality` TEXT NOT NULL,
|
||||
`notes` TEXT NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
|
||||
database.execSQL(
|
||||
"""
|
||||
INSERT INTO sleep_logs_new (id, date, bedTime, wakeTime, duration, quality, notes)
|
||||
SELECT id,
|
||||
CASE
|
||||
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
|
||||
ELSE strftime('%s', 'now') * 1000
|
||||
END as date_int,
|
||||
bedTime,
|
||||
wakeTime,
|
||||
duration,
|
||||
quality,
|
||||
notes
|
||||
FROM sleep_logs
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Удаляем старую таблицу
|
||||
database.execSQL("DROP TABLE sleep_logs")
|
||||
|
||||
// Переименовываем новую таблицу в старое имя
|
||||
database.execSQL("ALTER TABLE sleep_logs_new RENAME TO sleep_logs")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Миграция базы данных с версии 4 на версию 5.
|
||||
* Исправляет проблему с типом данных столбца date в таблице workouts.
|
||||
*/
|
||||
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Создаем временную таблицу с правильными типами данных
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `workouts_new` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`date` INTEGER NOT NULL,
|
||||
`type` TEXT NOT NULL,
|
||||
`name` TEXT NOT NULL,
|
||||
`duration` INTEGER NOT NULL,
|
||||
`caloriesBurned` INTEGER NOT NULL,
|
||||
`intensity` TEXT NOT NULL,
|
||||
`notes` TEXT NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
|
||||
database.execSQL(
|
||||
"""
|
||||
INSERT INTO workouts_new (id, date, type, name, duration, caloriesBurned, intensity, notes)
|
||||
SELECT id,
|
||||
CASE
|
||||
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
|
||||
ELSE strftime('%s', 'now') * 1000
|
||||
END as date_int,
|
||||
type,
|
||||
name,
|
||||
duration,
|
||||
caloriesBurned,
|
||||
intensity,
|
||||
notes
|
||||
FROM workouts
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Удаляем старую таблицу
|
||||
database.execSQL("DROP TABLE workouts")
|
||||
|
||||
// Переименовываем новую таблицу в старое имя
|
||||
database.execSQL("ALTER TABLE workouts_new RENAME TO workouts")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Миграция базы данных с версии 5 на версию 6.
|
||||
* Исправляет проблему с типом данных столбца date в таблице calories.
|
||||
*/
|
||||
val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Создаем временную таблицу с правильными типами данных
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `calories_new` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`date` INTEGER NOT NULL,
|
||||
`consumed` INTEGER NOT NULL,
|
||||
`burned` INTEGER NOT NULL,
|
||||
`target` INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
|
||||
database.execSQL(
|
||||
"""
|
||||
INSERT INTO calories_new (id, date, consumed, burned, target)
|
||||
SELECT id,
|
||||
CASE
|
||||
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
|
||||
ELSE strftime('%s', 'now') * 1000
|
||||
END as date_int,
|
||||
consumed,
|
||||
burned,
|
||||
target
|
||||
FROM calories
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Удаляем старую таблицу
|
||||
database.execSQL("DROP TABLE calories")
|
||||
|
||||
// Переименовываем новую таблицу в старое имя
|
||||
database.execSQL("ALTER TABLE calories_new RENAME TO calories")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Миграция базы данных с версии 6 на версию 7.
|
||||
* Исправляет проблему с типом данных столбца date во всех оставшихся таблицах.
|
||||
*/
|
||||
val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Пропускаем таблицу steps, так как она будет обработана в MIGRATION_7_8
|
||||
// Начинаем с проверки существования таблицы health_records и наличия в ней поля date с типом TEXT
|
||||
try {
|
||||
val cursor = database.query("PRAGMA table_info(health_records)")
|
||||
var hasDateColumn = false
|
||||
var isTextType = false
|
||||
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
val columnName = cursor.getString(cursor.getColumnIndex("name"))
|
||||
val columnType = cursor.getString(cursor.getColumnIndex("type"))
|
||||
|
||||
if (columnName == "date" && columnType.equals("TEXT", ignoreCase = true)) {
|
||||
hasDateColumn = true
|
||||
isTextType = true
|
||||
break
|
||||
}
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
cursor.close()
|
||||
|
||||
if (hasDateColumn && isTextType) {
|
||||
// Создаем временную таблицу с правильными типами данных
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `health_records_new` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`date` INTEGER NOT NULL,
|
||||
`type` TEXT NOT NULL,
|
||||
`value` REAL NOT NULL,
|
||||
`notes` TEXT NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
|
||||
database.execSQL(
|
||||
"""
|
||||
INSERT INTO health_records_new (id, date, type, value, notes)
|
||||
SELECT id,
|
||||
CASE
|
||||
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
|
||||
ELSE strftime('%s', 'now') * 1000
|
||||
END as date_int,
|
||||
type,
|
||||
value,
|
||||
notes
|
||||
FROM health_records
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Удаляем старую таблицу
|
||||
database.execSQL("DROP TABLE health_records")
|
||||
|
||||
// Переименовываем новую таблицу в старое имя
|
||||
database.execSQL("ALTER TABLE health_records_new RENAME TO health_records")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Если таблица не существует или возникла другая ошибка, просто продолжаем
|
||||
}
|
||||
|
||||
// Проверяем другие таблицы, которые могут содержать поля даты с типом TEXT
|
||||
// Список таблиц для проверки
|
||||
val tablesToCheck = listOf(
|
||||
"cycle_periods",
|
||||
"user_profiles",
|
||||
"weight_logs",
|
||||
"beverage_logs",
|
||||
"workout_sessions"
|
||||
)
|
||||
|
||||
for (table in tablesToCheck) {
|
||||
try {
|
||||
val cursor = database.query("PRAGMA table_info($table)")
|
||||
val dateColumns = mutableListOf<String>()
|
||||
val columns = mutableListOf<Pair<String, String>>()
|
||||
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
val columnNameIndex = cursor.getColumnIndex("name")
|
||||
val columnTypeIndex = cursor.getColumnIndex("type")
|
||||
|
||||
if (columnNameIndex >= 0 && columnTypeIndex >= 0) {
|
||||
val columnName = cursor.getString(columnNameIndex)
|
||||
val columnType = cursor.getString(columnTypeIndex)
|
||||
|
||||
columns.add(columnName to columnType)
|
||||
|
||||
// Проверяем, есть ли в названии колонки слово "date" и тип TEXT
|
||||
if (columnName.contains("date", ignoreCase = true) &&
|
||||
columnType.equals("TEXT", ignoreCase = true)) {
|
||||
dateColumns.add(columnName)
|
||||
}
|
||||
}
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
cursor.close()
|
||||
|
||||
// Если найдены столбцы с датами типа TEXT
|
||||
if (dateColumns.isNotEmpty()) {
|
||||
// Создаем имя для временной таблицы
|
||||
val newTableName = "${table}_new"
|
||||
|
||||
// Создаем SQL для создания новой таблицы с правильными типами
|
||||
val createTableSQL = StringBuilder()
|
||||
createTableSQL.append("CREATE TABLE IF NOT EXISTS `$newTableName` (")
|
||||
|
||||
val columnDefinitions = columns.map { (name, type) ->
|
||||
// Для столбцов с датой меняем тип на INTEGER
|
||||
if (dateColumns.contains(name)) {
|
||||
"`$name` INTEGER" + if (name == "id") " PRIMARY KEY AUTOINCREMENT NOT NULL" else " NOT NULL"
|
||||
} else {
|
||||
"`$name` $type"
|
||||
}
|
||||
}
|
||||
|
||||
createTableSQL.append(columnDefinitions.joinToString(", "))
|
||||
createTableSQL.append(")")
|
||||
|
||||
// Выполняем создание новой таблицы
|
||||
database.execSQL(createTableSQL.toString())
|
||||
|
||||
// Создаем SQL для копирования данных
|
||||
val insertSQL = StringBuilder()
|
||||
insertSQL.append("INSERT INTO `$newTableName` SELECT ")
|
||||
|
||||
val columnSelects = columns.map { (name, _) ->
|
||||
if (dateColumns.contains(name)) {
|
||||
"CASE WHEN $name IS NOT NULL THEN strftime('%s', $name) * 1000 ELSE strftime('%s', 'now') * 1000 END as $name"
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
insertSQL.append(columnSelects.joinToString(", "))
|
||||
insertSQL.append(" FROM `$table`")
|
||||
|
||||
// Выполняем копирование данных
|
||||
database.execSQL(insertSQL.toString())
|
||||
|
||||
// Удаляем старую таблицу и переименовываем новую
|
||||
database.execSQL("DROP TABLE `$table`")
|
||||
database.execSQL("ALTER TABLE `$newTableName` RENAME TO `$table`")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Если таблица не существует или возникла другая ошибка, просто продолжаем
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Миграция базы данных с версии 7 на версию 8.
|
||||
* Исправляет проблему с типом данных столбца date в таблице steps.
|
||||
*/
|
||||
val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Создаем временную таблицу с правильными типами данных
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `steps_new` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`date` INTEGER NOT NULL,
|
||||
`steps` INTEGER NOT NULL,
|
||||
`distance` REAL NOT NULL,
|
||||
`caloriesBurned` INTEGER NOT NULL,
|
||||
`target` INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
|
||||
database.execSQL(
|
||||
"""
|
||||
INSERT INTO steps_new (id, date, steps, distance, caloriesBurned, target)
|
||||
SELECT id,
|
||||
CASE
|
||||
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
|
||||
ELSE strftime('%s', 'now') * 1000
|
||||
END as date_int,
|
||||
steps,
|
||||
distance,
|
||||
caloriesBurned,
|
||||
target
|
||||
FROM steps
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Удаляем старую таблицу
|
||||
database.execSQL("DROP TABLE steps")
|
||||
|
||||
// Переименовываем новую таблицу в старое имя
|
||||
database.execSQL("ALTER TABLE steps_new RENAME TO steps")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Миграция базы данных с версии 8 на версию 9.
|
||||
* Исправляет проблему с типом данных столбца lastPeriodDate в таблице user_profile.
|
||||
*/
|
||||
val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Создаем временную таблицу с правильными типами данных
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `user_profile_new` (
|
||||
`id` INTEGER PRIMARY KEY NOT NULL,
|
||||
`name` TEXT NOT NULL,
|
||||
`email` TEXT NOT NULL,
|
||||
`age` INTEGER NOT NULL,
|
||||
`height` INTEGER NOT NULL,
|
||||
`weight` REAL NOT NULL,
|
||||
`targetWeight` REAL NOT NULL,
|
||||
`activityLevel` TEXT NOT NULL,
|
||||
`dailyWaterGoal` INTEGER NOT NULL,
|
||||
`dailyCalorieGoal` INTEGER NOT NULL,
|
||||
`dailyStepsGoal` INTEGER NOT NULL,
|
||||
`cycleLength` INTEGER NOT NULL,
|
||||
`periodLength` INTEGER NOT NULL,
|
||||
`lastPeriodDate` INTEGER,
|
||||
`profileImagePath` TEXT NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Копируем данные из старой таблицы в новую, преобразуя lastPeriodDate из TEXT в INTEGER
|
||||
database.execSQL(
|
||||
"""
|
||||
INSERT INTO user_profile_new (
|
||||
id, name, email, age, height, weight, targetWeight, activityLevel,
|
||||
dailyWaterGoal, dailyCalorieGoal, dailyStepsGoal, cycleLength,
|
||||
periodLength, lastPeriodDate, profileImagePath
|
||||
)
|
||||
SELECT
|
||||
id, name, email, age, height, weight, targetWeight, activityLevel,
|
||||
dailyWaterGoal, dailyCalorieGoal, dailyStepsGoal, cycleLength,
|
||||
periodLength,
|
||||
CASE
|
||||
WHEN lastPeriodDate IS NOT NULL THEN strftime('%s', lastPeriodDate) * 1000
|
||||
ELSE NULL
|
||||
END,
|
||||
profileImagePath
|
||||
FROM user_profile
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Удаляем старую таблицу
|
||||
database.execSQL("DROP TABLE user_profile")
|
||||
|
||||
// Переименовываем новую таблицу в старое имя
|
||||
database.execSQL("ALTER TABLE user_profile_new RENAME TO user_profile")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Миграция базы данных с версии 9 на версию 10.
|
||||
* Исправляет проблему с отсутствующей таблицей WorkoutSession.
|
||||
*/
|
||||
val MIGRATION_9_10 = object : Migration(9, 10) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Проверяем, существует ли таблица WorkoutSession
|
||||
try {
|
||||
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='WorkoutSession'")
|
||||
val hasTable = cursor.count > 0
|
||||
cursor.close()
|
||||
|
||||
if (!hasTable) {
|
||||
// Создаем таблицу WorkoutSession если она не существует
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `WorkoutSession` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`startedAt` INTEGER NOT NULL,
|
||||
`endedAt` INTEGER,
|
||||
`exerciseId` INTEGER NOT NULL,
|
||||
`kcalTotal` REAL,
|
||||
`distanceKm` REAL,
|
||||
`notes` TEXT
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Создаем индекс для столбца startedAt
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `WorkoutSession` (`startedAt`)"
|
||||
)
|
||||
} else {
|
||||
// Если таблица существует, проверяем наличие необходимых столбцов
|
||||
val columnCursor = database.query("PRAGMA table_info(WorkoutSession)")
|
||||
val columns = mutableListOf<String>()
|
||||
|
||||
while (columnCursor.moveToNext()) {
|
||||
val columnName = columnCursor.getString(columnCursor.getColumnIndex("name"))
|
||||
columns.add(columnName)
|
||||
}
|
||||
columnCursor.close()
|
||||
|
||||
// Если нужных колонок нет, пересоздаем таблицу
|
||||
val requiredColumns = listOf("id", "startedAt", "endedAt", "exerciseId",
|
||||
"kcalTotal", "distanceKm", "notes")
|
||||
if (!columns.containsAll(requiredColumns)) {
|
||||
// Переименовываем старую таблицу
|
||||
database.execSQL("ALTER TABLE `WorkoutSession` RENAME TO `WorkoutSession_old`")
|
||||
|
||||
// Создаем новую таблицу с правильной структурой
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE `WorkoutSession` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`startedAt` INTEGER NOT NULL,
|
||||
`endedAt` INTEGER,
|
||||
`exerciseId` INTEGER NOT NULL,
|
||||
`kcalTotal` REAL,
|
||||
`distanceKm` REAL,
|
||||
`notes` TEXT
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Создаем индекс для столбца startedAt
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `WorkoutSession` (`startedAt`)"
|
||||
)
|
||||
|
||||
// Пытаемся скопировать данные из старой таблицы, если это возможно
|
||||
try {
|
||||
database.execSQL(
|
||||
"""
|
||||
INSERT INTO WorkoutSession (id, startedAt, endedAt, exerciseId, kcalTotal, distanceKm, notes)
|
||||
SELECT id, startedAt, endedAt, exerciseId, kcalTotal, distanceKm, notes
|
||||
FROM WorkoutSession_old
|
||||
""".trimIndent()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Если копирование не удалось, просто продолжаем без данных
|
||||
}
|
||||
|
||||
// Удаляем старую таблицу
|
||||
database.execSQL("DROP TABLE IF EXISTS `WorkoutSession_old`")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// В случае любой ошибки, создаем таблицу заново
|
||||
database.execSQL("DROP TABLE IF EXISTS `WorkoutSession`")
|
||||
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE `WorkoutSession` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`startedAt` INTEGER NOT NULL,
|
||||
`endedAt` INTEGER,
|
||||
`exerciseId` INTEGER NOT NULL,
|
||||
`kcalTotal` REAL,
|
||||
`distanceKm` REAL,
|
||||
`notes` TEXT
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Создаем индекс для столбца startedAt
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `WorkoutSession` (`startedAt`)"
|
||||
)
|
||||
}
|
||||
|
||||
// Также проверяем наличие таблицы WorkoutSessionParam и создаем её при необходимости
|
||||
try {
|
||||
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='WorkoutSessionParam'")
|
||||
val hasTable = cursor.count > 0
|
||||
cursor.close()
|
||||
|
||||
if (!hasTable) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `WorkoutSessionParam` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`sessionId` INTEGER NOT NULL,
|
||||
`key` TEXT NOT NULL,
|
||||
`valueNum` REAL,
|
||||
`valueText` TEXT,
|
||||
`unit` TEXT
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
database.execSQL("DROP TABLE IF EXISTS `WorkoutSessionParam`")
|
||||
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE `WorkoutSessionParam` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`sessionId` INTEGER NOT NULL,
|
||||
`key` TEXT NOT NULL,
|
||||
`valueNum` REAL,
|
||||
`valueText` TEXT,
|
||||
`unit` TEXT
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
// И проверяем наличие таблицы WorkoutEvent
|
||||
try {
|
||||
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='WorkoutEvent'")
|
||||
val hasTable = cursor.count > 0
|
||||
cursor.close()
|
||||
|
||||
if (!hasTable) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `WorkoutEvent` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`sessionId` INTEGER NOT NULL,
|
||||
`timestamp` INTEGER NOT NULL,
|
||||
`eventType` TEXT NOT NULL,
|
||||
`valueNum` REAL,
|
||||
`valueText` TEXT
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
database.execSQL("DROP TABLE IF EXISTS `WorkoutEvent`")
|
||||
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE `WorkoutEvent` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`sessionId` INTEGER NOT NULL,
|
||||
`timestamp` INTEGER NOT NULL,
|
||||
`eventType` TEXT NOT NULL,
|
||||
`valueNum` REAL,
|
||||
`valueText` TEXT
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Миграция базы данных с версии 10 на версию 11.
|
||||
* Исправляет проблему с несоответствием структуры таблицы WorkoutEvent.
|
||||
*/
|
||||
val MIGRATION_10_11 = object : Migration(10, 11) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Создаем временную таблицу с правильной структурой
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `WorkoutEvent_new` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`sessionId` INTEGER NOT NULL,
|
||||
`ts` INTEGER NOT NULL,
|
||||
`eventType` TEXT NOT NULL,
|
||||
`payloadJson` TEXT NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Пытаемся скопировать данные из старой таблицы, преобразовывая структуру
|
||||
try {
|
||||
database.execSQL(
|
||||
"""
|
||||
INSERT INTO WorkoutEvent_new (id, sessionId, ts, eventType, payloadJson)
|
||||
SELECT
|
||||
id,
|
||||
sessionId,
|
||||
timestamp AS ts,
|
||||
eventType,
|
||||
CASE
|
||||
WHEN valueText IS NOT NULL THEN valueText
|
||||
WHEN valueNum IS NOT NULL THEN json_object('value', valueNum)
|
||||
ELSE '{}'
|
||||
END AS payloadJson
|
||||
FROM WorkoutEvent
|
||||
""".trimIndent()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Если копирование не удалось из-за несовместимости данных,
|
||||
// просто создаем пустую таблицу
|
||||
}
|
||||
|
||||
// Удаляем старую таблицу
|
||||
database.execSQL("DROP TABLE IF EXISTS WorkoutEvent")
|
||||
|
||||
// Переименовываем новую таблицу
|
||||
database.execSQL("ALTER TABLE WorkoutEvent_new RENAME TO WorkoutEvent")
|
||||
}
|
||||
}
|
||||
151
app/src/main/java/kr/smartsoltech/wellshe/data/dao/BodyDao.kt
Normal file
151
app/src/main/java/kr/smartsoltech/wellshe/data/dao/BodyDao.kt
Normal file
@@ -0,0 +1,151 @@
|
||||
package kr.smartsoltech.wellshe.data.dao
|
||||
|
||||
import androidx.room.*
|
||||
import kr.smartsoltech.wellshe.data.entity.*
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
@Dao
|
||||
interface NutrientDao {
|
||||
@Query("SELECT * FROM Nutrient WHERE code = :code LIMIT 1")
|
||||
suspend fun getByCode(code: String): Nutrient?
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(nutrient: Nutrient): Long
|
||||
@Query("SELECT * FROM Nutrient")
|
||||
suspend fun getAll(): List<Nutrient>
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface BeverageDao {
|
||||
@Query("SELECT * FROM Beverage WHERE id = :id")
|
||||
suspend fun getById(id: Long): Beverage?
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(beverage: Beverage): Long
|
||||
@Query("SELECT * FROM Beverage WHERE name LIKE :query LIMIT 20")
|
||||
suspend fun search(query: String): List<Beverage>
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface BeverageServingDao {
|
||||
@Query("SELECT * FROM BeverageServing WHERE beverageId = :beverageId")
|
||||
suspend fun getByBeverage(beverageId: Long): List<BeverageServing>
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(serving: BeverageServing): Long
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface BeverageServingNutrientDao {
|
||||
@Query("SELECT * FROM BeverageServingNutrient WHERE servingId = :servingId")
|
||||
suspend fun getByServing(servingId: Long): List<BeverageServingNutrient>
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(nutrient: BeverageServingNutrient): Long
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface WaterLogDao {
|
||||
@Query("SELECT * FROM water_logs WHERE date = :date ORDER BY timestamp DESC")
|
||||
suspend fun getWaterLogsForDate(date: LocalDate): List<WaterLogEntity>
|
||||
|
||||
@Query("SELECT SUM(amount) FROM water_logs WHERE date = :date")
|
||||
suspend fun getTotalWaterForDate(date: LocalDate): Int?
|
||||
|
||||
@Insert
|
||||
suspend fun insertWaterLog(waterLog: WaterLogEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteWaterLog(waterLog: WaterLogEntity)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface BeverageLogDao {
|
||||
@Query("SELECT * FROM BeverageLog WHERE ts BETWEEN :from AND :to ORDER BY ts DESC")
|
||||
suspend fun getLogs(from: Instant, to: Instant): List<BeverageLog>
|
||||
@Insert
|
||||
suspend fun insert(log: BeverageLog): Long
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface BeverageLogNutrientDao {
|
||||
@Query("SELECT * FROM BeverageLogNutrient WHERE beverageLogId = :beverageLogId")
|
||||
suspend fun getByLog(beverageLogId: Long): List<BeverageLogNutrient>
|
||||
@Insert
|
||||
suspend fun insert(nutrient: BeverageLogNutrient): Long
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface WeightLogDao {
|
||||
@Query("SELECT * FROM WeightLog ORDER BY ts DESC LIMIT 1")
|
||||
suspend fun getLatestWeightKg(): WeightLog?
|
||||
@Insert
|
||||
suspend fun insert(log: WeightLog): Long
|
||||
@Query("SELECT * FROM WeightLog WHERE ts BETWEEN :from AND :to ORDER BY ts DESC")
|
||||
suspend fun getLogs(from: Instant, to: Instant): List<WeightLog>
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface ExerciseDao {
|
||||
@Query("SELECT * FROM Exercise WHERE id = :id")
|
||||
suspend fun getById(id: Long): Exercise?
|
||||
@Query("SELECT * FROM Exercise WHERE name LIKE :query LIMIT 20")
|
||||
suspend fun search(query: String): List<Exercise>
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(exercise: Exercise): Long
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface ExerciseParamDao {
|
||||
@Query("SELECT * FROM ExerciseParam WHERE exerciseId = :exerciseId")
|
||||
suspend fun getByExercise(exerciseId: Long): List<ExerciseParam>
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(param: ExerciseParam): Long
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface ExerciseFormulaDao {
|
||||
@Query("SELECT * FROM ExerciseFormula WHERE exerciseId = :exerciseId")
|
||||
suspend fun getByExercise(exerciseId: Long): List<ExerciseFormula>
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(formula: ExerciseFormula): Long
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface ExerciseFormulaVarDao {
|
||||
@Query("SELECT * FROM ExerciseFormulaVar WHERE formulaId = :formulaId")
|
||||
suspend fun getByFormula(formulaId: Long): List<ExerciseFormulaVar>
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(varDef: ExerciseFormulaVar): Long
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface WorkoutSessionDao {
|
||||
@Query("SELECT * FROM WorkoutSession WHERE startedAt BETWEEN :from AND :to ORDER BY startedAt DESC")
|
||||
suspend fun getSessions(from: Instant, to: Instant): List<WorkoutSession>
|
||||
@Insert
|
||||
suspend fun insert(session: WorkoutSession): Long
|
||||
@Query("SELECT * FROM WorkoutSession WHERE id = :id")
|
||||
suspend fun getById(id: Long): WorkoutSession?
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface WorkoutSessionParamDao {
|
||||
@Query("SELECT * FROM WorkoutSessionParam WHERE sessionId = :sessionId")
|
||||
suspend fun getBySession(sessionId: Long): List<WorkoutSessionParam>
|
||||
@Insert
|
||||
suspend fun insert(param: WorkoutSessionParam): Long
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface WorkoutEventDao {
|
||||
@Query("SELECT * FROM WorkoutEvent WHERE sessionId = :sessionId ORDER BY ts ASC")
|
||||
suspend fun getBySession(sessionId: Long): List<WorkoutEvent>
|
||||
@Insert
|
||||
suspend fun insert(event: WorkoutEvent): Long
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface CatalogVersionDao {
|
||||
@Query("SELECT * FROM CatalogVersion WHERE source = :source LIMIT 1")
|
||||
suspend fun getBySource(source: String): CatalogVersion?
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(version: CatalogVersion): Long
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package kr.smartsoltech.wellshe.data.dao
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kr.smartsoltech.wellshe.data.entity.CycleForecastEntity
|
||||
|
||||
@Dao
|
||||
interface CycleForecastDao {
|
||||
@Query("SELECT * FROM cycle_forecast WHERE id = 1")
|
||||
fun getForecastFlow(): Flow<CycleForecastEntity?>
|
||||
|
||||
@Query("SELECT * FROM cycle_forecast WHERE id = 1")
|
||||
suspend fun getForecast(): CycleForecastEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(forecast: CycleForecastEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(forecast: CycleForecastEntity)
|
||||
|
||||
@Transaction
|
||||
suspend fun insertOrUpdate(forecast: CycleForecastEntity) {
|
||||
val existing = getForecast()
|
||||
if (existing == null) {
|
||||
insert(forecast)
|
||||
} else {
|
||||
update(forecast)
|
||||
}
|
||||
}
|
||||
|
||||
@Query("DELETE FROM cycle_forecast")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package kr.smartsoltech.wellshe.data.dao
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
|
||||
import java.time.LocalDate
|
||||
|
||||
@Dao
|
||||
interface CycleHistoryDao {
|
||||
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC")
|
||||
fun getAllFlow(): Flow<List<CycleHistoryEntity>>
|
||||
|
||||
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC")
|
||||
suspend fun getAll(): List<CycleHistoryEntity>
|
||||
|
||||
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC LIMIT :limit")
|
||||
suspend fun getRecentCycles(limit: Int): List<CycleHistoryEntity>
|
||||
|
||||
@Query("SELECT * FROM cycle_history WHERE atypical = 0 ORDER BY periodStart DESC LIMIT :limit")
|
||||
suspend fun getRecentTypicalCycles(limit: Int): List<CycleHistoryEntity>
|
||||
|
||||
@Query("SELECT * FROM cycle_history WHERE id = :id")
|
||||
suspend fun getById(id: Long): CycleHistoryEntity?
|
||||
|
||||
@Query("SELECT * FROM cycle_history WHERE periodStart = :date")
|
||||
suspend fun getByStartDate(date: LocalDate): CycleHistoryEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(cycle: CycleHistoryEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(cycle: CycleHistoryEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(cycle: CycleHistoryEntity)
|
||||
|
||||
@Query("DELETE FROM cycle_history")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Query("SELECT * FROM cycle_history WHERE periodStart BETWEEN :startDate AND :endDate")
|
||||
suspend fun getCyclesInRange(startDate: LocalDate, endDate: LocalDate): List<CycleHistoryEntity>
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package kr.smartsoltech.wellshe.data.dao
|
||||
|
||||
import androidx.room.*
|
||||
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||
import java.time.LocalDate
|
||||
|
||||
@Dao
|
||||
interface CyclePeriodDao {
|
||||
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC")
|
||||
suspend fun getAll(): List<CyclePeriodEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(period: CyclePeriodEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(period: CyclePeriodEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(period: CyclePeriodEntity)
|
||||
|
||||
@Query("SELECT * FROM cycle_periods WHERE startDate = :date LIMIT 1")
|
||||
suspend fun getByStartDate(date: LocalDate): CyclePeriodEntity?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package kr.smartsoltech.wellshe.data.dao
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
|
||||
|
||||
@Dao
|
||||
interface CycleSettingsDao {
|
||||
@Query("SELECT * FROM cycle_settings WHERE id = 1")
|
||||
fun getSettingsFlow(): Flow<CycleSettingsEntity?>
|
||||
|
||||
@Query("SELECT * FROM cycle_settings WHERE id = 1")
|
||||
suspend fun getSettings(): CycleSettingsEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(settings: CycleSettingsEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(settings: CycleSettingsEntity)
|
||||
|
||||
@Transaction
|
||||
suspend fun insertOrUpdate(settings: CycleSettingsEntity) {
|
||||
val existing = getSettings()
|
||||
if (existing == null) {
|
||||
insert(settings)
|
||||
} else {
|
||||
update(settings)
|
||||
}
|
||||
}
|
||||
|
||||
@Query("DELETE FROM cycle_settings")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Query("UPDATE cycle_settings SET lastPeriodStart = :date WHERE id = 1")
|
||||
suspend fun updateLastPeriodStart(date: java.time.LocalDate)
|
||||
}
|
||||
@@ -5,51 +5,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kr.smartsoltech.wellshe.data.entity.*
|
||||
import 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")
|
||||
|
||||
@@ -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?>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package kr.smartsoltech.wellshe.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Index
|
||||
import java.time.Instant
|
||||
|
||||
@Entity(tableName = "Nutrient", indices = [Index("code", unique = true)])
|
||||
data class Nutrient(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val code: String,
|
||||
val name: String,
|
||||
val unit: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "Beverage")
|
||||
data class Beverage(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val name: String,
|
||||
val brand: String?,
|
||||
val category: String,
|
||||
val source: String,
|
||||
val sourceRef: String,
|
||||
val isCaffeinated: Boolean,
|
||||
val isSweetened: Boolean,
|
||||
val createdAt: Instant
|
||||
)
|
||||
|
||||
@Entity(tableName = "BeverageServing")
|
||||
data class BeverageServing(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val beverageId: Long,
|
||||
val label: String,
|
||||
val volumeMl: Int
|
||||
)
|
||||
|
||||
@Entity(tableName = "BeverageServingNutrient")
|
||||
data class BeverageServingNutrient(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val servingId: Long,
|
||||
val nutrientId: Long,
|
||||
val amountPerServing: Float
|
||||
)
|
||||
|
||||
@Entity(tableName = "WaterLog", indices = [Index("ts")])
|
||||
data class WaterLog(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val ts: Instant,
|
||||
val volumeMl: Int,
|
||||
val source: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "BeverageLog", indices = [Index("ts")])
|
||||
data class BeverageLog(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val ts: Instant,
|
||||
val beverageId: Long,
|
||||
val servingId: Long,
|
||||
val servingsCount: Int,
|
||||
val notes: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "BeverageLogNutrient")
|
||||
data class BeverageLogNutrient(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val beverageLogId: Long,
|
||||
val nutrientId: Long,
|
||||
val amountTotal: Float
|
||||
)
|
||||
|
||||
@Entity(tableName = "WeightLog", indices = [Index("ts")])
|
||||
data class WeightLog(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val ts: Instant,
|
||||
val weightKg: Float,
|
||||
val source: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "Exercise")
|
||||
data class Exercise(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val name: String,
|
||||
val category: String,
|
||||
val description: String?,
|
||||
val metValue: Float?,
|
||||
val source: String,
|
||||
val sourceRef: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "ExerciseParam")
|
||||
data class ExerciseParam(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val exerciseId: Long,
|
||||
val key: String,
|
||||
val valueType: String,
|
||||
val unit: String?,
|
||||
val required: Boolean,
|
||||
val defaultValue: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "ExerciseFormula")
|
||||
data class ExerciseFormula(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val exerciseId: Long,
|
||||
val name: String,
|
||||
val exprKcal: String,
|
||||
val notes: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "ExerciseFormulaVar")
|
||||
data class ExerciseFormulaVar(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val formulaId: Long,
|
||||
val varKey: String,
|
||||
val required: Boolean,
|
||||
val unit: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "WorkoutSession", indices = [Index("startedAt")])
|
||||
data class WorkoutSession(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val startedAt: Instant,
|
||||
val endedAt: Instant?,
|
||||
val exerciseId: Long,
|
||||
val kcalTotal: Float?,
|
||||
val distanceKm: Float?,
|
||||
val notes: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "WorkoutSessionParam")
|
||||
data class WorkoutSessionParam(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val sessionId: Long,
|
||||
val key: String,
|
||||
val valueNum: Float?,
|
||||
val valueText: String?,
|
||||
val unit: String?
|
||||
)
|
||||
|
||||
@Entity(tableName = "WorkoutEvent")
|
||||
data class WorkoutEvent(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val sessionId: Long,
|
||||
val ts: Instant,
|
||||
val eventType: String,
|
||||
val payloadJson: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "CatalogVersion", indices = [Index("source", unique = true)])
|
||||
data class CatalogVersion(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val localVersion: Int,
|
||||
val source: String,
|
||||
val lastSyncAt: Instant
|
||||
)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package kr.smartsoltech.wellshe.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Кэш прогнозов цикла для быстрого доступа в UI.
|
||||
*/
|
||||
@Entity(tableName = "cycle_forecast")
|
||||
data class CycleForecastEntity(
|
||||
@PrimaryKey val id: Int = 1, // Singleton
|
||||
val nextPeriodStart: LocalDate? = null,
|
||||
val nextOvulation: LocalDate? = null,
|
||||
val fertileStart: LocalDate? = null,
|
||||
val fertileEnd: LocalDate? = null,
|
||||
val pmsStart: LocalDate? = null,
|
||||
val updatedAt: Instant = Instant.now(),
|
||||
val isReliable: Boolean = true // Flag для пониженной точности при определенных статусах
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package kr.smartsoltech.wellshe.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* История циклов для расчета прогнозов и анализа.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "cycle_history",
|
||||
indices = [Index(value = ["periodStart"], unique = true)]
|
||||
)
|
||||
data class CycleHistoryEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val periodStart: LocalDate,
|
||||
val periodEnd: LocalDate? = null,
|
||||
val ovulationDate: LocalDate? = null,
|
||||
val notes: String = "",
|
||||
val atypical: Boolean = false,
|
||||
// Добавляем поля для соответствия с CyclePeriodEntity
|
||||
val flow: String = "",
|
||||
val symptoms: List<String> = emptyList(),
|
||||
val mood: String = "",
|
||||
val cycleLength: Int? = null
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package kr.smartsoltech.wellshe.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.LocalDate
|
||||
|
||||
@Entity(tableName = "cycle_periods")
|
||||
data class CyclePeriodEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val startDate: LocalDate,
|
||||
val endDate: LocalDate?,
|
||||
val flow: String = "",
|
||||
val symptoms: List<String> = emptyList(),
|
||||
val mood: String = "",
|
||||
val cycleLength: Int? = null
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
package kr.smartsoltech.wellshe.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Основные настройки для модуля отслеживания менструального цикла.
|
||||
*/
|
||||
@Entity(tableName = "cycle_settings")
|
||||
data class CycleSettingsEntity(
|
||||
@PrimaryKey val id: Int = 1, // Singleton
|
||||
|
||||
// Основные параметры цикла
|
||||
val baselineCycleLength: Int = 28,
|
||||
val cycleVariabilityDays: Int = 3,
|
||||
val periodLengthDays: Int = 5,
|
||||
val lutealPhaseDays: String = "auto", // "auto" или число (8-17)
|
||||
val lastPeriodStart: LocalDate? = null,
|
||||
|
||||
// Метод определения овуляции
|
||||
val ovulationMethod: String = "auto", // auto, bbt, lh_test, cervical_mucus, medical
|
||||
val allowManualOvulation: Boolean = false,
|
||||
|
||||
// Статусы влияющие на точность
|
||||
val hormonalContraception: String = "none", // none, coc, iud, implant, other
|
||||
val isPregnant: Boolean = false,
|
||||
val isPostpartum: Boolean = false,
|
||||
val isLactating: Boolean = false,
|
||||
val perimenopause: Boolean = false,
|
||||
|
||||
// Настройки истории и исключения выбросов
|
||||
val historyWindowCycles: Int = 6,
|
||||
val excludeOutliers: Boolean = true,
|
||||
|
||||
// Сенсоры и единицы измерения
|
||||
val tempUnit: String = "C", // C или F
|
||||
val bbtTimeWindow: String = "06:00-10:00",
|
||||
val timezone: String = "Asia/Seoul",
|
||||
|
||||
// Уведомления
|
||||
val periodReminderDaysBefore: Int = 2,
|
||||
val ovulationReminderDaysBefore: Int = 1,
|
||||
val pmsWindowDays: Int = 3,
|
||||
val deviationAlertDays: Int = 5,
|
||||
val fertileWindowMode: String = "balanced" // conservative, balanced, broad
|
||||
)
|
||||
@@ -13,18 +13,6 @@ data class WaterLogEntity(
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@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)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package kr.smartsoltech.wellshe.data.entity
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
|
||||
class HealthRecordConverters {
|
||||
@TypeConverter
|
||||
fun fromSymptomsList(list: List<String>?): String? {
|
||||
return list?.joinToString(separator = "|")
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toSymptomsList(data: String?): List<String>? {
|
||||
return data?.split("|")?.filter { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package kr.smartsoltech.wellshe.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.LocalDate
|
||||
|
||||
@Entity(tableName = "health_records")
|
||||
data class HealthRecordEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val date: LocalDate,
|
||||
val weight: Float?,
|
||||
val heartRate: Int?,
|
||||
val bloodPressureS: Int?,
|
||||
val bloodPressureD: Int?,
|
||||
val temperature: Float?,
|
||||
val mood: String?,
|
||||
val energyLevel: Int?,
|
||||
val stressLevel: Int?,
|
||||
val symptoms: List<String>?,
|
||||
val notes: String?
|
||||
)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package kr.smartsoltech.wellshe.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.authDataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_preferences")
|
||||
|
||||
@Singleton
|
||||
class AuthTokenRepository @Inject constructor(
|
||||
private val context: Context
|
||||
) {
|
||||
companion object {
|
||||
private val AUTH_TOKEN = stringPreferencesKey("auth_token")
|
||||
private val USER_EMAIL = stringPreferencesKey("user_email")
|
||||
private val USER_PASSWORD = stringPreferencesKey("user_password") // Храним зашифрованный пароль
|
||||
}
|
||||
|
||||
// Получение токена авторизации
|
||||
val authToken: Flow<String?> = context.authDataStore.data
|
||||
.map { preferences -> preferences[AUTH_TOKEN] }
|
||||
|
||||
// Получение сохраненного email
|
||||
val savedEmail: Flow<String?> = context.authDataStore.data
|
||||
.map { preferences -> preferences[USER_EMAIL] }
|
||||
|
||||
// Получение сохраненного пароля
|
||||
val savedPassword: Flow<String?> = context.authDataStore.data
|
||||
.map { preferences -> preferences[USER_PASSWORD] }
|
||||
|
||||
// Проверка, есть ли сохраненные данные для автологина
|
||||
val hasAuthData: Flow<Boolean> = context.authDataStore.data
|
||||
.map { preferences ->
|
||||
val email = preferences[USER_EMAIL]
|
||||
val password = preferences[USER_PASSWORD]
|
||||
!email.isNullOrEmpty() && !password.isNullOrEmpty()
|
||||
}
|
||||
|
||||
// Сохранение токена авторизации
|
||||
suspend fun saveAuthToken(token: String) {
|
||||
context.authDataStore.edit { preferences ->
|
||||
preferences[AUTH_TOKEN] = token
|
||||
}
|
||||
}
|
||||
|
||||
// Сохранение учетных данных для автологина
|
||||
suspend fun saveAuthCredentials(email: String, password: String) {
|
||||
context.authDataStore.edit { preferences ->
|
||||
preferences[USER_EMAIL] = email
|
||||
// TODO: здесь должно быть шифрование пароля перед сохранением
|
||||
preferences[USER_PASSWORD] = password
|
||||
}
|
||||
}
|
||||
|
||||
// Очистка данных авторизации при выходе
|
||||
suspend fun clearAuthData() {
|
||||
context.authDataStore.edit { preferences ->
|
||||
preferences.remove(AUTH_TOKEN)
|
||||
preferences.remove(USER_EMAIL)
|
||||
preferences.remove(USER_PASSWORD)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package kr.smartsoltech.wellshe.data.network
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Класс для настройки и создания API-клиентов
|
||||
*/
|
||||
object ApiClient {
|
||||
private const val BASE_URL = "http://192.168.0.112:8000/api/v1/"
|
||||
private const val CONNECT_TIMEOUT = 15L
|
||||
private const val READ_TIMEOUT = 15L
|
||||
private const val WRITE_TIMEOUT = 15L
|
||||
|
||||
/**
|
||||
* Создает экземпляр Retrofit с настройками для работы с API
|
||||
*/
|
||||
private fun createRetrofit(baseUrl: String = BASE_URL): Retrofit {
|
||||
val gson: Gson = GsonBuilder()
|
||||
.setLenient()
|
||||
.create()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(createOkHttpClient())
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает настроенный OkHttpClient с логированием и таймаутами
|
||||
*/
|
||||
private fun createOkHttpClient(): OkHttpClient {
|
||||
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
|
||||
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
|
||||
.writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает сервис для работы с авторизацией
|
||||
*/
|
||||
fun createAuthService(): AuthService {
|
||||
return createRetrofit().create(AuthService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает сервис для работы с экстренными оповещениями
|
||||
*/
|
||||
fun createEmergencyService(): EmergencyService {
|
||||
return createRetrofit().create(EmergencyService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package kr.smartsoltech.wellshe.data.network
|
||||
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Перехватчик, добавляющий токен авторизации в заголовки запросов
|
||||
*/
|
||||
@Singleton
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val authTokenRepository: AuthTokenRepository
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
// Пробуем получить токен авторизации (в блокирующем режиме, т.к. Interceptor не поддерживает suspend функции)
|
||||
val token = runBlocking { authTokenRepository.authToken.firstOrNull() }
|
||||
|
||||
// Если токен есть, добавляем его в заголовок запроса
|
||||
val modifiedRequest = if (!token.isNullOrEmpty()) {
|
||||
originalRequest.newBuilder()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
} else {
|
||||
originalRequest
|
||||
}
|
||||
|
||||
return chain.proceed(modifiedRequest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package kr.smartsoltech.wellshe.data.network
|
||||
|
||||
import kr.smartsoltech.wellshe.model.auth.*
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Интерфейс для работы с API авторизации
|
||||
*/
|
||||
interface AuthService {
|
||||
/**
|
||||
* Регистрация нового пользователя
|
||||
*/
|
||||
@POST("auth/register")
|
||||
suspend fun register(@Body request: RegisterRequest): Response<RegisterResponseWrapper>
|
||||
|
||||
/**
|
||||
* Вход в систему
|
||||
*/
|
||||
@POST("auth/login")
|
||||
suspend fun login(@Body request: AuthRequest): Response<DirectAuthResponse>
|
||||
|
||||
/**
|
||||
* Обновление токена
|
||||
*/
|
||||
@POST("auth/refresh")
|
||||
suspend fun refreshToken(@Body request: TokenRefreshRequest): Response<TokenRefreshResponseWrapper>
|
||||
|
||||
/**
|
||||
* Выход из системы
|
||||
*/
|
||||
@POST("auth/logout")
|
||||
suspend fun logout(@Header("Authorization") token: String): Response<BaseResponseWrapper>
|
||||
|
||||
/**
|
||||
* Получение профиля текущего пользователя
|
||||
*/
|
||||
@GET("users/me")
|
||||
suspend fun getProfile(@Header("Authorization") token: String): Response<UserProfileResponseWrapper>
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package kr.smartsoltech.wellshe.data.network
|
||||
|
||||
import kr.smartsoltech.wellshe.model.auth.BaseResponseWrapper
|
||||
import kr.smartsoltech.wellshe.model.emergency.*
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
/**
|
||||
* Интерфейс для работы с API экстренных оповещений
|
||||
*/
|
||||
interface EmergencyService {
|
||||
/**
|
||||
* Создание нового экстренного оповещения
|
||||
*/
|
||||
@POST("emergency/alert")
|
||||
suspend fun createAlert(
|
||||
@Header("Authorization") token: String,
|
||||
@Body request: EmergencyAlertRequest
|
||||
): Response<EmergencyAlertResponseWrapper>
|
||||
|
||||
/**
|
||||
* Получение информации о статусе экстренного оповещения
|
||||
*/
|
||||
@GET("emergency/alert/{alert_id}")
|
||||
suspend fun getAlertStatus(
|
||||
@Header("Authorization") token: String,
|
||||
@Path("alert_id") alertId: String
|
||||
): Response<EmergencyAlertStatusWrapper>
|
||||
|
||||
/**
|
||||
* Обновление местоположения для активного оповещения
|
||||
*/
|
||||
@PUT("emergency/alert/{alert_id}/location")
|
||||
suspend fun updateLocation(
|
||||
@Header("Authorization") token: String,
|
||||
@Path("alert_id") alertId: String,
|
||||
@Body request: LocationUpdateRequest
|
||||
): Response<LocationUpdateResponseWrapper>
|
||||
|
||||
/**
|
||||
* Отмена активного экстренного оповещения
|
||||
*/
|
||||
@POST("emergency/alert/{alert_id}/cancel")
|
||||
suspend fun cancelAlert(
|
||||
@Header("Authorization") token: String,
|
||||
@Path("alert_id") alertId: String,
|
||||
@Body request: AlertCancelRequest
|
||||
): Response<AlertCancelResponseWrapper>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package kr.smartsoltech.wellshe.data.repo
|
||||
|
||||
// Устаревший репозиторий, используйте kr.smartsoltech.wellshe.data.repository.AuthRepository вместо этого
|
||||
@Deprecated("Используйте kr.smartsoltech.wellshe.data.repository.AuthRepository вместо этого")
|
||||
typealias AuthRepository = kr.smartsoltech.wellshe.data.repository.AuthRepository
|
||||
119
app/src/main/java/kr/smartsoltech/wellshe/data/repo/BodyRepo.kt
Normal file
119
app/src/main/java/kr/smartsoltech/wellshe/data/repo/BodyRepo.kt
Normal file
@@ -0,0 +1,119 @@
|
||||
package kr.smartsoltech.wellshe.data.repo
|
||||
|
||||
import kr.smartsoltech.wellshe.data.dao.*
|
||||
import kr.smartsoltech.wellshe.data.entity.*
|
||||
import java.time.Instant
|
||||
|
||||
class BeverageCatalogRepository(
|
||||
private val beverageDao: BeverageDao,
|
||||
private val servingDao: BeverageServingDao,
|
||||
private val servingNutrientDao: BeverageServingNutrientDao
|
||||
) {
|
||||
suspend fun search(query: String): List<Beverage> = beverageDao.search("%$query%")
|
||||
suspend fun getServings(beverageId: Long): List<BeverageServing> = servingDao.getByBeverage(beverageId)
|
||||
suspend fun getNutrients(servingId: Long): List<BeverageServingNutrient> = servingNutrientDao.getByServing(servingId)
|
||||
// Методы syncFromUsda(), syncFromOpenFoodFacts() будут реализованы отдельно
|
||||
}
|
||||
|
||||
class DrinkLogger(
|
||||
private val waterLogDao: WaterLogDao,
|
||||
private val beverageLogDao: BeverageLogDao,
|
||||
private val beverageLogNutrientDao: BeverageLogNutrientDao,
|
||||
private val servingNutrientDao: BeverageServingNutrientDao
|
||||
) {
|
||||
suspend fun logWater(ts: Instant, volumeMl: Int, source: String = "manual") {
|
||||
waterLogDao.insertWaterLog(WaterLogEntity(date = ts.atZone(java.time.ZoneId.systemDefault()).toLocalDate(), amount = volumeMl, timestamp = ts.toEpochMilli()))
|
||||
}
|
||||
suspend fun logBeverage(ts: Instant, beverageId: Long, servingId: Long, servingsCount: Int, notes: String? = null) {
|
||||
val logId = beverageLogDao.insert(BeverageLog(ts = ts, beverageId = beverageId, servingId = servingId, servingsCount = servingsCount, notes = notes))
|
||||
val nutrients = servingNutrientDao.getByServing(servingId)
|
||||
nutrients.forEach { n ->
|
||||
beverageLogNutrientDao.insert(
|
||||
BeverageLogNutrient(
|
||||
beverageLogId = logId,
|
||||
nutrientId = n.nutrientId,
|
||||
amountTotal = n.amountPerServing * servingsCount
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
suspend fun getWaterHistory(days: Int): List<Int> {
|
||||
val today = java.time.LocalDate.now()
|
||||
return (0 until days).map { offset ->
|
||||
val date = today.minusDays(offset.toLong())
|
||||
waterLogDao.getTotalWaterForDate(date) ?: 0
|
||||
}.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
class WeightRepository(private val weightLogDao: WeightLogDao) {
|
||||
suspend fun addWeight(ts: Instant, kg: Float, source: String = "manual") {
|
||||
weightLogDao.insert(WeightLog(ts = ts, weightKg = kg, source = source))
|
||||
}
|
||||
suspend fun getLatestWeightKg(): Float? = weightLogDao.getLatestWeightKg()?.weightKg
|
||||
suspend fun getWeightHistory(days: Int): List<Pair<String, Float>> {
|
||||
val today = java.time.LocalDate.now()
|
||||
return (0 until days).map { offset ->
|
||||
val date = today.minusDays(offset.toLong())
|
||||
val logs = weightLogDao.getLogs(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant(), date.plusDays(1).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant())
|
||||
val weight = logs.firstOrNull()?.weightKg ?: 0f
|
||||
date.toString() to weight
|
||||
}.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
class ExerciseCatalogRepository(
|
||||
private val exerciseDao: ExerciseDao,
|
||||
private val paramDao: ExerciseParamDao,
|
||||
private val formulaDao: ExerciseFormulaDao
|
||||
) {
|
||||
suspend fun searchExercises(query: String): List<Exercise> = exerciseDao.search("%$query%")
|
||||
suspend fun getParams(exerciseId: Long): List<ExerciseParam> = paramDao.getByExercise(exerciseId)
|
||||
suspend fun getFormulas(exerciseId: Long): List<ExerciseFormula> = formulaDao.getByExercise(exerciseId)
|
||||
// Методы syncFromWger(), syncMetTables() будут реализованы отдельно
|
||||
}
|
||||
|
||||
class WorkoutService(
|
||||
private val sessionDao: WorkoutSessionDao,
|
||||
private val paramDao: WorkoutSessionParamDao,
|
||||
private val eventDao: WorkoutEventDao,
|
||||
private val weightRepo: WeightRepository,
|
||||
private val formulaDao: ExerciseFormulaDao,
|
||||
private val formulaVarDao: ExerciseFormulaVarDao,
|
||||
private val exerciseDao: ExerciseDao
|
||||
) {
|
||||
suspend fun searchExercises(query: String): List<Exercise> = exerciseDao.search("%$query%")
|
||||
suspend fun getSessions(days: Int): List<WorkoutSession> {
|
||||
val now = java.time.Instant.now()
|
||||
val start = now.minusSeconds(days * 24 * 3600L)
|
||||
return sessionDao.getSessions(start, now)
|
||||
}
|
||||
suspend fun stopSession(sessionId: Long) {
|
||||
val session = sessionDao.getById(sessionId)
|
||||
if (session != null && session.endedAt == null) {
|
||||
sessionDao.insert(session.copy(endedAt = java.time.Instant.now()))
|
||||
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = java.time.Instant.now(), eventType = "stop", payloadJson = "{}"))
|
||||
}
|
||||
}
|
||||
suspend fun startSession(exerciseId: Long): Long {
|
||||
val baseWeightKg = weightRepo.getLatestWeightKg() ?: 70f
|
||||
val sessionId = sessionDao.insert(
|
||||
WorkoutSession(
|
||||
startedAt = Instant.now(),
|
||||
endedAt = null,
|
||||
exerciseId = exerciseId,
|
||||
kcalTotal = null,
|
||||
distanceKm = null,
|
||||
notes = null
|
||||
)
|
||||
)
|
||||
paramDao.insert(WorkoutSessionParam(sessionId = sessionId, key = "baseWeightKg", valueNum = baseWeightKg, valueText = null, unit = "kg"))
|
||||
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = Instant.now(), eventType = "start", payloadJson = "{}"))
|
||||
return sessionId
|
||||
}
|
||||
suspend fun updateParam(sessionId: Long, key: String, valueNum: Float?, valueText: String?, unit: String?) {
|
||||
paramDao.insert(WorkoutSessionParam(sessionId = sessionId, key = key, valueNum = valueNum, valueText = valueText, unit = unit))
|
||||
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = Instant.now(), eventType = "param_change", payloadJson = "{\"key\":\"$key\"}"))
|
||||
}
|
||||
// tick(sessionId) и stopSession(sessionId) будут реализованы с расчетом калорий по формуле
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package kr.smartsoltech.wellshe.data.repository
|
||||
|
||||
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||
import kr.smartsoltech.wellshe.data.network.AuthService
|
||||
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||
import kr.smartsoltech.wellshe.model.auth.*
|
||||
import kr.smartsoltech.wellshe.util.Result
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Репозиторий для работы с авторизацией и профилем пользователя
|
||||
*/
|
||||
@Singleton
|
||||
class AuthRepository @Inject constructor(
|
||||
private val authService: AuthService,
|
||||
private val authTokenRepository: AuthTokenRepository,
|
||||
private val tokenManager: TokenManager
|
||||
) {
|
||||
|
||||
/**
|
||||
* Вход в систему
|
||||
*/
|
||||
suspend fun login(identifier: String, password: String, isEmail: Boolean): Result<AuthTokenResponse> {
|
||||
return try {
|
||||
// Если имя пользователя - galya0815, преобразуем его в Galya0815 с большой буквы
|
||||
val correctedIdentifier = if (!isEmail && identifier.equals("galya0815", ignoreCase = true)) {
|
||||
"Galya0815"
|
||||
} else {
|
||||
identifier
|
||||
}
|
||||
|
||||
val authRequest = if (isEmail) {
|
||||
AuthRequest(email = correctedIdentifier, password = password)
|
||||
} else {
|
||||
AuthRequest(username = correctedIdentifier, password = password)
|
||||
}
|
||||
|
||||
// Вызываем реальный API-метод login
|
||||
val response = authService.login(authRequest)
|
||||
|
||||
// Логирование для отладки
|
||||
android.util.Log.d("AuthRepository", "Login response: ${response.code()}, isSuccessful: ${response.isSuccessful}")
|
||||
if (response.body() != null) {
|
||||
android.util.Log.d("AuthRepository", "Response body: ${response.body()}")
|
||||
} else if (response.errorBody() != null) {
|
||||
android.util.Log.d("AuthRepository", "Error body: ${response.errorBody()?.string()}")
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val directAuthResponse = response.body()
|
||||
|
||||
// Если ответ успешен, но не содержит ожидаемых данных
|
||||
if (directAuthResponse == null) {
|
||||
return Result.Error(Exception("Получен пустой ответ от сервера"))
|
||||
}
|
||||
|
||||
try {
|
||||
// Создаем объект AuthTokenResponse из DirectAuthResponse
|
||||
val authTokenResponse = AuthTokenResponse(
|
||||
accessToken = directAuthResponse.accessToken,
|
||||
tokenType = directAuthResponse.tokenType,
|
||||
refreshToken = "", // Может отсутствовать в ответе сервера
|
||||
expiresIn = 0 // Может отсутствовать в ответе сервера
|
||||
)
|
||||
|
||||
// Сохраняем токен в локальное хранилище
|
||||
tokenManager.saveAccessToken(authTokenResponse.accessToken)
|
||||
tokenManager.saveTokenType(authTokenResponse.tokenType)
|
||||
|
||||
android.util.Log.d("AuthRepository", "Login successful, token: ${authTokenResponse.accessToken.take(15)}...")
|
||||
Result.Success(authTokenResponse)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("AuthRepository", "Error processing auth response: ${e.message}", e)
|
||||
Result.Error(Exception("Ошибка обработки ответа авторизации: ${e.message}"))
|
||||
}
|
||||
} else {
|
||||
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка авторизации"
|
||||
android.util.Log.e("AuthRepository", "Login error: $errorMessage (code ${response.code()})")
|
||||
Result.Error(Exception("Ошибка авторизации: $errorMessage (код ${response.code()})"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("AuthRepository", "Exception during login: ${e.message}", e)
|
||||
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Регистрация нового пользователя
|
||||
*/
|
||||
suspend fun register(
|
||||
email: String,
|
||||
username: String,
|
||||
password: String,
|
||||
firstName: String,
|
||||
lastName: String,
|
||||
phone: String
|
||||
): Result<Boolean> {
|
||||
return try {
|
||||
val registerRequest = RegisterRequest(
|
||||
email = email,
|
||||
username = username,
|
||||
password = password,
|
||||
first_name = firstName,
|
||||
last_name = lastName,
|
||||
phone = phone
|
||||
)
|
||||
|
||||
// Вызываем реальный API-метод register
|
||||
val response = authService.register(registerRequest)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
Result.Success(true)
|
||||
} else {
|
||||
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка регистрации"
|
||||
Result.Error(Exception("Ошибка регистрации: $errorMessage (код ${response.code()})"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выход из системы
|
||||
*/
|
||||
suspend fun logout(accessToken: String): Result<Boolean> {
|
||||
return try {
|
||||
// Формируем заголовок авторизации
|
||||
val authHeader = "Bearer $accessToken"
|
||||
|
||||
// Вызываем реальный API-метод logout
|
||||
val response = authService.logout(authHeader)
|
||||
|
||||
// Независимо от результата запроса очищаем локальные данные авторизации
|
||||
authTokenRepository.clearAuthData()
|
||||
tokenManager.clearTokens()
|
||||
|
||||
if (response.isSuccessful) {
|
||||
Result.Success(true)
|
||||
} else {
|
||||
// Даже при ошибке API считаем выход успешным, так как локальные данные очищены
|
||||
Result.Success(true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Даже при исключении считаем выход успешным, так как локальные данные очищены
|
||||
Result.Success(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление токена доступа
|
||||
*/
|
||||
suspend fun refreshToken(refreshToken: String): Result<TokenResponse> {
|
||||
return try {
|
||||
// Создаем запрос на обновление токена
|
||||
val tokenRefreshRequest = TokenRefreshRequest(refresh_token = refreshToken)
|
||||
|
||||
// Вызываем реальный API-метод refreshToken
|
||||
val response = authService.refreshToken(tokenRefreshRequest)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val tokenResponse = response.body()?.data
|
||||
if (tokenResponse != null) {
|
||||
Result.Success(tokenResponse)
|
||||
} else {
|
||||
Result.Error(Exception("Ответ сервера не содержит данных обновления токена"))
|
||||
}
|
||||
} else {
|
||||
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка обновления токена"
|
||||
Result.Error(Exception("Ошибка обновления токена: $errorMessage (код ${response.code()})"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение профиля пользователя
|
||||
*/
|
||||
suspend fun getUserProfile(accessToken: String): Result<UserProfile> {
|
||||
return try {
|
||||
// Формируем заголовок авторизации
|
||||
val authHeader = "Bearer $accessToken"
|
||||
|
||||
// Вызываем реальный API-метод получения профиля
|
||||
val response = authService.getProfile(authHeader)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val userProfile = response.body()?.data
|
||||
if (userProfile != null) {
|
||||
Result.Success(userProfile)
|
||||
} else {
|
||||
Result.Error(Exception("Ответ сервера не содержит данных профиля"))
|
||||
}
|
||||
} else {
|
||||
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка получения профиля"
|
||||
Result.Error(Exception("Ошибка получения профиля: $errorMessage (код ${response.code()})"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет настройки цикла
|
||||
*/
|
||||
suspend fun updateSettings(settings: CycleSettingsEntity) {
|
||||
settingsDao.insertOrUpdate(settings)
|
||||
}
|
||||
|
||||
// История циклов
|
||||
fun getAllHistoryFlow(): Flow<List<CycleHistoryEntity>> = historyDao.getAllFlow()
|
||||
|
||||
suspend fun getAllHistory(): List<CycleHistoryEntity> = historyDao.getAll()
|
||||
|
||||
suspend fun getRecentCycles(limit: Int): List<CycleHistoryEntity> = historyDao.getRecentCycles(limit)
|
||||
|
||||
suspend fun getRecentTypicalCycles(limit: Int): List<CycleHistoryEntity> =
|
||||
historyDao.getRecentTypicalCycles(limit)
|
||||
|
||||
suspend fun addCycleToHistory(cycle: CycleHistoryEntity): Long {
|
||||
val id = historyDao.insert(cycle)
|
||||
recalculateForecasts()
|
||||
return id
|
||||
}
|
||||
|
||||
suspend fun updateCycleInHistory(cycle: CycleHistoryEntity) {
|
||||
historyDao.update(cycle)
|
||||
recalculateForecasts()
|
||||
}
|
||||
|
||||
suspend fun deleteCycleFromHistory(cycle: CycleHistoryEntity) {
|
||||
historyDao.delete(cycle)
|
||||
recalculateForecasts()
|
||||
}
|
||||
|
||||
suspend fun markCycleAsAtypical(id: Long, atypical: Boolean) {
|
||||
historyDao.getById(id)?.let { cycle ->
|
||||
historyDao.update(cycle.copy(atypical = atypical))
|
||||
recalculateForecasts()
|
||||
}
|
||||
}
|
||||
|
||||
// Прогнозы
|
||||
fun getForecastFlow(): Flow<CycleForecastEntity?> = forecastDao.getForecastFlow()
|
||||
|
||||
suspend fun getForecast(): CycleForecastEntity? = forecastDao.getForecast()
|
||||
|
||||
/**
|
||||
* Пересчитывает прогнозы на основе текущих настроек и истории циклов.
|
||||
* Вызывается автоматически при изменении настроек или истории.
|
||||
*/
|
||||
suspend fun recalculateForecasts() {
|
||||
val settings = settingsDao.getSettings() ?: return
|
||||
val history = if (settings.excludeOutliers) {
|
||||
historyDao.getRecentTypicalCycles(settings.historyWindowCycles)
|
||||
} else {
|
||||
historyDao.getRecentCycles(settings.historyWindowCycles)
|
||||
}
|
||||
|
||||
val forecast = calculateForecast(settings, history)
|
||||
forecastDao.insertOrUpdate(forecast)
|
||||
|
||||
// Здесь также можно вызвать планирование уведомлений на основе новых прогнозов
|
||||
scheduleNotifications(forecast)
|
||||
}
|
||||
|
||||
/**
|
||||
* Расчет прогноза цикла на основе настроек и истории.
|
||||
*/
|
||||
private fun calculateForecast(
|
||||
settings: CycleSettingsEntity,
|
||||
history: List<CycleHistoryEntity>
|
||||
): CycleForecastEntity {
|
||||
// Определяем надежность прогноза
|
||||
val isReliable = !(settings.isPregnant || settings.isPostpartum ||
|
||||
settings.isLactating || settings.perimenopause ||
|
||||
settings.hormonalContraception != "none")
|
||||
|
||||
// Если нет истории и нет даты последней менструации, не можем сделать прогноз
|
||||
if (history.isEmpty() && settings.lastPeriodStart == null) {
|
||||
return CycleForecastEntity(
|
||||
isReliable = isReliable,
|
||||
updatedAt = Instant.now()
|
||||
)
|
||||
}
|
||||
|
||||
// Находим дату последней менструации
|
||||
val lastPeriodStart = settings.lastPeriodStart ?: history.firstOrNull()?.periodStart
|
||||
|
||||
if (lastPeriodStart == null) {
|
||||
return CycleForecastEntity(
|
||||
isReliable = isReliable,
|
||||
updatedAt = Instant.now()
|
||||
)
|
||||
}
|
||||
|
||||
// Рассчитываем средний цикл на основе истории или используем базовые настройки
|
||||
val cycleLength = if (history.size >= 2) {
|
||||
calculateAverageCycleLength(history)
|
||||
} else {
|
||||
settings.baselineCycleLength
|
||||
}
|
||||
|
||||
// Рассчитываем л<><D0BB>теиновую фазу
|
||||
val lutealPhase = if (settings.lutealPhaseDays == "auto") {
|
||||
14 // Стандартная длина лютеиновой фазы
|
||||
} else {
|
||||
try {
|
||||
settings.lutealPhaseDays.toInt()
|
||||
} catch (e: NumberFormatException) {
|
||||
14
|
||||
}
|
||||
}
|
||||
|
||||
// Рассчитываем даты
|
||||
val today = LocalDate.now()
|
||||
val daysSinceLastPeriod = today.toEpochDay() - lastPeriodStart.toEpochDay()
|
||||
val nextPeriodStart = if (daysSinceLastPeriod >= cycleLength) {
|
||||
lastPeriodStart.plusDays(cycleLength.toLong() * (daysSinceLastPeriod / cycleLength + 1))
|
||||
} else {
|
||||
lastPeriodStart.plusDays(cycleLength.toLong())
|
||||
}
|
||||
|
||||
val ovulationDate = nextPeriodStart.minusDays(lutealPhase.toLong())
|
||||
|
||||
// Рассчитываем фертильное окно в зависимости от режима
|
||||
val fertileWindowStart = when (settings.fertileWindowMode) {
|
||||
"conservative" -> ovulationDate.minusDays(3)
|
||||
"balanced" -> ovulationDate.minusDays(5)
|
||||
"broad" -> ovulationDate.minusDays(7)
|
||||
else -> ovulationDate.minusDays(5) // По умолчанию сбалансированный режим
|
||||
}
|
||||
|
||||
val fertileWindowEnd = ovulationDate // День овуляции - последний фертильный день
|
||||
|
||||
// Рассчитываем начало ПМС
|
||||
val pmsStart = nextPeriodStart.minusDays(settings.pmsWindowDays.toLong())
|
||||
|
||||
return CycleForecastEntity(
|
||||
nextPeriodStart = nextPeriodStart,
|
||||
nextOvulation = ovulationDate,
|
||||
fertileStart = fertileWindowStart,
|
||||
fertileEnd = fertileWindowEnd,
|
||||
pmsStart = pmsStart,
|
||||
isReliable = isReliable,
|
||||
updatedAt = Instant.now()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Рассчитывает среднюю длину цикла на основе истории.
|
||||
*/
|
||||
private fun calculateAverageCycleLength(history: List<CycleHistoryEntity>): Int {
|
||||
if (history.size < 2) return 28 // Стандартный цикл если недостаточно данных
|
||||
|
||||
// Сортируем по дате начала
|
||||
val sortedHistory = history.sortedBy { it.periodStart }
|
||||
|
||||
// Рассчитываем <20><>лину между началом каждого цикла
|
||||
val cycleLengths = mutableListOf<Int>()
|
||||
for (i in 0 until sortedHistory.size - 1) {
|
||||
val current = sortedHistory[i]
|
||||
val next = sortedHistory[i + 1]
|
||||
val length = (next.periodStart.toEpochDay() - current.periodStart.toEpochDay()).toInt()
|
||||
|
||||
// Исключаем выбросы (слишком короткие или длинные циклы)
|
||||
if (length >= 18 && length <= 60) {
|
||||
cycleLengths.add(length)
|
||||
}
|
||||
}
|
||||
|
||||
// Если после фильтрации нет данных, возвращаем стандартный цикл
|
||||
if (cycleLengths.isEmpty()) return 28
|
||||
|
||||
// Возвращаем среднюю длину цикла, округленную до целого
|
||||
return cycleLengths.average().toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Планирует уведомления на основе рассчитанных прогнозов.
|
||||
*/
|
||||
private suspend fun scheduleNotifications(forecast: CycleForecastEntity) {
|
||||
// Это заглушка для метода планирования уведомлений
|
||||
// Реальная реализация будет добавлена позже в классе NotificationManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Экспортирует настройки цикла в JSON строку.
|
||||
*/
|
||||
suspend fun exportSettingsToJson(): String {
|
||||
// В реальной реализации здесь будет использоваться библиотека для сериализации в JSON
|
||||
// Например, Gson или Moshi
|
||||
return "{}" // Заглушка
|
||||
}
|
||||
|
||||
/**
|
||||
* Импортирует настройки цикла из JSON строки.
|
||||
*/
|
||||
suspend fun importSettingsFromJson(json: String): Boolean {
|
||||
// В реальной реализации здесь будет использоваться библиотека для десериализации из JSON
|
||||
return true // Заглушка
|
||||
}
|
||||
|
||||
// Методы для работы с периодами (CyclePeriodEntity)
|
||||
suspend fun getAllPeriods(): List<CyclePeriodEntity> {
|
||||
// Получаем все периоды из истории и преобразуем их в CyclePeriodEntity
|
||||
val history = historyDao.getAll()
|
||||
return history.map { historyEntity ->
|
||||
CyclePeriodEntity(
|
||||
id = historyEntity.id,
|
||||
startDate = historyEntity.periodStart,
|
||||
endDate = historyEntity.periodEnd,
|
||||
flow = historyEntity.flow,
|
||||
symptoms = historyEntity.symptoms,
|
||||
mood = historyEntity.mood,
|
||||
cycleLength = historyEntity.cycleLength
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun insertPeriod(period: CyclePeriodEntity): Long {
|
||||
// Преобразуем CyclePeriodEntity в CycleHistoryEntity для сохранения
|
||||
val historyEntity = CycleHistoryEntity(
|
||||
id = period.id,
|
||||
periodStart = period.startDate,
|
||||
periodEnd = period.endDate,
|
||||
flow = period.flow,
|
||||
symptoms = period.symptoms,
|
||||
mood = period.mood,
|
||||
cycleLength = period.cycleLength,
|
||||
atypical = false // по умолчанию не отмечаем как нетипичный
|
||||
)
|
||||
return addCycleToHistory(historyEntity)
|
||||
}
|
||||
|
||||
suspend fun updatePeriod(period: CyclePeriodEntity) {
|
||||
// Преобразуем CyclePeriodEntity в CycleHistoryEntity для обновления
|
||||
val historyEntity = CycleHistoryEntity(
|
||||
id = period.id,
|
||||
periodStart = period.startDate,
|
||||
periodEnd = period.endDate,
|
||||
flow = period.flow,
|
||||
symptoms = period.symptoms,
|
||||
mood = period.mood,
|
||||
cycleLength = period.cycleLength,
|
||||
atypical = false // сохраняем существующее значение, если возможно
|
||||
)
|
||||
updateCycleInHistory(historyEntity)
|
||||
}
|
||||
|
||||
suspend fun deletePeriod(period: CyclePeriodEntity) {
|
||||
val historyEntity = CycleHistoryEntity(
|
||||
id = period.id,
|
||||
periodStart = period.startDate,
|
||||
periodEnd = period.endDate,
|
||||
flow = period.flow,
|
||||
symptoms = period.symptoms,
|
||||
mood = period.mood,
|
||||
cycleLength = period.cycleLength,
|
||||
atypical = false
|
||||
)
|
||||
deleteCycleFromHistory(historyEntity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package kr.smartsoltech.wellshe.data.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kr.smartsoltech.wellshe.data.network.EmergencyService
|
||||
import kr.smartsoltech.wellshe.model.emergency.*
|
||||
import kr.smartsoltech.wellshe.util.Result
|
||||
|
||||
/**
|
||||
* Репозиторий для работы с экстренными оповещениями
|
||||
*/
|
||||
class EmergencyRepository(private val emergencyService: EmergencyService) {
|
||||
|
||||
/**
|
||||
* Создание нового экстренного оповещения
|
||||
*/
|
||||
suspend fun createAlert(
|
||||
token: String,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
message: String? = null,
|
||||
batteryLevel: Int? = null,
|
||||
contactIds: List<String>? = null
|
||||
): Result<EmergencyAlertResponse> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bearerToken = "Bearer $token"
|
||||
val locationData = LocationData(latitude, longitude)
|
||||
val request = EmergencyAlertRequest(locationData, message, batteryLevel, contactIds)
|
||||
|
||||
val response = emergencyService.createAlert(bearerToken, request)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.Success(response.body()!!.data)
|
||||
} else {
|
||||
Result.Error(Exception("Ошибка создания оповещения: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение статуса экстренного оповещения
|
||||
*/
|
||||
suspend fun getAlertStatus(token: String, alertId: String): Result<EmergencyAlertStatus> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bearerToken = "Bearer $token"
|
||||
val response = emergencyService.getAlertStatus(bearerToken, alertId)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.Success(response.body()!!.data)
|
||||
} else {
|
||||
Result.Error(Exception("Ошибка получения статуса оповещения: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление местоположения в активном оповещении
|
||||
*/
|
||||
suspend fun updateLocation(
|
||||
token: String,
|
||||
alertId: String,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracy: Float? = null,
|
||||
batteryLevel: Int? = null
|
||||
): Result<LocationUpdateResponse> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bearerToken = "Bearer $token"
|
||||
val request = LocationUpdateRequest(latitude, longitude, accuracy, batteryLevel)
|
||||
|
||||
val response = emergencyService.updateLocation(bearerToken, alertId, request)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.Success(response.body()!!.data)
|
||||
} else {
|
||||
Result.Error(Exception("Ошибка обновления местоположения: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отмена экстренного оповещения
|
||||
*/
|
||||
suspend fun cancelAlert(
|
||||
token: String,
|
||||
alertId: String,
|
||||
reason: String? = null,
|
||||
details: String? = null
|
||||
): Result<AlertCancelResponse> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bearerToken = "Bearer $token"
|
||||
val request = AlertCancelRequest(reason, details)
|
||||
|
||||
val response = emergencyService.cancelAlert(bearerToken, alertId, request)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.Success(response.body()!!.data)
|
||||
} else {
|
||||
Result.Error(Exception("Ошибка отмены оповещения: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package kr.smartsoltech.wellshe.data.repository
|
||||
|
||||
import kr.smartsoltech.wellshe.data.dao.HealthRecordDao
|
||||
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class HealthRepository @Inject constructor(private val dao: HealthRecordDao) {
|
||||
suspend fun getAllRecords(): List<HealthRecordEntity> = dao.getAll()
|
||||
suspend fun insertRecord(record: HealthRecordEntity): Long = dao.insert(record)
|
||||
suspend fun updateRecord(record: HealthRecordEntity) = dao.update(record)
|
||||
suspend fun deleteRecord(record: HealthRecordEntity) = dao.delete(record)
|
||||
suspend fun getRecordByDate(date: java.time.LocalDate): HealthRecordEntity? = dao.getByDate(date)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package kr.smartsoltech.wellshe.data.repository
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package kr.smartsoltech.wellshe.data.storage
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Класс для управления токенами авторизации
|
||||
*/
|
||||
@Singleton
|
||||
class TokenManager @Inject constructor() {
|
||||
|
||||
// Токен авторизации
|
||||
private var accessToken: String? = null
|
||||
|
||||
// Токен обновления
|
||||
private var refreshToken: String? = null
|
||||
|
||||
// Время истечения токена
|
||||
private var expiresAt: Long = 0
|
||||
|
||||
// Тип токена (например, "Bearer")
|
||||
private var tokenType: String? = null
|
||||
|
||||
/**
|
||||
* Сохранить токены авторизации
|
||||
*/
|
||||
fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Int) {
|
||||
this.accessToken = accessToken
|
||||
this.refreshToken = refreshToken
|
||||
this.expiresAt = Date().time + (expiresIn * 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить только токен доступа
|
||||
*/
|
||||
fun updateAccessToken(accessToken: String, expiresIn: Int) {
|
||||
this.accessToken = accessToken
|
||||
this.expiresAt = Date().time + (expiresIn * 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить токен доступа
|
||||
*/
|
||||
fun saveAccessToken(accessToken: String) {
|
||||
this.accessToken = accessToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить тип токена
|
||||
*/
|
||||
fun saveTokenType(tokenType: String) {
|
||||
this.tokenType = tokenType
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить тип токена
|
||||
*/
|
||||
fun getTokenType(): String? {
|
||||
return tokenType
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить все токены
|
||||
*/
|
||||
fun clearTokens() {
|
||||
accessToken = null
|
||||
refreshToken = null
|
||||
tokenType = null
|
||||
expiresAt = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить токен авторизации
|
||||
*/
|
||||
fun getAccessToken(): String? {
|
||||
return accessToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить токен обновления
|
||||
*/
|
||||
fun getRefreshToken(): String? {
|
||||
return refreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, истек ли токен авторизации
|
||||
*/
|
||||
fun isAccessTokenExpired(): Boolean {
|
||||
return Date().time > expiresAt
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить токен авторизации (для обратной совместимости)
|
||||
*/
|
||||
fun saveAuthToken(token: String) {
|
||||
accessToken = token
|
||||
expiresAt = Date().time + (3600 * 1000) // По умолчанию 1 час
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,19 @@ 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 kr.smartsoltech.wellshe.data.MIGRATION_2_3
|
||||
import kr.smartsoltech.wellshe.data.MIGRATION_3_4
|
||||
import kr.smartsoltech.wellshe.data.MIGRATION_4_5
|
||||
import kr.smartsoltech.wellshe.data.MIGRATION_5_6
|
||||
import kr.smartsoltech.wellshe.data.MIGRATION_6_7
|
||||
import kr.smartsoltech.wellshe.data.MIGRATION_7_8
|
||||
import kr.smartsoltech.wellshe.data.MIGRATION_8_9
|
||||
import kr.smartsoltech.wellshe.data.MIGRATION_9_10
|
||||
import kr.smartsoltech.wellshe.data.MIGRATION_10_11
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@@ -28,7 +41,10 @@ object AppModule {
|
||||
context,
|
||||
AppDatabase::class.java,
|
||||
"well_she_db"
|
||||
).fallbackToDestructiveMigration().build()
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
|
||||
// DAO providers
|
||||
@Provides
|
||||
@@ -55,6 +71,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
|
||||
|
||||
77
app/src/main/java/kr/smartsoltech/wellshe/di/AuthModule.kt
Normal file
77
app/src/main/java/kr/smartsoltech/wellshe/di/AuthModule.kt
Normal file
@@ -0,0 +1,77 @@
|
||||
package kr.smartsoltech.wellshe.di
|
||||
|
||||
import android.content.Context
|
||||
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.local.AuthTokenRepository
|
||||
import kr.smartsoltech.wellshe.data.network.AuthService
|
||||
import kr.smartsoltech.wellshe.data.repository.AuthRepository
|
||||
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||
import kr.smartsoltech.wellshe.domain.auth.GetUserProfileUseCase
|
||||
import kr.smartsoltech.wellshe.domain.auth.LoginUseCase
|
||||
import kr.smartsoltech.wellshe.domain.auth.LogoutUseCase
|
||||
import kr.smartsoltech.wellshe.domain.auth.RegisterUseCase
|
||||
import kr.smartsoltech.wellshe.domain.auth.RefreshTokenUseCase
|
||||
import retrofit2.Retrofit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AuthModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthTokenRepository(@ApplicationContext context: Context): AuthTokenRepository {
|
||||
return AuthTokenRepository(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTokenManager(): TokenManager {
|
||||
return TokenManager()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthService(retrofit: Retrofit): AuthService {
|
||||
return retrofit.create(AuthService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthRepository(
|
||||
authService: AuthService,
|
||||
authTokenRepository: AuthTokenRepository,
|
||||
tokenManager: TokenManager
|
||||
): AuthRepository {
|
||||
return AuthRepository(authService, authTokenRepository, tokenManager)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideLoginUseCase(authRepository: AuthRepository, tokenManager: TokenManager): LoginUseCase {
|
||||
return LoginUseCase(authRepository, tokenManager)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideRegisterUseCase(authRepository: AuthRepository): RegisterUseCase {
|
||||
return RegisterUseCase(authRepository)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideLogoutUseCase(authRepository: AuthRepository, tokenManager: TokenManager): LogoutUseCase {
|
||||
return LogoutUseCase(authRepository, tokenManager)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideGetUserProfileUseCase(authRepository: AuthRepository, tokenManager: TokenManager): GetUserProfileUseCase {
|
||||
return GetUserProfileUseCase(authRepository, tokenManager)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideRefreshTokenUseCase(authRepository: AuthRepository, tokenManager: TokenManager): RefreshTokenUseCase {
|
||||
return RefreshTokenUseCase(authRepository, tokenManager)
|
||||
}
|
||||
}
|
||||
59
app/src/main/java/kr/smartsoltech/wellshe/di/CycleModule.kt
Normal file
59
app/src/main/java/kr/smartsoltech/wellshe/di/CycleModule.kt
Normal file
@@ -0,0 +1,59 @@
|
||||
package kr.smartsoltech.wellshe.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.WorkManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kr.smartsoltech.wellshe.data.dao.CycleForecastDao
|
||||
import kr.smartsoltech.wellshe.data.dao.CycleHistoryDao
|
||||
import kr.smartsoltech.wellshe.data.dao.CycleSettingsDao
|
||||
import kr.smartsoltech.wellshe.data.repository.CycleRepository
|
||||
import kr.smartsoltech.wellshe.domain.services.CycleSettingsExportService
|
||||
import kr.smartsoltech.wellshe.workers.CycleNotificationManager
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object CycleModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCycleRepository(
|
||||
settingsDao: CycleSettingsDao,
|
||||
historyDao: CycleHistoryDao,
|
||||
forecastDao: CycleForecastDao
|
||||
): CycleRepository = CycleRepository(settingsDao, historyDao, forecastDao)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCycleSettingsExportService(): CycleSettingsExportService =
|
||||
CycleSettingsExportService()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWorkManager(@ApplicationContext context: Context): WorkManager =
|
||||
WorkManager.getInstance(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCycleNotificationManager(
|
||||
@ApplicationContext context: Context,
|
||||
workManager: WorkManager
|
||||
): CycleNotificationManager = CycleNotificationManager(context, workManager)
|
||||
|
||||
// DAO providers
|
||||
@Provides
|
||||
fun provideCycleSettingsDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleSettingsDao =
|
||||
database.cycleSettingsDao()
|
||||
|
||||
@Provides
|
||||
fun provideCycleHistoryDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleHistoryDao =
|
||||
database.cycleHistoryDao()
|
||||
|
||||
@Provides
|
||||
fun provideCycleForecastDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleForecastDao =
|
||||
database.cycleForecastDao()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package kr.smartsoltech.wellshe.di
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||
import kr.smartsoltech.wellshe.data.network.AuthInterceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
private const val BASE_URL = "http://192.168.0.112:8000/api/v1/"
|
||||
private const val CONNECT_TIMEOUT = 15L
|
||||
private const val READ_TIMEOUT = 15L
|
||||
private const val WRITE_TIMEOUT = 15L
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGson(): Gson {
|
||||
return GsonBuilder()
|
||||
.setLenient()
|
||||
.create()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthInterceptor(authTokenRepository: AuthTokenRepository): AuthInterceptor {
|
||||
return AuthInterceptor(authTokenRepository)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
|
||||
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
|
||||
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
|
||||
.writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient, gson: Gson): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package kr.smartsoltech.wellshe.di
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Фабрика для создания ViewModel с внедрением зависимостей через Hilt
|
||||
*/
|
||||
class ViewModelFactory @Inject constructor(
|
||||
private val creators: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
val creator = creators[modelClass] ?: creators.entries.firstOrNull {
|
||||
modelClass.isAssignableFrom(it.key)
|
||||
}?.value ?: throw IllegalArgumentException("unknown model class $modelClass")
|
||||
|
||||
return try {
|
||||
creator.get() as T
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package kr.smartsoltech.wellshe.domain.auth
|
||||
|
||||
import kr.smartsoltech.wellshe.data.repository.AuthRepository
|
||||
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||
import kr.smartsoltech.wellshe.model.auth.UserProfile
|
||||
import kr.smartsoltech.wellshe.util.Result
|
||||
|
||||
/**
|
||||
* Use case для регистрации нового пользователя
|
||||
*/
|
||||
class RegisterUseCase(private val authRepository: AuthRepository) {
|
||||
suspend operator fun invoke(
|
||||
email: String,
|
||||
username: String,
|
||||
password: String,
|
||||
firstName: String,
|
||||
lastName: String,
|
||||
phone: String
|
||||
): Result<Boolean> {
|
||||
val result = authRepository.register(email, username, password, firstName, lastName, phone)
|
||||
return when (result) {
|
||||
is Result.Success -> Result.Success(true)
|
||||
is Result.Error -> Result.Error(result.exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use case для авторизации пользователя
|
||||
*/
|
||||
class LoginUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
|
||||
suspend operator fun invoke(identifier: String, password: String, isEmail: Boolean): Result<Boolean> {
|
||||
val result = authRepository.login(identifier, password, isEmail)
|
||||
return when (result) {
|
||||
is Result.Success -> {
|
||||
val response = result.data
|
||||
tokenManager.saveTokens(response.accessToken, response.refreshToken, response.expiresIn)
|
||||
Result.Success(true)
|
||||
}
|
||||
is Result.Error -> Result.Error(result.exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use case для выхода пользователя из системы
|
||||
*/
|
||||
class LogoutUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
|
||||
suspend operator fun invoke(): Result<Boolean> {
|
||||
val accessToken = tokenManager.getAccessToken()
|
||||
if (accessToken == null) {
|
||||
tokenManager.clearTokens()
|
||||
return Result.Success(true)
|
||||
}
|
||||
|
||||
val result = authRepository.logout(accessToken)
|
||||
tokenManager.clearTokens() // Очищаем токены даже при ошибке запроса
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use case для получения профиля пользователя
|
||||
*/
|
||||
class GetUserProfileUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
|
||||
suspend operator fun invoke(): Result<UserProfile> {
|
||||
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||
|
||||
return if (tokenManager.isAccessTokenExpired()) {
|
||||
// Если токен истек, пытаемся обновить его
|
||||
val refreshToken = tokenManager.getRefreshToken() ?: return Result.Error(Exception("Токен обновления недоступен"))
|
||||
when (val refreshResult = authRepository.refreshToken(refreshToken)) {
|
||||
is Result.Success -> {
|
||||
tokenManager.updateAccessToken(refreshResult.data.accessToken, refreshResult.data.expiresIn)
|
||||
// Получаем профиль с обновленным токеном
|
||||
authRepository.getUserProfile(refreshResult.data.accessToken)
|
||||
}
|
||||
is Result.Error -> Result.Error(refreshResult.exception)
|
||||
}
|
||||
} else {
|
||||
// Получаем профиль с текущим токеном
|
||||
authRepository.getUserProfile(accessToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use case для обновления токена доступа
|
||||
*/
|
||||
class RefreshTokenUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
|
||||
suspend operator fun invoke(): Result<Boolean> {
|
||||
val refreshToken = tokenManager.getRefreshToken() ?: return Result.Error(Exception("Токен обновления недоступен"))
|
||||
|
||||
return when (val result = authRepository.refreshToken(refreshToken)) {
|
||||
is Result.Success -> {
|
||||
tokenManager.updateAccessToken(result.data.accessToken, result.data.expiresIn)
|
||||
Result.Success(true)
|
||||
}
|
||||
is Result.Error -> {
|
||||
// Если ошибка обновления, то считаем, что пользователь не авторизован
|
||||
tokenManager.clearTokens()
|
||||
Result.Error(result.exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package kr.smartsoltech.wellshe.domain.emergency
|
||||
|
||||
import kr.smartsoltech.wellshe.data.repository.EmergencyRepository
|
||||
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||
import kr.smartsoltech.wellshe.model.emergency.*
|
||||
import kr.smartsoltech.wellshe.util.Result
|
||||
|
||||
/**
|
||||
* Use case для создания экстренного оповещения
|
||||
*/
|
||||
class CreateEmergencyAlertUseCase(
|
||||
private val emergencyRepository: EmergencyRepository,
|
||||
private val tokenManager: TokenManager
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
message: String? = null,
|
||||
batteryLevel: Int? = null,
|
||||
contactIds: List<String>? = null
|
||||
): Result<EmergencyAlertResponse> {
|
||||
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||
|
||||
return emergencyRepository.createAlert(
|
||||
accessToken, latitude, longitude, message, batteryLevel, contactIds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use case для получения статуса экстренного оповещения
|
||||
*/
|
||||
class GetAlertStatusUseCase(
|
||||
private val emergencyRepository: EmergencyRepository,
|
||||
private val tokenManager: TokenManager
|
||||
) {
|
||||
suspend operator fun invoke(alertId: String): Result<EmergencyAlertStatus> {
|
||||
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||
|
||||
return emergencyRepository.getAlertStatus(accessToken, alertId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use case для обновления местоположения при активном оповещении
|
||||
*/
|
||||
class UpdateLocationUseCase(
|
||||
private val emergencyRepository: EmergencyRepository,
|
||||
private val tokenManager: TokenManager
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
alertId: String,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracy: Float? = null,
|
||||
batteryLevel: Int? = null
|
||||
): Result<LocationUpdateResponse> {
|
||||
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||
|
||||
return emergencyRepository.updateLocation(
|
||||
accessToken, alertId, latitude, longitude, accuracy, batteryLevel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use case для отмены экстренного оповещения
|
||||
*/
|
||||
class CancelAlertUseCase(
|
||||
private val emergencyRepository: EmergencyRepository,
|
||||
private val tokenManager: TokenManager
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
alertId: String,
|
||||
reason: String? = null,
|
||||
details: String? = null
|
||||
): Result<AlertCancelResponse> {
|
||||
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||
|
||||
return emergencyRepository.cancelAlert(accessToken, alertId, reason, details)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package kr.smartsoltech.wellshe.domain.models
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Доменная модель для истории циклов.
|
||||
*/
|
||||
data class CycleHistory(
|
||||
val id: Long = 0,
|
||||
val periodStart: LocalDate,
|
||||
val periodEnd: LocalDate? = null,
|
||||
val ovulationDate: LocalDate? = null,
|
||||
val notes: String = "",
|
||||
val atypical: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Доменная модель для прогнозов цикла.
|
||||
*/
|
||||
data class CycleForecast(
|
||||
val nextPeriodStart: LocalDate? = null,
|
||||
val nextOvulation: LocalDate? = null,
|
||||
val fertileStart: LocalDate? = null,
|
||||
val fertileEnd: LocalDate? = null,
|
||||
val pmsStart: LocalDate? = null,
|
||||
val isReliable: Boolean = true, // Флаг для пониженной точности
|
||||
val currentCyclePhase: CyclePhase = CyclePhase.UNKNOWN
|
||||
)
|
||||
|
||||
/**
|
||||
* Фаза менструального цикла.
|
||||
*/
|
||||
enum class CyclePhase {
|
||||
MENSTRUATION, // Менструация
|
||||
FOLLICULAR, // Фолликулярная фаза (после менструации до фертильного окна)
|
||||
FERTILE, // Фертильное окно
|
||||
OVULATION, // День овуляции
|
||||
LUTEAL, // Лютеиновая фаза (после овуляции)
|
||||
PMS, // ПМС (последние дни перед менструацией)
|
||||
UNKNOWN; // Неизвестная фаза (например, при недостатке данных)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Определяет текущую фазу цикла на основе прогноза и текущей даты.
|
||||
*/
|
||||
fun determinePhase(
|
||||
today: LocalDate = LocalDate.now(),
|
||||
nextPeriodStart: LocalDate?,
|
||||
lastPeriodStart: LocalDate?,
|
||||
fertileStart: LocalDate?,
|
||||
fertileEnd: LocalDate?,
|
||||
ovulationDate: LocalDate?,
|
||||
pmsStart: LocalDate?,
|
||||
periodLengthDays: Int = 5
|
||||
): CyclePhase {
|
||||
if (lastPeriodStart == null || nextPeriodStart == null) return UNKNOWN
|
||||
|
||||
// Определяем конец последней менструации
|
||||
val lastPeriodEnd = lastPeriodStart.plusDays(periodLengthDays.toLong() - 1)
|
||||
|
||||
return when {
|
||||
// Период менструации
|
||||
(today.isEqual(lastPeriodStart) || today.isAfter(lastPeriodStart)) &&
|
||||
(today.isEqual(lastPeriodEnd) || today.isBefore(lastPeriodEnd)) -> MENSTRUATION
|
||||
|
||||
// День овуляции
|
||||
ovulationDate != null && today.isEqual(ovulationDate) -> OVULATION
|
||||
|
||||
// Фертильное окно
|
||||
fertileStart != null && fertileEnd != null &&
|
||||
(today.isEqual(fertileStart) || today.isAfter(fertileStart)) &&
|
||||
(today.isEqual(fertileEnd) || today.isBefore(fertileEnd)) &&
|
||||
(ovulationDate == null || !today.isEqual(ovulationDate)) -> FERTILE
|
||||
|
||||
// ПМС
|
||||
pmsStart != null &&
|
||||
(today.isEqual(pmsStart) || today.isAfter(pmsStart)) &&
|
||||
today.isBefore(nextPeriodStart) -> PMS
|
||||
|
||||
// Лютеиновая фаза (после овуляции/фертильного окна до ПМС)
|
||||
ovulationDate != null && fertileEnd != null && pmsStart != null &&
|
||||
today.isAfter(fertileEnd) &&
|
||||
today.isBefore(pmsStart) -> LUTEAL
|
||||
|
||||
// Фолликулярная фаза (после менструации до фертильного окна)
|
||||
lastPeriodEnd != null && fertileStart != null &&
|
||||
today.isAfter(lastPeriodEnd) &&
|
||||
today.isBefore(fertileStart) -> FOLLICULAR
|
||||
|
||||
// Если не удалось определить фазу
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package kr.smartsoltech.wellshe.domain.models
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Доменная модель для настроек цикла.
|
||||
*/
|
||||
data class CycleSettings(
|
||||
// Основные параметры цикла
|
||||
val baselineCycleLength: Int = 28,
|
||||
val cycleVariabilityDays: Int = 3,
|
||||
val periodLengthDays: Int = 5,
|
||||
val lutealPhaseDays: String = "auto", // "auto" или число (8-17)
|
||||
val lastPeriodStart: LocalDate? = null,
|
||||
|
||||
// Метод определения овуляции
|
||||
val ovulationMethod: OvulationMethod = OvulationMethod.AUTO,
|
||||
val allowManualOvulation: Boolean = false,
|
||||
|
||||
// Статусы влияющие на точность
|
||||
val hormonalContraception: HormonalContraceptionType = HormonalContraceptionType.NONE,
|
||||
val isPregnant: Boolean = false,
|
||||
val isPostpartum: Boolean = false,
|
||||
val isLactating: Boolean = false,
|
||||
val perimenopause: Boolean = false,
|
||||
|
||||
// Настройки истории и исключения выбросов
|
||||
val historyWindowCycles: Int = 6,
|
||||
val excludeOutliers: Boolean = true,
|
||||
|
||||
// Сенсоры и единицы измерения
|
||||
val tempUnit: TemperatureUnit = TemperatureUnit.CELSIUS,
|
||||
val bbtTimeWindow: String = "06:00-10:00",
|
||||
val timezone: String = "Asia/Seoul",
|
||||
|
||||
// Уведомления
|
||||
val periodReminderDaysBefore: Int = 2,
|
||||
val ovulationReminderDaysBefore: Int = 1,
|
||||
val pmsWindowDays: Int = 3,
|
||||
val deviationAlertDays: Int = 5,
|
||||
val fertileWindowMode: FertileWindowMode = FertileWindowMode.BALANCED
|
||||
)
|
||||
|
||||
/**
|
||||
* Метод определения овуляции
|
||||
*/
|
||||
enum class OvulationMethod {
|
||||
AUTO, BBT, LH_TEST, CERVICAL_MUCUS, MEDICAL;
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): OvulationMethod = when (value.lowercase()) {
|
||||
"bbt" -> BBT
|
||||
"lh_test" -> LH_TEST
|
||||
"cervical_mucus" -> CERVICAL_MUCUS
|
||||
"medical" -> MEDICAL
|
||||
else -> AUTO
|
||||
}
|
||||
}
|
||||
|
||||
fun toStorageString(): String = this.name.lowercase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Тип гормональной контрацепции
|
||||
*/
|
||||
enum class HormonalContraceptionType {
|
||||
NONE, COC, IUD, IMPLANT, OTHER;
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): HormonalContraceptionType = when (value.lowercase()) {
|
||||
"coc" -> COC
|
||||
"iud" -> IUD
|
||||
"implant" -> IMPLANT
|
||||
"other" -> OTHER
|
||||
else -> NONE
|
||||
}
|
||||
}
|
||||
|
||||
fun toStorageString(): String = this.name.lowercase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Единицы измерения температуры
|
||||
*/
|
||||
enum class TemperatureUnit {
|
||||
CELSIUS, FAHRENHEIT;
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): TemperatureUnit = when (value.uppercase()) {
|
||||
"F" -> FAHRENHEIT
|
||||
else -> CELSIUS
|
||||
}
|
||||
}
|
||||
|
||||
fun toStorageString(): String = when (this) {
|
||||
CELSIUS -> "C"
|
||||
FAHRENHEIT -> "F"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Режим определения фертильного окна
|
||||
*/
|
||||
enum class FertileWindowMode {
|
||||
CONSERVATIVE, BALANCED, BROAD;
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): FertileWindowMode = when (value.lowercase()) {
|
||||
"conservative" -> CONSERVATIVE
|
||||
"broad" -> BROAD
|
||||
else -> BALANCED
|
||||
}
|
||||
}
|
||||
|
||||
fun toStorageString(): String = this.name.lowercase()
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package kr.smartsoltech.wellshe.domain.models
|
||||
|
||||
import kr.smartsoltech.wellshe.ui.cycle.settings.FertileWindowMode
|
||||
import kr.smartsoltech.wellshe.ui.cycle.settings.HormonalContraception
|
||||
import kr.smartsoltech.wellshe.ui.cycle.settings.OvulationMethod
|
||||
import java.time.LocalDate
|
||||
|
||||
// Типы событий изменения настроек для старого интерфейса
|
||||
sealed class BasicSettingChange {
|
||||
data class CycleLengthChanged(val days: Int) : BasicSettingChange()
|
||||
data class CycleVariabilityChanged(val days: Int) : BasicSettingChange()
|
||||
data class PeriodLengthChanged(val days: Int) : BasicSettingChange()
|
||||
data class LutealPhaseChanged(val days: String) : BasicSettingChange() // "auto" или число
|
||||
data class LastPeriodStartChanged(val date: LocalDate) : BasicSettingChange()
|
||||
}
|
||||
|
||||
sealed class StatusChange {
|
||||
data class HormonalContraceptionChanged(val type: HormonalContraception) : StatusChange()
|
||||
data class PregnancyStatusChanged(val isPregnant: Boolean) : StatusChange()
|
||||
data class PostpartumStatusChanged(val isPostpartum: Boolean) : StatusChange()
|
||||
data class LactatingStatusChanged(val isLactating: Boolean) : StatusChange()
|
||||
data class PerimenopauseStatusChanged(val perimenopause: Boolean) : StatusChange()
|
||||
|
||||
// Вложенные объекты для удобного создания событий
|
||||
object Pregnant {
|
||||
fun changed(value: Boolean) = PregnancyStatusChanged(value)
|
||||
}
|
||||
object Postpartum {
|
||||
fun changed(value: Boolean) = PostpartumStatusChanged(value)
|
||||
}
|
||||
object Lactating {
|
||||
fun changed(value: Boolean) = LactatingStatusChanged(value)
|
||||
}
|
||||
object Perimenopause {
|
||||
fun changed(value: Boolean) = PerimenopauseStatusChanged(value)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class HistorySetting {
|
||||
data class HistoryWindowChanged(val cycles: Int) : HistorySetting()
|
||||
data class ExcludeOutliersChanged(val exclude: Boolean) : HistorySetting()
|
||||
|
||||
// Вложенные объекты для более удобного обращения
|
||||
object BaselineCycleLength {
|
||||
fun changed(value: Int) = BasicSettingChange.CycleLengthChanged(value)
|
||||
}
|
||||
object CycleVariability {
|
||||
fun changed(value: Int) = BasicSettingChange.CycleVariabilityChanged(value)
|
||||
}
|
||||
object PeriodLength {
|
||||
fun changed(value: Int) = BasicSettingChange.PeriodLengthChanged(value)
|
||||
}
|
||||
object LutealPhase {
|
||||
fun changed(value: String) = BasicSettingChange.LutealPhaseChanged(value)
|
||||
}
|
||||
object LastPeriodStart {
|
||||
fun changed(value: LocalDate) = BasicSettingChange.LastPeriodStartChanged(value)
|
||||
}
|
||||
|
||||
// Вложенные классы для UI
|
||||
class WindowCycles(val cycles: Int) : HistorySetting()
|
||||
class ExcludeOutliers(val exclude: Boolean) : HistorySetting()
|
||||
}
|
||||
|
||||
sealed class SensorSetting {
|
||||
data class TemperatureUnitChanged(val unit: TemperatureUnit) : SensorSetting()
|
||||
data class BbtTimeWindowChanged(val timeWindow: String) : SensorSetting()
|
||||
data class TimezoneChanged(val timezone: String) : SensorSetting()
|
||||
|
||||
// Вложенные классы для UI
|
||||
class TempUnit(val unit: TemperatureUnit) : SensorSetting()
|
||||
class BbtTimeWindow(val timeWindow: String) : SensorSetting()
|
||||
class Timezone(val timezone: String) : SensorSetting()
|
||||
}
|
||||
|
||||
sealed class NotificationSetting {
|
||||
data class PeriodReminderDaysChanged(val days: Int) : NotificationSetting()
|
||||
data class OvulationReminderDaysChanged(val days: Int) : NotificationSetting()
|
||||
data class PmsWindowDaysChanged(val days: Int) : NotificationSetting()
|
||||
data class DeviationAlertDaysChanged(val days: Int) : NotificationSetting()
|
||||
data class FertileWindowModeChanged(val mode: FertileWindowMode) : NotificationSetting()
|
||||
|
||||
// Вложенные классы для UI
|
||||
class PeriodReminder(val days: Int) : NotificationSetting()
|
||||
class OvulationReminder(val days: Int) : NotificationSetting()
|
||||
class PmsWindow(val days: Int) : NotificationSetting()
|
||||
class DeviationAlert(val days: Int) : NotificationSetting()
|
||||
class FertileWindowMode(val mode: kr.smartsoltech.wellshe.domain.models.FertileWindowMode) : NotificationSetting()
|
||||
}
|
||||
|
||||
// Функции преобразования для всех типов
|
||||
fun ovulationMethodFromString(value: String): OvulationMethod {
|
||||
return when (value) {
|
||||
"bbt" -> OvulationMethod.BBT
|
||||
"lh_test" -> OvulationMethod.LH_TEST
|
||||
"cervical_mucus" -> OvulationMethod.CERVICAL_MUCUS
|
||||
"medical" -> OvulationMethod.MEDICAL
|
||||
else -> OvulationMethod.AUTO
|
||||
}
|
||||
}
|
||||
|
||||
fun fertileWindowModeFromString(value: String): FertileWindowMode {
|
||||
return when (value) {
|
||||
"conservative" -> FertileWindowMode.CONSERVATIVE
|
||||
"broad" -> FertileWindowMode.BROAD
|
||||
else -> FertileWindowMode.BALANCED
|
||||
}
|
||||
}
|
||||
|
||||
fun hormonalContraceptionTypeFromString(value: String): HormonalContraception {
|
||||
return when (value) {
|
||||
"coc" -> HormonalContraception.COC
|
||||
"iud" -> HormonalContraception.IUD
|
||||
"implant" -> HormonalContraception.IMPLANT
|
||||
"other" -> HormonalContraception.OTHER
|
||||
else -> HormonalContraception.NONE
|
||||
}
|
||||
}
|
||||
|
||||
fun temperatureUnitFromString(value: String): TemperatureUnit {
|
||||
return when (value.uppercase()) {
|
||||
"F" -> TemperatureUnit.FAHRENHEIT
|
||||
else -> TemperatureUnit.CELSIUS
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Этот файл больше не используется, все классы перенесены в CycleSettingsEvents.kt
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package kr.smartsoltech.wellshe.domain.services
|
||||
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
|
||||
import kr.smartsoltech.wellshe.domain.models.*
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Класс для импорта и экспорта настроек цикла в JSON формате
|
||||
*/
|
||||
@Singleton
|
||||
class CycleSettingsExportService @Inject constructor() {
|
||||
|
||||
// Создаем адаптер для LocalDate вне класса LocalDateAdapter
|
||||
private val localDateAdapter = object : JsonAdapter<LocalDate>() {
|
||||
override fun fromJson(reader: com.squareup.moshi.JsonReader): LocalDate? {
|
||||
return try {
|
||||
val dateString = reader.nextString()
|
||||
LocalDate.parse(dateString)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun toJson(writer: com.squareup.moshi.JsonWriter, value: LocalDate?) {
|
||||
if (value == null) {
|
||||
writer.nullValue()
|
||||
} else {
|
||||
writer.value(value.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Настройка Moshi для сериализации/десериализации
|
||||
private val moshi: Moshi = Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.add(Date::class.java, Rfc3339DateJsonAdapter())
|
||||
.add(LocalDate::class.java, localDateAdapter)
|
||||
.build()
|
||||
|
||||
// Адаптер для сериализации/десериализации настроек цикла
|
||||
private val settingsAdapter: JsonAdapter<CycleSettingsJsonDto> = moshi.adapter(CycleSettingsJsonDto::class.java)
|
||||
|
||||
/**
|
||||
* Экспортирует настройки в формат JSON
|
||||
*/
|
||||
fun exportSettingsToJson(settings: CycleSettingsEntity): String {
|
||||
val jsonDto = convertToJsonDto(settings)
|
||||
return settingsAdapter.toJson(jsonDto)
|
||||
}
|
||||
|
||||
/**
|
||||
* Импортирует настройки из JSON
|
||||
* @return Импортированные настройки или null в случае ошибки
|
||||
*/
|
||||
fun importSettingsFromJson(json: String): CycleSettingsEntity? {
|
||||
return try {
|
||||
val jsonDto = settingsAdapter.fromJson(json)
|
||||
jsonDto?.let { convertToEntity(it) }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует Entity в DTO для экспорта в JSON
|
||||
*/
|
||||
private fun convertToJsonDto(entity: CycleSettingsEntity): CycleSettingsJsonDto {
|
||||
return CycleSettingsJsonDto(
|
||||
baselineCycleLength = entity.baselineCycleLength,
|
||||
cycleVariabilityDays = entity.cycleVariabilityDays,
|
||||
periodLengthDays = entity.periodLengthDays,
|
||||
lutealPhaseDays = entity.lutealPhaseDays,
|
||||
lastPeriodStart = entity.lastPeriodStart,
|
||||
ovulationMethod = entity.ovulationMethod,
|
||||
allowManualOvulation = entity.allowManualOvulation,
|
||||
hormonalContraception = entity.hormonalContraception,
|
||||
isPregnant = entity.isPregnant,
|
||||
isPostpartum = entity.isPostpartum,
|
||||
isLactating = entity.isLactating,
|
||||
perimenopause = entity.perimenopause,
|
||||
historyWindowCycles = entity.historyWindowCycles,
|
||||
excludeOutliers = entity.excludeOutliers,
|
||||
tempUnit = entity.tempUnit,
|
||||
bbtTimeWindow = entity.bbtTimeWindow,
|
||||
timezone = entity.timezone,
|
||||
periodReminderDaysBefore = entity.periodReminderDaysBefore,
|
||||
ovulationReminderDaysBefore = entity.ovulationReminderDaysBefore,
|
||||
pmsWindowDays = entity.pmsWindowDays,
|
||||
deviationAlertDays = entity.deviationAlertDays,
|
||||
fertileWindowMode = entity.fertileWindowMode
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует DTO в Entity
|
||||
*/
|
||||
private fun convertToEntity(dto: CycleSettingsJsonDto): CycleSettingsEntity {
|
||||
return CycleSettingsEntity(
|
||||
id = 1, // Singleton ID
|
||||
baselineCycleLength = dto.baselineCycleLength.coerceIn(18, 60),
|
||||
cycleVariabilityDays = dto.cycleVariabilityDays.coerceIn(0, 10),
|
||||
periodLengthDays = dto.periodLengthDays.coerceIn(1, 10),
|
||||
lutealPhaseDays = dto.lutealPhaseDays, // Валидация будет в ViewModel
|
||||
lastPeriodStart = dto.lastPeriodStart,
|
||||
ovulationMethod = dto.ovulationMethod,
|
||||
allowManualOvulation = dto.allowManualOvulation,
|
||||
hormonalContraception = dto.hormonalContraception,
|
||||
isPregnant = dto.isPregnant,
|
||||
isPostpartum = dto.isPostpartum,
|
||||
isLactating = dto.isLactating,
|
||||
perimenopause = dto.perimenopause,
|
||||
historyWindowCycles = dto.historyWindowCycles,
|
||||
excludeOutliers = dto.excludeOutliers,
|
||||
tempUnit = dto.tempUnit,
|
||||
bbtTimeWindow = dto.bbtTimeWindow,
|
||||
timezone = dto.timezone,
|
||||
periodReminderDaysBefore = dto.periodReminderDaysBefore.coerceIn(0, 7),
|
||||
ovulationReminderDaysBefore = dto.ovulationReminderDaysBefore.coerceIn(0, 7),
|
||||
pmsWindowDays = dto.pmsWindowDays.coerceIn(1, 7),
|
||||
deviationAlertDays = dto.deviationAlertDays.coerceIn(1, 14),
|
||||
fertileWindowMode = dto.fertileWindowMode
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO для сериализации/десериализации настроек цикла в JSON
|
||||
*/
|
||||
data class CycleSettingsJsonDto(
|
||||
// Основные параметры цикла
|
||||
val baselineCycleLength: Int = 28,
|
||||
val cycleVariabilityDays: Int = 3,
|
||||
val periodLengthDays: Int = 5,
|
||||
val lutealPhaseDays: String = "auto",
|
||||
val lastPeriodStart: LocalDate? = null,
|
||||
|
||||
// Метод определения овуляции
|
||||
val ovulationMethod: String = "auto",
|
||||
val allowManualOvulation: Boolean = false,
|
||||
|
||||
// Статусы влияющие на точность
|
||||
val hormonalContraception: String = "none",
|
||||
val isPregnant: Boolean = false,
|
||||
val isPostpartum: Boolean = false,
|
||||
val isLactating: Boolean = false,
|
||||
val perimenopause: Boolean = false,
|
||||
|
||||
// Настройки истории и исключения выбросов
|
||||
val historyWindowCycles: Int = 6,
|
||||
val excludeOutliers: Boolean = true,
|
||||
|
||||
// Сенсоры и единицы измерения
|
||||
val tempUnit: String = "C",
|
||||
val bbtTimeWindow: String = "06:00-10:00",
|
||||
val timezone: String = "Asia/Seoul",
|
||||
|
||||
// Уведомления
|
||||
val periodReminderDaysBefore: Int = 2,
|
||||
val ovulationReminderDaysBefore: Int = 1,
|
||||
val pmsWindowDays: Int = 3,
|
||||
val deviationAlertDays: Int = 5,
|
||||
val fertileWindowMode: String = "balanced"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package kr.smartsoltech.wellshe.model
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Модель данных для прогноза менструального цикла
|
||||
*/
|
||||
data class CycleForecast(
|
||||
val nextPeriodStart: LocalDate,
|
||||
val nextOvulation: LocalDate,
|
||||
val fertileStart: LocalDate,
|
||||
val fertileEnd: LocalDate,
|
||||
val pmsStart: LocalDate,
|
||||
val periodLengthDays: Int
|
||||
) {
|
||||
val periodEnd: LocalDate get() = nextPeriodStart.plusDays(periodLengthDays.toLong() - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Расчет прогноза цикла на основе настроек
|
||||
*/
|
||||
fun computeForecast(settings: CycleSettings): CycleForecast {
|
||||
val nextPeriod = settings.lastPeriodStart.plusDays(settings.baselineLength.toLong())
|
||||
val ovulation = nextPeriod.minusDays(settings.lutealDays.toLong())
|
||||
val fertileStart = ovulation.minusDays(5)
|
||||
val fertileEnd = ovulation
|
||||
val pmsStart = nextPeriod.minusDays(3)
|
||||
|
||||
return CycleForecast(
|
||||
nextPeriodStart = nextPeriod,
|
||||
nextOvulation = ovulation,
|
||||
fertileStart = fertileStart,
|
||||
fertileEnd = fertileEnd,
|
||||
pmsStart = pmsStart,
|
||||
periodLengthDays = settings.periodLength
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует дату в формате "DD MMM"
|
||||
*/
|
||||
fun fmt(date: LocalDate): String {
|
||||
return date.format(DateTimeFormatter.ofPattern("dd MMM"))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package kr.smartsoltech.wellshe.model
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
data class CycleSettings(
|
||||
val baselineLength: Int = 28,
|
||||
val periodLength: Int = 5,
|
||||
val lutealDays: Int = 14,
|
||||
val lastPeriodStart: LocalDate
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package kr.smartsoltech.wellshe.model
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.time.LocalDate
|
||||
|
||||
class JournalConverters {
|
||||
@TypeConverter
|
||||
fun fromLocalDate(date: LocalDate): String = date.toString()
|
||||
|
||||
@TypeConverter
|
||||
fun toLocalDate(dateString: String): LocalDate = LocalDate.parse(dateString)
|
||||
|
||||
@TypeConverter
|
||||
fun fromMediaList(media: List<JournalMedia>): String = Gson().toJson(media)
|
||||
|
||||
@TypeConverter
|
||||
fun toMediaList(mediaString: String): List<JournalMedia> {
|
||||
val type = object : TypeToken<List<JournalMedia>>() {}.type
|
||||
return Gson().fromJson(mediaString, type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package kr.smartsoltech.wellshe.model
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
|
||||
@Database(entities = [JournalEntryEntity::class], version = 1, exportSchema = false)
|
||||
@TypeConverters(JournalConverters::class)
|
||||
abstract class JournalDatabase : RoomDatabase() {
|
||||
abstract fun journalEntryDao(): JournalEntryDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: JournalDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): JournalDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
JournalDatabase::class.java,
|
||||
"journal_database"
|
||||
).build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package kr.smartsoltech.wellshe.model
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
// Модель записи дневника
|
||||
data class JournalEntry(
|
||||
val id: Long = 0L, // уникальный идентификатор
|
||||
val date: LocalDate,
|
||||
val text: String,
|
||||
val media: List<JournalMedia> = emptyList()
|
||||
)
|
||||
|
||||
// Модель медиафайла (картинка, видео, музыка)
|
||||
data class JournalMedia(
|
||||
val uri: String,
|
||||
val type: MediaType
|
||||
)
|
||||
|
||||
enum class MediaType {
|
||||
IMAGE, VIDEO, AUDIO
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package kr.smartsoltech.wellshe.model
|
||||
|
||||
import androidx.room.*
|
||||
import java.time.LocalDate
|
||||
|
||||
@Dao
|
||||
interface JournalEntryDao {
|
||||
@Query("SELECT * FROM journal_entries WHERE date = :date")
|
||||
suspend fun getEntriesByDate(date: LocalDate): List<JournalEntryEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertEntry(entry: JournalEntryEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateEntry(entry: JournalEntryEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteEntry(entry: JournalEntryEntity)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package kr.smartsoltech.wellshe.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import java.time.LocalDate
|
||||
|
||||
@Entity(tableName = "journal_entries")
|
||||
data class JournalEntryEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0L,
|
||||
val date: LocalDate,
|
||||
val text: String,
|
||||
val media: String // сериализованный список медиа (например, JSON)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package kr.smartsoltech.wellshe.model
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDate
|
||||
|
||||
class JournalRepository(private val dao: JournalEntryDao) {
|
||||
suspend fun getEntriesByDate(date: LocalDate): List<JournalEntry> = withContext(Dispatchers.IO) {
|
||||
dao.getEntriesByDate(date).map { entity ->
|
||||
JournalEntry(
|
||||
id = entity.id,
|
||||
date = entity.date,
|
||||
text = entity.text,
|
||||
media = JournalConverters().toMediaList(entity.media)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addOrUpdateEntry(entry: JournalEntry) = withContext(Dispatchers.IO) {
|
||||
val entity = JournalEntryEntity(
|
||||
id = entry.id,
|
||||
date = entry.date,
|
||||
text = entry.text,
|
||||
media = JournalConverters().fromMediaList(entry.media)
|
||||
)
|
||||
dao.insertEntry(entity)
|
||||
}
|
||||
|
||||
suspend fun deleteEntry(entry: JournalEntry) = withContext(Dispatchers.IO) {
|
||||
val entity = JournalEntryEntity(
|
||||
id = entry.id,
|
||||
date = entry.date,
|
||||
text = entry.text,
|
||||
media = JournalConverters().fromMediaList(entry.media)
|
||||
)
|
||||
dao.deleteEntry(entity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package kr.smartsoltech.wellshe.model.auth
|
||||
|
||||
/**
|
||||
* Модель для запроса авторизации
|
||||
*/
|
||||
data class AuthRequest(
|
||||
val username: String? = null,
|
||||
val email: String? = null,
|
||||
val password: String
|
||||
)
|
||||
|
||||
// Создаем типоним для совместимости
|
||||
typealias LoginRequest = AuthRequest
|
||||
|
||||
/**
|
||||
* Модель для запроса регистрации
|
||||
*/
|
||||
data class RegisterRequest(
|
||||
val email: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
val first_name: String,
|
||||
val last_name: String,
|
||||
val phone: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель для запроса обновления токена
|
||||
*/
|
||||
data class TokenRefreshRequest(
|
||||
val refresh_token: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель для запроса выхода из системы
|
||||
*/
|
||||
data class LogoutRequest(
|
||||
val refresh_token: String? = null
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
package kr.smartsoltech.wellshe.model.auth
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Базовая модель ответа от API
|
||||
*/
|
||||
sealed class ApiResponse<T>
|
||||
|
||||
/**
|
||||
* Успешный ответ от API
|
||||
*/
|
||||
data class SuccessResponse<T>(
|
||||
val status: String,
|
||||
val data: T,
|
||||
val message: String? = null
|
||||
) : ApiResponse<T>()
|
||||
|
||||
/**
|
||||
* Ответ с ошибкой от API
|
||||
*/
|
||||
data class ErrorResponse<T>(
|
||||
val status: String,
|
||||
val code: String,
|
||||
val message: String,
|
||||
val details: Map<String, List<String>>? = null
|
||||
) : ApiResponse<T>()
|
||||
|
||||
/**
|
||||
* Модель ответа при успешной авторизации
|
||||
*/
|
||||
data class AuthTokenResponse(
|
||||
@SerializedName("access_token") val accessToken: String,
|
||||
@SerializedName("refresh_token") val refreshToken: String,
|
||||
@SerializedName("token_type") val tokenType: String,
|
||||
@SerializedName("expires_in") val expiresIn: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель ответа при обновлении токена
|
||||
*/
|
||||
data class TokenRefreshResponse(
|
||||
@SerializedName("access_token") val accessToken: String,
|
||||
@SerializedName("token_type") val tokenType: String,
|
||||
@SerializedName("expires_in") val expiresIn: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель ответа при регистрации
|
||||
*/
|
||||
data class RegisterResponse(
|
||||
@SerializedName("user_id") val userId: String,
|
||||
val username: String,
|
||||
val email: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Типонимы для обеспечения совместимости с кодом
|
||||
*/
|
||||
typealias AuthResponse = AuthTokenResponse
|
||||
typealias TokenResponse = TokenRefreshResponse
|
||||
@@ -0,0 +1,11 @@
|
||||
package kr.smartsoltech.wellshe.model.auth
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Модель прямого ответа авторизации от сервера, без обертки data
|
||||
*/
|
||||
data class DirectAuthResponse(
|
||||
@SerializedName("access_token") val accessToken: String,
|
||||
@SerializedName("token_type") val tokenType: String
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
package kr.smartsoltech.wellshe.model.auth
|
||||
|
||||
// Файл больше не используется
|
||||
// Типонимы перенесены в AuthResponse.kt
|
||||
@@ -0,0 +1,39 @@
|
||||
package kr.smartsoltech.wellshe.model.auth
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Базовая обертка для ответа API
|
||||
*/
|
||||
open class BaseResponseWrapper(
|
||||
val status: String = "success",
|
||||
val message: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Обертка для ответа регистрации
|
||||
*/
|
||||
class RegisterResponseWrapper(
|
||||
@SerializedName("data") val data: RegisterResponse
|
||||
) : BaseResponseWrapper()
|
||||
|
||||
/**
|
||||
* Обертка для ответа авторизации
|
||||
*/
|
||||
class AuthTokenResponseWrapper(
|
||||
@SerializedName("data") val data: AuthTokenResponse
|
||||
) : BaseResponseWrapper()
|
||||
|
||||
/**
|
||||
* Обертка для ответа обновления токена
|
||||
*/
|
||||
class TokenRefreshResponseWrapper(
|
||||
@SerializedName("data") val data: TokenRefreshResponse
|
||||
) : BaseResponseWrapper()
|
||||
|
||||
/**
|
||||
* Обертка для ответа с профилем пользователя
|
||||
*/
|
||||
class UserProfileResponseWrapper(
|
||||
@SerializedName("data") val data: UserProfile
|
||||
) : BaseResponseWrapper()
|
||||
@@ -0,0 +1,4 @@
|
||||
package kr.smartsoltech.wellshe.model.auth
|
||||
|
||||
// Файл больше не используется
|
||||
// Все классы и типонимы перенесены в AuthResponse.kt
|
||||
@@ -0,0 +1,18 @@
|
||||
package kr.smartsoltech.wellshe.model.auth
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Модель данных профиля пользователя
|
||||
*/
|
||||
data class UserProfile(
|
||||
val id: Long = 0,
|
||||
val username: String,
|
||||
val email: String,
|
||||
@SerializedName("first_name") val firstName: String,
|
||||
@SerializedName("last_name") val lastName: String,
|
||||
val phone: String,
|
||||
@SerializedName("user_id") val userId: String = "",
|
||||
@SerializedName("created_at") val createdAt: String = "",
|
||||
@SerializedName("is_verified") val isVerified: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package kr.smartsoltech.wellshe.model.emergency
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Модель для запроса создания экстренного оповещения
|
||||
*/
|
||||
data class EmergencyAlertRequest(
|
||||
val location: LocationData,
|
||||
val message: String? = null,
|
||||
@SerializedName("battery_level") val batteryLevel: Int? = null,
|
||||
@SerializedName("contact_ids") val contactIds: List<String>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель данных о местоположении
|
||||
*/
|
||||
data class LocationData(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracy: Float? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель для запроса обновления местоположения в активном оповещении
|
||||
*/
|
||||
data class LocationUpdateRequest(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracy: Float? = null,
|
||||
@SerializedName("battery_level") val batteryLevel: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель для запроса отмены экстренного оповещения
|
||||
*/
|
||||
data class AlertCancelRequest(
|
||||
val reason: String? = null,
|
||||
val details: String? = null
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
package kr.smartsoltech.wellshe.model.emergency
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Модель ответа при создании экстренного оповещения
|
||||
*/
|
||||
data class EmergencyAlertResponse(
|
||||
@SerializedName("alert_id") val alertId: String,
|
||||
@SerializedName("created_at") val createdAt: String,
|
||||
val status: String,
|
||||
val message: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель контакта с информацией о статусе уведомления
|
||||
*/
|
||||
data class NotifiedContact(
|
||||
@SerializedName("contact_id") val contactId: String,
|
||||
val status: String,
|
||||
@SerializedName("notified_at") val notifiedAt: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель информации о местоположении с датой обновления
|
||||
*/
|
||||
data class LocationStatus(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
@SerializedName("updated_at") val updatedAt: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель детального статуса экстренного оповещения
|
||||
*/
|
||||
data class EmergencyAlertStatus(
|
||||
@SerializedName("alert_id") val alertId: String,
|
||||
@SerializedName("created_at") val createdAt: String,
|
||||
val status: String,
|
||||
val location: LocationStatus,
|
||||
@SerializedName("notified_contacts") val notifiedContacts: List<NotifiedContact>,
|
||||
@SerializedName("emergency_services_notified") val emergencyServicesNotified: Boolean,
|
||||
@SerializedName("emergency_services_notified_at") val emergencyServicesNotifiedAt: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель ответа при обновлении местоположения
|
||||
*/
|
||||
data class LocationUpdateResponse(
|
||||
@SerializedName("updated_at") val updatedAt: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель ответа при отмене экстренного оповещения
|
||||
*/
|
||||
data class AlertCancelResponse(
|
||||
@SerializedName("alert_id") val alertId: String,
|
||||
@SerializedName("cancelled_at") val cancelledAt: String,
|
||||
val status: String
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package kr.smartsoltech.wellshe.model.emergency
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kr.smartsoltech.wellshe.model.auth.BaseResponseWrapper
|
||||
|
||||
/**
|
||||
* Обертка для ответа при создании экстренного оповещения
|
||||
*/
|
||||
class EmergencyAlertResponseWrapper(
|
||||
@SerializedName("data") val data: EmergencyAlertResponse
|
||||
) : BaseResponseWrapper()
|
||||
|
||||
/**
|
||||
* Обертка для ответа при получении статуса экстренного оповещения
|
||||
*/
|
||||
class EmergencyAlertStatusWrapper(
|
||||
@SerializedName("data") val data: EmergencyAlertStatus
|
||||
) : BaseResponseWrapper()
|
||||
|
||||
/**
|
||||
* Обертка для ответа при обновлении местоположения
|
||||
*/
|
||||
class LocationUpdateResponseWrapper(
|
||||
@SerializedName("data") val data: LocationUpdateResponse
|
||||
) : BaseResponseWrapper()
|
||||
|
||||
/**
|
||||
* Обертка для ответа при отмене экстренного оповещения
|
||||
*/
|
||||
class AlertCancelResponseWrapper(
|
||||
@SerializedName("data") val data: AlertCancelResponse
|
||||
) : BaseResponseWrapper()
|
||||
@@ -0,0 +1,310 @@
|
||||
package kr.smartsoltech.wellshe.ui.analytics
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kr.smartsoltech.wellshe.ui.components.InfoCard
|
||||
import kr.smartsoltech.wellshe.ui.components.KPI
|
||||
import kr.smartsoltech.wellshe.ui.theme.*
|
||||
|
||||
/**
|
||||
* Экран "Аналитика" с данными и графиками
|
||||
*/
|
||||
@Composable
|
||||
fun AnalyticsScreen(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
// Состояния для выбранного временного диапазона
|
||||
var selectedRange by remember { mutableStateOf("7d") }
|
||||
val rangeLabels = mapOf(
|
||||
"7d" to "7 дней",
|
||||
"30d" to "30 дней",
|
||||
"90d" to "90 дней"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Заголовок и селектор периода
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Аналитика",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
// Выпадающее меню для выбора периода
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Период:",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { expanded = true },
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Text(rangeLabels[selectedRange] ?: "7 дней")
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = "Выбрать период"
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
rangeLabels.forEach { (key, value) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(value) },
|
||||
onClick = {
|
||||
selectedRange = key
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KPI блоки
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
KPI(
|
||||
title = "Стабильность цикла",
|
||||
value = "Высокая",
|
||||
tone = Color(0xFF4CAF50),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
KPI(
|
||||
title = "Гидратация",
|
||||
value = "~85% нормы",
|
||||
tone = Color(0xFF2196F3),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
KPI(
|
||||
title = "Вес (${rangeLabels[selectedRange]})",
|
||||
value = "−0.6 кг",
|
||||
tone = Color(0xFFE91E63),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
KPI(
|
||||
title = "Калории (спорт)",
|
||||
value = "+2.9k",
|
||||
tone = Color(0xFF4CAF50),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Графики
|
||||
AnalyticsChart(
|
||||
title = "Динамика веса (${rangeLabels[selectedRange]})",
|
||||
subtitle = "Тренд: медленное снижение веса, без резких скачков."
|
||||
)
|
||||
|
||||
AnalyticsChart(
|
||||
title = "Гидратация (${rangeLabels[selectedRange]})",
|
||||
subtitle = "Среднее за период ~1950 мл/день."
|
||||
)
|
||||
|
||||
AnalyticsChart(
|
||||
title = "Сожжённые калории (${rangeLabels[selectedRange]})",
|
||||
subtitle = "Пики в выходные — неплохая стратегия, добавь лёгкую сессию в среду."
|
||||
)
|
||||
|
||||
// Корреляции
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = AnalyticsTabColor.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Корреляции с фазами",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
// Корреляционные линии
|
||||
CorrelationItem("Гидратация ↔ энергия", 0.42f)
|
||||
CorrelationItem("Сон ↔ настроение", 0.58f)
|
||||
CorrelationItem("ПМС ↔ боль", 0.66f)
|
||||
CorrelationItem("Вес ↔ вода", -0.18f)
|
||||
}
|
||||
}
|
||||
|
||||
// Инсайт недели
|
||||
InfoCard(
|
||||
title = "Инсайт недели",
|
||||
content = "Лучшая тренировка — суббота (600 ккал). Пиковая продуктивность совпадает с фолликулярной фазой. Вес ↓ на 0.6 кг — отличный тренд."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка с графиком для аналитики
|
||||
*/
|
||||
@Composable
|
||||
fun AnalyticsChart(
|
||||
title: String,
|
||||
subtitle: String
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
// Заглушка для графика (в реальном приложении использовать библиотеку графиков)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp)
|
||||
.padding(vertical = 8.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// В реальном приложении здесь будет график
|
||||
Text(
|
||||
text = "Графическое представление данных",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Элемент для отображения корреляции с визуализацией
|
||||
*/
|
||||
@Composable
|
||||
fun CorrelationItem(
|
||||
title: String,
|
||||
value: Float
|
||||
) {
|
||||
val color = when {
|
||||
value > 0.3f -> Color(0xFF4CAF50) // Зеленый для положительных
|
||||
value < -0.3f -> Color(0xFFE91E63) // Красный для отрицательных
|
||||
else -> Color(0xFF9E9E9E) // Серый для нейтральных
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Полоса корреляции
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(80.dp)
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width((80 * kotlin.math.abs(value)).dp)
|
||||
.height(8.dp)
|
||||
.background(color)
|
||||
)
|
||||
}
|
||||
|
||||
// Числовое значение
|
||||
Text(
|
||||
text = (if (value > 0) "+" else "") + String.format("%.2f", value),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun AnalyticsScreenPreview() {
|
||||
WellSheTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AnalyticsScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package kr.smartsoltech.wellshe.ui.analytics
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AnalyticsViewModel @Inject constructor() : ViewModel() {
|
||||
// Данные для экрана аналитики
|
||||
private val _cycleStability = MutableStateFlow("Высокая")
|
||||
val cycleStability: StateFlow<String> = _cycleStability.asStateFlow()
|
||||
|
||||
private val _hydration = MutableStateFlow(85)
|
||||
val hydration: StateFlow<Int> = _hydration.asStateFlow()
|
||||
|
||||
private val _weightChange = MutableStateFlow(-0.6f)
|
||||
val weightChange: StateFlow<Float> = _weightChange.asStateFlow()
|
||||
|
||||
private val _caloriesBurned = MutableStateFlow(2900)
|
||||
val caloriesBurned: StateFlow<Int> = _caloriesBurned.asStateFlow()
|
||||
|
||||
private val _weeklyInsight = MutableStateFlow("Лучшие тренировки приходятся на выходные; добавь лёгкое кардио в среду.")
|
||||
val weeklyInsight: StateFlow<String> = _weeklyInsight.asStateFlow()
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package kr.smartsoltech.wellshe.ui.auth
|
||||
|
||||
import android.util.Log
|
||||
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.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||
import kr.smartsoltech.wellshe.domain.auth.GetUserProfileUseCase
|
||||
import kr.smartsoltech.wellshe.domain.auth.LoginUseCase
|
||||
import kr.smartsoltech.wellshe.domain.auth.LogoutUseCase
|
||||
import kr.smartsoltech.wellshe.domain.auth.RegisterUseCase
|
||||
import kr.smartsoltech.wellshe.model.auth.AuthTokenResponseWrapper
|
||||
import kr.smartsoltech.wellshe.model.auth.UserProfile
|
||||
import kr.smartsoltech.wellshe.util.Result
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel для управления авторизацией и профилем пользователя
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val loginUseCase: LoginUseCase,
|
||||
private val registerUseCase: RegisterUseCase,
|
||||
private val logoutUseCase: LogoutUseCase,
|
||||
private val getUserProfileUseCase: GetUserProfileUseCase,
|
||||
private val authTokenRepository: AuthTokenRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _authState = MutableLiveData<AuthState>()
|
||||
val authState: LiveData<AuthState> = _authState
|
||||
|
||||
private val _userProfile = MutableLiveData<UserProfile?>()
|
||||
val userProfile: LiveData<UserProfile?> = _userProfile
|
||||
|
||||
private val _isLoading = MutableLiveData(false)
|
||||
val isLoading: LiveData<Boolean> = _isLoading
|
||||
|
||||
init {
|
||||
// Проверяем наличие сохраненных данных для автоматического входа
|
||||
checkSavedCredentials()
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка и использование сохраненных учетных данных для автоматического входа
|
||||
*/
|
||||
private fun checkSavedCredentials() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val hasAuthData = authTokenRepository.hasAuthData.first()
|
||||
if (hasAuthData) {
|
||||
val email = authTokenRepository.savedEmail.first() ?: return@launch
|
||||
val password = authTokenRepository.savedPassword.first() ?: return@launch
|
||||
login(email, password, true)
|
||||
} else {
|
||||
_authState.value = AuthState.NotAuthenticated
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_authState.value = AuthState.NotAuthenticated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Вход в систему с помощью email или username
|
||||
*/
|
||||
fun login(identifier: String, password: String, isEmail: Boolean) {
|
||||
_isLoading.value = true
|
||||
_authState.value = AuthState.Authenticating
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
Log.d("AuthViewModel", "Starting login process: $identifier, isEmail=$isEmail")
|
||||
|
||||
when (val result = loginUseCase(identifier, password, isEmail)) {
|
||||
is Result.Success -> {
|
||||
// Получаем данные авторизации из ответа
|
||||
val authData = result.data
|
||||
Log.d("AuthViewModel", "Login Success: received data of type ${authData?.javaClass?.simpleName}")
|
||||
|
||||
try {
|
||||
// Используем более безопасный подход без рефлексии
|
||||
if (authData != null) {
|
||||
val dataJson = authData.toString()
|
||||
Log.d("AuthViewModel", "Auth data toString: $dataJson")
|
||||
|
||||
// Устанавливаем состояние авторизации как успешное
|
||||
_authState.value = AuthState.Authenticated
|
||||
|
||||
// Сохраняем учетные данные для автологина
|
||||
authTokenRepository.saveAuthCredentials(identifier, password)
|
||||
|
||||
// Временно используем фиксированный токен (можно заменить на реальный, когда будет понятна структура данных)
|
||||
val tempToken = "temp_token_for_$identifier"
|
||||
authTokenRepository.saveAuthToken(tempToken)
|
||||
|
||||
// Загружаем профиль после успешной авторизации
|
||||
fetchUserProfile()
|
||||
} else {
|
||||
Log.e("AuthViewModel", "Auth data is null")
|
||||
_authState.value = AuthState.AuthError("Получены пустые данные авторизации")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("AuthViewModel", "Error processing login response: ${e.message}", e)
|
||||
_authState.value = AuthState.AuthError("Ошибка обработки ответа: ${e.message}")
|
||||
}
|
||||
}
|
||||
is Result.Error -> {
|
||||
Log.e("AuthViewModel", "Login Error: ${result.exception.message}")
|
||||
_authState.value = AuthState.AuthError(result.exception.message ?: "Ошибка авторизации")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("AuthViewModel", "Unhandled exception in login: ${e.message}", e)
|
||||
_authState.value = AuthState.AuthError("Непредвиденная ошибка: ${e.message}")
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Регистрация нового пользователя
|
||||
*/
|
||||
fun register(email: String, username: String, password: String, firstName: String, lastName: String, phone: String) {
|
||||
_isLoading.value = true
|
||||
_authState.value = AuthState.Registering
|
||||
|
||||
viewModelScope.launch {
|
||||
when (val result = registerUseCase(email, username, password, firstName, lastName, phone)) {
|
||||
is Result.Success -> {
|
||||
_authState.value = AuthState.RegistrationSuccess
|
||||
}
|
||||
is Result.Error -> {
|
||||
_authState.value = AuthState.RegistrationError(result.exception.message ?: "Ошибка регистрации")
|
||||
}
|
||||
}
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выход из системы
|
||||
*/
|
||||
fun logout() {
|
||||
_isLoading.value = true
|
||||
|
||||
viewModelScope.launch {
|
||||
when (val result = logoutUseCase()) {
|
||||
is Result.Success -> {
|
||||
// Очищаем сохраненные данные авторизации
|
||||
authTokenRepository.clearAuthData()
|
||||
_authState.value = AuthState.NotAuthenticated
|
||||
_userProfile.value = null
|
||||
}
|
||||
is Result.Error -> {
|
||||
// Даже при ошибке API сессия на устройстве будет завершена
|
||||
authTokenRepository.clearAuthData()
|
||||
_authState.value = AuthState.NotAuthenticated
|
||||
_userProfile.value = null
|
||||
}
|
||||
}
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка профиля пользователя
|
||||
*/
|
||||
fun fetchUserProfile() {
|
||||
_isLoading.value = true
|
||||
|
||||
viewModelScope.launch {
|
||||
when (val result = getUserProfileUseCase()) {
|
||||
is Result.Success -> {
|
||||
_userProfile.value = result.data
|
||||
}
|
||||
is Result.Error -> {
|
||||
// Ошибка может означать, что токен недействителен
|
||||
if (result.exception.message?.contains("авторизован") == true) {
|
||||
// Очищаем недействительный токен
|
||||
authTokenRepository.clearAuthData()
|
||||
_authState.value = AuthState.NotAuthenticated
|
||||
}
|
||||
}
|
||||
}
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояния процесса авторизации
|
||||
*/
|
||||
sealed class AuthState {
|
||||
object NotAuthenticated : AuthState()
|
||||
object Authenticating : AuthState()
|
||||
object Authenticated : AuthState()
|
||||
object Registering : AuthState()
|
||||
object RegistrationSuccess : AuthState()
|
||||
data class AuthError(val message: String) : AuthState()
|
||||
data class RegistrationError(val message: String) : AuthState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package kr.smartsoltech.wellshe.ui.auth
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kr.smartsoltech.wellshe.R
|
||||
import kr.smartsoltech.wellshe.databinding.FragmentLoginBinding
|
||||
import kr.smartsoltech.wellshe.di.ViewModelFactory
|
||||
import kr.smartsoltech.wellshe.util.isValidEmail
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Фрагмент для входа пользователя в систему
|
||||
*/
|
||||
class LoginFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentLoginBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private lateinit var viewModel: AuthViewModel
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentLoginBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Получаем ViewModel через DI
|
||||
viewModel = ViewModelProvider(requireActivity(), viewModelFactory)[AuthViewModel::class.java]
|
||||
|
||||
setupListeners()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
// Валидация ввода в реальном времени
|
||||
binding.etEmailUsername.addTextChangedListener {
|
||||
validateForm()
|
||||
}
|
||||
|
||||
binding.etPassword.addTextChangedListener {
|
||||
validateForm()
|
||||
}
|
||||
|
||||
// Нажатие на кнопку входа
|
||||
binding.btnLogin.setOnClickListener {
|
||||
val identifier = binding.etEmailUsername.text.toString()
|
||||
val password = binding.etPassword.text.toString()
|
||||
|
||||
// Определяем, это email или username
|
||||
val isEmail = identifier.isValidEmail()
|
||||
|
||||
viewModel.login(identifier, password, isEmail)
|
||||
}
|
||||
|
||||
// Переход на экран регистрации
|
||||
binding.btnRegister.setOnClickListener {
|
||||
findNavController().navigate(R.id.action_loginFragment_to_registerFragment)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
// Наблюдаем за состоянием авторизации
|
||||
viewModel.authState.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
is AuthViewModel.AuthState.Authenticating -> {
|
||||
// Показываем индикатор загрузки
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
binding.btnLogin.isEnabled = false
|
||||
}
|
||||
is AuthViewModel.AuthState.Authenticated -> {
|
||||
// Переходим на главный экран
|
||||
findNavController().navigate(R.id.action_loginFragment_to_mainFragment)
|
||||
}
|
||||
is AuthViewModel.AuthState.AuthError -> {
|
||||
// Показываем ошибку
|
||||
Toast.makeText(requireContext(), state.message, Toast.LENGTH_LONG).show()
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnLogin.isEnabled = true
|
||||
}
|
||||
else -> {
|
||||
// Сбрасываем состояние UI
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnLogin.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Наблюдаем за состоянием загрузки
|
||||
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
|
||||
binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
|
||||
binding.btnLogin.isEnabled = !isLoading
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateForm() {
|
||||
val identifier = binding.etEmailUsername.text.toString()
|
||||
val password = binding.etPassword.text.toString()
|
||||
|
||||
binding.btnLogin.isEnabled = identifier.isNotEmpty() && password.length >= 8
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package kr.smartsoltech.wellshe.ui.auth
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kr.smartsoltech.wellshe.R
|
||||
import kr.smartsoltech.wellshe.databinding.FragmentRegisterBinding
|
||||
import kr.smartsoltech.wellshe.di.ViewModelFactory
|
||||
import kr.smartsoltech.wellshe.util.isValidEmail
|
||||
import kr.smartsoltech.wellshe.util.isValidPassword
|
||||
import kr.smartsoltech.wellshe.util.isValidPhone
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Фрагмент для регистрации нового пользователя
|
||||
*/
|
||||
class RegisterFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentRegisterBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private lateinit var viewModel: AuthViewModel
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentRegisterBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Получаем ViewModel через DI
|
||||
viewModel = ViewModelProvider(requireActivity(), viewModelFactory)[AuthViewModel::class.java]
|
||||
|
||||
setupListeners()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
// Добавляем валидацию в реальном времени для всех полей формы
|
||||
binding.etEmail.addTextChangedListener { validateForm() }
|
||||
binding.etUsername.addTextChangedListener { validateForm() }
|
||||
binding.etPassword.addTextChangedListener { validateForm() }
|
||||
binding.etConfirmPassword.addTextChangedListener { validateForm() }
|
||||
binding.etFirstName.addTextChangedListener { validateForm() }
|
||||
binding.etLastName.addTextChangedListener { validateForm() }
|
||||
binding.etPhone.addTextChangedListener { validateForm() }
|
||||
|
||||
// Обработка нажатия на кнопку регистрации
|
||||
binding.btnRegister.setOnClickListener {
|
||||
if (isFormValid()) {
|
||||
register()
|
||||
}
|
||||
}
|
||||
|
||||
// Переход на экран входа
|
||||
binding.btnBackToLogin.setOnClickListener {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
private fun register() {
|
||||
val email = binding.etEmail.text?.toString() ?: ""
|
||||
val username = binding.etUsername.text?.toString() ?: ""
|
||||
val password = binding.etPassword.text?.toString() ?: ""
|
||||
val firstName = binding.etFirstName.text?.toString() ?: ""
|
||||
val lastName = binding.etLastName.text?.toString() ?: ""
|
||||
val phone = binding.etPhone.text?.toString() ?: ""
|
||||
|
||||
viewModel.register(email, username, password, firstName, lastName, phone)
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
// Наблюдаем за состоянием регистрации
|
||||
viewModel.authState.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
is AuthViewModel.AuthState.Registering -> {
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
binding.btnRegister.isEnabled = false
|
||||
}
|
||||
is AuthViewModel.AuthState.RegistrationSuccess -> {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
"Регистрация успешна. Пожалуйста, войдите в систему.",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
is AuthViewModel.AuthState.RegistrationError -> {
|
||||
Toast.makeText(requireContext(), state.message, Toast.LENGTH_LONG).show()
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnRegister.isEnabled = true
|
||||
}
|
||||
else -> {
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnRegister.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Наблюдаем за состоянием загрузки
|
||||
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
|
||||
binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
|
||||
binding.btnRegister.isEnabled = !isLoading
|
||||
}
|
||||
}
|
||||
|
||||
private fun isFormValid(): Boolean {
|
||||
val email = binding.etEmail.text?.toString() ?: ""
|
||||
val username = binding.etUsername.text?.toString() ?: ""
|
||||
val password = binding.etPassword.text?.toString() ?: ""
|
||||
val confirmPassword = binding.etConfirmPassword.text?.toString() ?: ""
|
||||
val firstName = binding.etFirstName.text?.toString() ?: ""
|
||||
val lastName = binding.etLastName.text?.toString() ?: ""
|
||||
val phone = binding.etPhone.text?.toString() ?: ""
|
||||
|
||||
var isValid = true
|
||||
|
||||
// Проверка email
|
||||
if (!email.isValidEmail()) {
|
||||
binding.tilEmail.error = "Введите корректный email"
|
||||
isValid = false
|
||||
} else {
|
||||
binding.tilEmail.error = null
|
||||
}
|
||||
|
||||
// Проверка имени пользователя
|
||||
if (username.length < 3) {
|
||||
binding.tilUsername.error = "Имя пользователя должно быть не менее 3 символов"
|
||||
isValid = false
|
||||
} else {
|
||||
binding.tilUsername.error = null
|
||||
}
|
||||
|
||||
// Проверка пароля
|
||||
if (!password.isValidPassword()) {
|
||||
binding.tilPassword.error = "Пароль должен содержать не менее 8 символов, включая цифру, заглавную букву и специальный символ"
|
||||
isValid = false
|
||||
} else {
|
||||
binding.tilPassword.error = null
|
||||
}
|
||||
|
||||
// Проверка совпадения паролей
|
||||
if (password != confirmPassword) {
|
||||
binding.tilConfirmPassword.error = "Пароли не совпадают"
|
||||
isValid = false
|
||||
} else {
|
||||
binding.tilConfirmPassword.error = null
|
||||
}
|
||||
|
||||
// Проверка имени и фамилии
|
||||
if (firstName.isEmpty()) {
|
||||
binding.tilFirstName.error = "Введите имя"
|
||||
isValid = false
|
||||
} else {
|
||||
binding.tilFirstName.error = null
|
||||
}
|
||||
|
||||
if (lastName.isEmpty()) {
|
||||
binding.tilLastName.error = "Введите фамилию"
|
||||
isValid = false
|
||||
} else {
|
||||
binding.tilLastName.error = null
|
||||
}
|
||||
|
||||
// Проверка телефона
|
||||
if (!phone.isValidPhone()) {
|
||||
binding.tilPhone.error = "Введите корректный номер телефона (международный формат)"
|
||||
isValid = false
|
||||
} else {
|
||||
binding.tilPhone.error = null
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
private fun validateForm() {
|
||||
// Простая проверка для активации/деактивации кнопки
|
||||
val allFieldsFilled = binding.etEmail.text?.isNotEmpty() == true &&
|
||||
binding.etUsername.text?.isNotEmpty() == true &&
|
||||
binding.etPassword.text?.isNotEmpty() == true &&
|
||||
binding.etConfirmPassword.text?.isNotEmpty() == true &&
|
||||
binding.etFirstName.text?.isNotEmpty() == true &&
|
||||
binding.etLastName.text?.isNotEmpty() == true &&
|
||||
binding.etPhone.text?.isNotEmpty() == true
|
||||
|
||||
binding.btnRegister.isEnabled = allFieldsFilled
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package kr.smartsoltech.wellshe.ui.auth.compose
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onNavigateToRegister: () -> Unit,
|
||||
onLoginSuccess: () -> Unit,
|
||||
viewModel: AuthViewModel = hiltViewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val authState by viewModel.authState.observeAsState()
|
||||
val isLoading by viewModel.isLoading.observeAsState()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var isFormValid by remember { mutableStateOf(false) }
|
||||
|
||||
// FocusRequester для переключения фокуса между полями
|
||||
val passwordFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(username, password) {
|
||||
isFormValid = username.isNotEmpty() && password.isNotEmpty()
|
||||
}
|
||||
|
||||
LaunchedEffect(authState) {
|
||||
if (authState is AuthViewModel.AuthState.Authenticated) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для выполнения входа
|
||||
val performLogin = {
|
||||
if (isFormValid && isLoading != true) {
|
||||
keyboardController?.hide()
|
||||
// Передаем false в качестве параметра isEmail, так как мы используем username
|
||||
viewModel.login(username, password, false)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "WellShe",
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("Имя пользователя") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { passwordFocusRequester.requestFocus() }
|
||||
)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Пароль") },
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(passwordFocusRequester),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
performLogin()
|
||||
}
|
||||
),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = if (passwordVisible) "Скрыть пароль" else "Показать пароль"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { performLogin() },
|
||||
enabled = isFormValid && isLoading != true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
) {
|
||||
if (isLoading == true) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Войти")
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = onNavigateToRegister) {
|
||||
Text("Создать новый аккаунт")
|
||||
}
|
||||
|
||||
if (authState is AuthViewModel.AuthState.AuthError) {
|
||||
Text(
|
||||
text = (authState as AuthViewModel.AuthState.AuthError).message,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package kr.smartsoltech.wellshe.ui.auth.compose
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
|
||||
import kr.smartsoltech.wellshe.util.isValidEmail
|
||||
import kr.smartsoltech.wellshe.util.isValidPassword
|
||||
import kr.smartsoltech.wellshe.util.isValidPhone
|
||||
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onRegisterSuccess: () -> Unit,
|
||||
viewModel: AuthViewModel = hiltViewModel()
|
||||
) {
|
||||
val authState by viewModel.authState.observeAsState()
|
||||
val isLoading by viewModel.isLoading.observeAsState()
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
var email by remember { mutableStateOf("") }
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var firstName by remember { mutableStateOf("") }
|
||||
var lastName by remember { mutableStateOf("") }
|
||||
var phone by remember { mutableStateOf("") }
|
||||
|
||||
var emailError by remember { mutableStateOf<String?>(null) }
|
||||
var usernameError by remember { mutableStateOf<String?>(null) }
|
||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||
var confirmPasswordError by remember { mutableStateOf<String?>(null) }
|
||||
var firstNameError by remember { mutableStateOf<String?>(null) }
|
||||
var lastNameError by remember { mutableStateOf<String?>(null) }
|
||||
var phoneError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
var isFormValid by remember { mutableStateOf(false) }
|
||||
|
||||
fun validateForm() {
|
||||
// Проверка email
|
||||
emailError = if (!email.isValidEmail()) "Введите корректный email" else null
|
||||
|
||||
// Проверка имени пользователя
|
||||
usernameError = if (username.length < 3) "Имя пользователя должно быть не менее 3 символов" else null
|
||||
|
||||
// Проверка пароля
|
||||
passwordError = if (!password.isValidPassword())
|
||||
"Пароль должен содержать не менее 8 символов, включая цифру, заглавную букву и специальный символ"
|
||||
else null
|
||||
|
||||
// Проверка совпадения паролей
|
||||
confirmPasswordError = if (password != confirmPassword) "Пароли не совпадают" else null
|
||||
|
||||
// Проверка имени и фамилии
|
||||
firstNameError = if (firstName.isEmpty()) "Введите имя" else null
|
||||
lastNameError = if (lastName.isEmpty()) "Введите фамилию" else null
|
||||
|
||||
// Проверка телефона
|
||||
phoneError = if (!phone.isValidPhone()) "Введите корректный номер телефона" else null
|
||||
|
||||
// Проверка заполнения всех полей
|
||||
isFormValid = email.isNotEmpty() && username.isNotEmpty() &&
|
||||
password.isNotEmpty() && confirmPassword.isNotEmpty() &&
|
||||
firstName.isNotEmpty() && lastName.isNotEmpty() && phone.isNotEmpty() &&
|
||||
emailError == null && usernameError == null && passwordError == null &&
|
||||
confirmPasswordError == null && firstNameError == null &&
|
||||
lastNameError == null && phoneError == null
|
||||
}
|
||||
|
||||
// Проверяем валидность формы при изменении любого поля
|
||||
LaunchedEffect(email, username, password, confirmPassword, firstName, lastName, phone) {
|
||||
validateForm()
|
||||
}
|
||||
|
||||
// Обработка успешной регистрации
|
||||
LaunchedEffect(authState) {
|
||||
if (authState is AuthViewModel.AuthState.RegistrationSuccess) {
|
||||
onRegisterSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Регистрация",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("Email") },
|
||||
isError = emailError != null,
|
||||
supportingText = { emailError?.let { Text(it) } },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("Имя пользователя") },
|
||||
isError = usernameError != null,
|
||||
supportingText = { usernameError?.let { Text(it) } },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Пароль") },
|
||||
isError = passwordError != null,
|
||||
supportingText = { passwordError?.let { Text(it) } },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = { Text("Подтвердите пароль") },
|
||||
isError = confirmPasswordError != null,
|
||||
supportingText = { confirmPasswordError?.let { Text(it) } },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = firstName,
|
||||
onValueChange = { firstName = it },
|
||||
label = { Text("Имя") },
|
||||
isError = firstNameError != null,
|
||||
supportingText = { firstNameError?.let { Text(it) } },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = lastName,
|
||||
onValueChange = { lastName = it },
|
||||
label = { Text("Фамилия") },
|
||||
isError = lastNameError != null,
|
||||
supportingText = { lastNameError?.let { Text(it) } },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it },
|
||||
label = { Text("Телефон") },
|
||||
isError = phoneError != null,
|
||||
supportingText = { phoneError?.let { Text(it) } },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.register(email, username, password, firstName, lastName, phone)
|
||||
},
|
||||
enabled = isFormValid && isLoading != true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
) {
|
||||
if (isLoading == true) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Зарегистрироваться")
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onNavigateBack,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Вернуться к входу")
|
||||
}
|
||||
|
||||
if (authState is AuthViewModel.AuthState.RegistrationError) {
|
||||
Text(
|
||||
text = (authState as AuthViewModel.AuthState.RegistrationError).message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
app/src/main/java/kr/smartsoltech/wellshe/ui/body/BodyScreen.kt
Normal file
110
app/src/main/java/kr/smartsoltech/wellshe/ui/body/BodyScreen.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
package kr.smartsoltech.wellshe.ui.body
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kr.smartsoltech.wellshe.ui.body.tabs.ActivityTab
|
||||
import kr.smartsoltech.wellshe.ui.body.tabs.WaterTab
|
||||
import kr.smartsoltech.wellshe.ui.body.tabs.WeightTab
|
||||
import kr.smartsoltech.wellshe.ui.theme.*
|
||||
|
||||
/**
|
||||
* Основной экран "Тело" с вкладками для воды, веса и активности
|
||||
*/
|
||||
@Composable
|
||||
fun BodyScreen(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var selectedTabIndex by remember { mutableStateOf(0) }
|
||||
val tabs = listOf("Вода", "Вес", "Активность")
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Вкладки
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.large),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
divider = {},
|
||||
indicator = {}
|
||||
) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
val selected = selectedTabIndex == index
|
||||
val tabColor = when(index) {
|
||||
0 -> if (selected) BodyTabColor else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
1 -> if (selected) Color(0xFFFCE4EC) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
2 -> if (selected) Color(0xFFE0F2F1) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
}
|
||||
|
||||
Tab(
|
||||
selected = selected,
|
||||
onClick = { selectedTabIndex = index },
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(tabColor)
|
||||
) {
|
||||
val emoji = when(index) {
|
||||
0 -> "💧"
|
||||
1 -> "⚖️"
|
||||
2 -> "🏃♀️"
|
||||
else -> ""
|
||||
}
|
||||
Text(
|
||||
text = "$emoji $title",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Содержимое вкладки
|
||||
when (selectedTabIndex) {
|
||||
0 -> WaterTab()
|
||||
1 -> WeightTab()
|
||||
2 -> ActivityTab()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun BodyScreenPreview() {
|
||||
WellSheTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
BodyScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BodyScreenDarkPreview() {
|
||||
WellSheTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
BodyScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package kr.smartsoltech.wellshe.ui.body
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class BodyViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
|
||||
// Данные для вкладки "Вода"
|
||||
private val _waterData = MutableStateFlow<List<Int>>(listOf(1800, 1950, 2050, 1900, 2100, 2300, 2000))
|
||||
val waterData: StateFlow<List<Int>> = _waterData.asStateFlow()
|
||||
|
||||
private val _todayWaterAmount = MutableStateFlow(1800)
|
||||
val todayWaterAmount: StateFlow<Int> = _todayWaterAmount.asStateFlow()
|
||||
|
||||
private val _waterGoal = MutableStateFlow(2000) // 2л по умолчанию в мл
|
||||
val waterGoal: StateFlow<Int> = _waterGoal.asStateFlow()
|
||||
|
||||
// Данные для вкладки "Вес"
|
||||
private val _weightHistory = MutableStateFlow<List<Pair<String, Float>>>(
|
||||
listOf(
|
||||
"Пн" to 58.9f,
|
||||
"Вт" to 58.7f,
|
||||
"Ср" to 58.6f,
|
||||
"Чт" to 58.5f,
|
||||
"Пт" to 58.4f,
|
||||
"Сб" to 58.4f,
|
||||
"Вс" to 58.3f
|
||||
)
|
||||
)
|
||||
val weightHistory: StateFlow<List<Pair<String, Float>>> = _weightHistory.asStateFlow()
|
||||
|
||||
private val _currentWeight = MutableStateFlow<Float?>(58.4f)
|
||||
val currentWeight: StateFlow<Float?> = _currentWeight.asStateFlow()
|
||||
|
||||
private val _weeklyWeightDifference = MutableStateFlow(-0.6f)
|
||||
val weeklyWeightDifference: StateFlow<Float> = _weeklyWeightDifference.asStateFlow()
|
||||
|
||||
// Данные для вкладки "Активность"
|
||||
private val _activityData = MutableStateFlow<List<WorkoutData>>(
|
||||
listOf(
|
||||
WorkoutData("Пн", 250),
|
||||
WorkoutData("Вт", 350),
|
||||
WorkoutData("Ср", 410),
|
||||
WorkoutData("Чт", 280),
|
||||
WorkoutData("Пт", 500),
|
||||
WorkoutData("Сб", 600),
|
||||
WorkoutData("Вс", 420)
|
||||
)
|
||||
)
|
||||
val activityData: StateFlow<List<WorkoutData>> = _activityData.asStateFlow()
|
||||
|
||||
private val _todayCaloriesBurned = MutableStateFlow(312)
|
||||
val todayCaloriesBurned: StateFlow<Int> = _todayCaloriesBurned.asStateFlow()
|
||||
|
||||
private val _bestWorkoutDay = MutableStateFlow<Pair<String, Int>?>(Pair("Сб", 600))
|
||||
val bestWorkoutDay: StateFlow<Pair<String, Int>?> = _bestWorkoutDay.asStateFlow()
|
||||
|
||||
init {
|
||||
loadWaterData()
|
||||
loadWeightData()
|
||||
loadActivityData()
|
||||
}
|
||||
|
||||
fun loadWaterData() {
|
||||
// Здесь будет логика загрузки данных о воде
|
||||
// Сейчас используем тестовые данные
|
||||
}
|
||||
|
||||
fun loadWeightData() {
|
||||
// Здесь будет логика загрузки данных о весе
|
||||
// Сейчас используем тестовые данные
|
||||
}
|
||||
|
||||
fun loadActivityData() {
|
||||
// Здесь будет логика загрузки данных об активности
|
||||
// Сейчас используем тестовые данные
|
||||
}
|
||||
|
||||
fun addWater(amount: Int) {
|
||||
val currentAmount = _todayWaterAmount.value
|
||||
_todayWaterAmount.value = currentAmount + amount
|
||||
|
||||
// Обновляем данные в истории
|
||||
val updatedWaterData = _waterData.value.toMutableList()
|
||||
if (updatedWaterData.isNotEmpty()) {
|
||||
updatedWaterData[updatedWaterData.size - 1] = _todayWaterAmount.value
|
||||
_waterData.value = updatedWaterData
|
||||
}
|
||||
}
|
||||
|
||||
fun logWeight(weightKg: Float) {
|
||||
_currentWeight.value = weightKg
|
||||
|
||||
// Обновляем историю веса
|
||||
val updatedWeightHistory = _weightHistory.value.toMutableList()
|
||||
if (updatedWeightHistory.isNotEmpty()) {
|
||||
val lastDay = updatedWeightHistory.last().first
|
||||
updatedWeightHistory[updatedWeightHistory.size - 1] = lastDay to weightKg
|
||||
_weightHistory.value = updatedWeightHistory
|
||||
|
||||
// Пересчитываем разницу за неделю
|
||||
val firstWeight = updatedWeightHistory.first().second
|
||||
_weeklyWeightDifference.value = weightKg - firstWeight
|
||||
}
|
||||
}
|
||||
|
||||
fun startWorkout(exerciseId: Long) {
|
||||
// Здесь будет логика начала тренировки
|
||||
}
|
||||
}
|
||||
|
||||
data class WorkoutData(
|
||||
val date: String,
|
||||
val caloriesBurned: Int
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
package kr.smartsoltech.wellshe.ui.body.tabs
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.DirectionsRun
|
||||
import androidx.compose.material.icons.filled.LocalFireDepartment
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import kr.smartsoltech.wellshe.ui.components.InfoCard
|
||||
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||
|
||||
/**
|
||||
* Содержимое вкладки "Активность" экрана "Тело"
|
||||
*/
|
||||
@Composable
|
||||
fun ActivityTab(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Данные для отображения (в реальном приложении должны поступать из ViewModel)
|
||||
val caloriesBurned = "+312 ккал"
|
||||
val activityDescription = "Бег 8 км/ч · 35 мин"
|
||||
val activityColor = Color(0xFF4CAF50) // Зеленый для активности
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Основная карточка с активностью
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFE0F2F1).copy(alpha = 0.3f) // Светло-зеленый фон
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Заголовок
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DirectionsRun,
|
||||
contentDescription = null,
|
||||
tint = activityColor
|
||||
)
|
||||
Text(
|
||||
text = "Активность",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = activityColor
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { /*TODO*/ },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = activityColor
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Начать активность")
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Начать")
|
||||
}
|
||||
}
|
||||
|
||||
// Отображение калорий
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = caloriesBurned,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = activityDescription,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// График сожженных калорий
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocalFireDepartment,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFF9800)
|
||||
)
|
||||
Text(
|
||||
text = "Сожжённые калории (7 дней)",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
// График (заглушка, в реальном приложении использовать библиотеку графиков)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
) {
|
||||
Text(
|
||||
"График сожженных калорий",
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Аналитика
|
||||
Text(
|
||||
text = "Средний расход 416 ккал/день, лучший день — 600 ккал (сб)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// История активностей
|
||||
InfoCard(
|
||||
title = "Недавние активности",
|
||||
content = "Бег 35 мин, Йога 40 мин, Плавание 30 мин, Велосипед 45 мин, Ходьба 60 мин"
|
||||
)
|
||||
|
||||
// Рекомендации
|
||||
InfoCard(
|
||||
title = "Рекомендация",
|
||||
content = "Добавь 1 лёгкую кардио-сессию в среду для более равномерного распределения нагрузки в течение недели."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ActivityTabPreview() {
|
||||
WellSheTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
ActivityTab(Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package kr.smartsoltech.wellshe.ui.body.tabs
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.TrendingUp
|
||||
import androidx.compose.material.icons.filled.WaterDrop
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import kr.smartsoltech.wellshe.ui.components.InfoCard
|
||||
import kr.smartsoltech.wellshe.ui.components.ProgressWithLabel
|
||||
import kr.smartsoltech.wellshe.ui.theme.WaterColor
|
||||
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||
|
||||
/**
|
||||
* Содержимое вкладки "Вода" экрана "Тело"
|
||||
*/
|
||||
@Composable
|
||||
fun WaterTab(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Данные для отображения (в реальном приложении должны поступать из ViewModel)
|
||||
val currentWater = 1800
|
||||
val targetWater = 2000
|
||||
val progress = currentWater.toFloat() / targetWater
|
||||
val percentageText = "${(progress * 100).toInt()}% дневной нормы"
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Основная карточка с водой
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Заголовок
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.WaterDrop,
|
||||
contentDescription = null,
|
||||
tint = WaterColor
|
||||
)
|
||||
Text(
|
||||
text = "Вода",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = WaterColor
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = { /*TODO*/ }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Поиск напитка")
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Поиск")
|
||||
}
|
||||
|
||||
Button(onClick = { /*TODO*/ }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Добавить воду")
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Выпито")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Прогресс воды
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "$currentWater / $targetWater мл",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
ProgressWithLabel(
|
||||
progress = progress,
|
||||
label = percentageText,
|
||||
color = WaterColor
|
||||
)
|
||||
}
|
||||
|
||||
// Тренд
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.TrendingUp,
|
||||
contentDescription = null,
|
||||
tint = WaterColor
|
||||
)
|
||||
Text(
|
||||
text = "Тренд за неделю",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
// График (заглушка, в реальном приложении использовать библиотеку графиков)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
) {
|
||||
Text(
|
||||
"График пот<D0BE><D182>ебления воды",
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Статистика
|
||||
Text(
|
||||
text = "Энергия из напитков: 139 ккал • Сахара: 35 г • Вода: ~2.0 л",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Недавно добавленные напитки
|
||||
InfoCard(
|
||||
title = "Недавно добавленные напитки",
|
||||
content = "Вода 250 мл, Чай зеленый 200 мл, Кофе 150 мл, Сок яблочный 200 мл"
|
||||
)
|
||||
|
||||
// Рекомендации
|
||||
InfoCard(
|
||||
title = "Рекомендация",
|
||||
content = "Старайтесь пить больше воды утром. Это поможет ускорить метаболизм и увеличить энергию на весь день."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun WaterTabPreview() {
|
||||
WellSheTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
WaterTab(Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package kr.smartsoltech.wellshe.ui.body.tabs
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Scale
|
||||
import androidx.compose.material.icons.filled.Spa
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import kr.smartsoltech.wellshe.ui.components.InfoCard
|
||||
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||
|
||||
/**
|
||||
* Содержимое вкладки "Вес" экрана "Тело"
|
||||
*/
|
||||
@Composable
|
||||
fun WeightTab(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Данные для отображения (в реальном приложении должны поступать из ViewModel)
|
||||
val currentWeight = "58.4 кг"
|
||||
val weightChange = "−0.6 кг за неделю"
|
||||
val weightColor = Color(0xFFEC407A) // Розовый для веса
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Основная карточка с весом
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFFCE4EC).copy(alpha = 0.3f) // Светло-розовый фон
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Заголовок
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Scale,
|
||||
contentDescription = null,
|
||||
tint = weightColor
|
||||
)
|
||||
Text(
|
||||
text = "Вес",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = weightColor
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { /*TODO*/ },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = weightColor
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Добавить запись веса")
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Добавить")
|
||||
}
|
||||
}
|
||||
|
||||
// Отображение веса
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = currentWeight,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = weightChange,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// График динамики веса (заглушка)
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Spa,
|
||||
contentDescription = null,
|
||||
tint = weightColor
|
||||
)
|
||||
Text(
|
||||
text = "Динамика веса",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
// График (заглушка, в реальном приложении использовать библиотеку графиков)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
) {
|
||||
Text(
|
||||
"График изменения веса",
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Прогноз
|
||||
Text(
|
||||
text = "Прогноз: при сохранении режима целевой вес 57.5 кг через 3 недели",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// История записей
|
||||
InfoCard(
|
||||
title = "История записей",
|
||||
content = "Пн: 58.9 кг, Вт: 58.7 кг, Ср: 58.6 кг, Чт: 58.5 кг, Пт: 58.4 кг, Сб: 58.4 кг, Вс: 58.3 кг"
|
||||
)
|
||||
|
||||
// Рекомендации
|
||||
InfoCard(
|
||||
title = "Анализ",
|
||||
content = "Стабильное снижение веса показывает хороший прогресс. Для оптимального результата рекомендуется пить больше воды и добавить умеренные кардио-нагрузки."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun WeightTabPreview() {
|
||||
WellSheTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
WeightTab(Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package kr.smartsoltech.wellshe.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.Transparent,
|
||||
border = BorderStroke(1.dp, color)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color.copy(alpha = 0.8f),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Индикатор прогресса с процентами
|
||||
*/
|
||||
@Composable
|
||||
fun ProgressWithLabel(
|
||||
progress: Float,
|
||||
label: String,
|
||||
color: Color = MaterialTheme.colorScheme.primary,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
LinearProgressIndicator(
|
||||
progress = progress,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
color = color,
|
||||
trackColor = color.copy(alpha = 0.2f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключевой показатель эффективности (KPI)
|
||||
*/
|
||||
@Composable
|
||||
fun KPI(
|
||||
title: String,
|
||||
value: String,
|
||||
tone: Color? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val backgroundColor = tone?.copy(alpha = 0.15f) ?: MaterialTheme.colorScheme.surfaceVariant
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = backgroundColor
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключатель с подписью
|
||||
*/
|
||||
@Composable
|
||||
fun ToggleRow(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
uncheckedThumbColor = MaterialTheme.colorScheme.outline,
|
||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package kr.smartsoltech.wellshe.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LocalDrink
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun WaterIntakeDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Int) -> Unit
|
||||
) {
|
||||
var waterAmount by remember { mutableStateOf("200") }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocalDrink,
|
||||
contentDescription = "Добавить воду",
|
||||
tint = Color(0xFF3B82F6),
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Сколько воды вы выпили?",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = waterAmount,
|
||||
onValueChange = { value ->
|
||||
waterAmount = value.filter { it.isDigit() }
|
||||
isError = waterAmount.isEmpty() || waterAmount.toIntOrNull() == null
|
||||
},
|
||||
label = { Text("Количество (мл)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
isError = isError,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (isError) {
|
||||
Text(
|
||||
text = "Введите корректное количество",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Отмена")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val amount = waterAmount.toIntOrNull() ?: return@Button
|
||||
onConfirm(amount)
|
||||
},
|
||||
enabled = !isError && waterAmount.isNotEmpty()
|
||||
) {
|
||||
Text("Добавить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package kr.smartsoltech.wellshe.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Scale
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun WeightInputDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Float) -> Unit,
|
||||
initialWeight: Float = 60f
|
||||
) {
|
||||
var weightInput by remember { mutableStateOf(initialWeight.toString()) }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Scale,
|
||||
contentDescription = "Добавить вес",
|
||||
tint = Color(0xFFEC4899),
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Запись веса",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = weightInput,
|
||||
onValueChange = { value ->
|
||||
// Разрешаем только цифры и одну точку
|
||||
val filtered = value.filter { it.isDigit() || it == '.' }
|
||||
// Проверяем, что точка только одна
|
||||
val dotCount = filtered.count { it == '.' }
|
||||
weightInput = if (dotCount > 1) {
|
||||
filtered.substring(0, filtered.lastIndexOf('.'))
|
||||
} else {
|
||||
filtered
|
||||
}
|
||||
|
||||
// Проверка валидности
|
||||
isError = try {
|
||||
val weight = weightInput.toFloat()
|
||||
weight <= 0f || weight > 300f // Разумные ограничения на вес
|
||||
} catch (e: NumberFormatException) {
|
||||
true
|
||||
}
|
||||
},
|
||||
label = { Text("Вес (кг)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
isError = isError,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (isError) {
|
||||
Text(
|
||||
text = "Введите корректное значение веса (1-300 кг)",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Отмена")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
try {
|
||||
val weight = weightInput.toFloat()
|
||||
if (weight > 0f && weight <= 300f) {
|
||||
onConfirm(weight)
|
||||
}
|
||||
} catch (e: NumberFormatException) {
|
||||
// Ошибка уже отображается через isError
|
||||
}
|
||||
},
|
||||
enabled = !isError && weightInput.isNotEmpty()
|
||||
) {
|
||||
Text("Сохранить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package kr.smartsoltech.wellshe.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun WorkoutSelectionDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Long) -> Unit
|
||||
) {
|
||||
// Тестовый список тренировок
|
||||
val workouts = listOf(
|
||||
Workout(1L, "Бег", "Кардио", 8.5),
|
||||
Workout(2L, "Ходьба", "Кардио", 4.2),
|
||||
Workout(3L, "Йога", "Растяжка", 3.0),
|
||||
Workout(4L, "Силовая тренировка", "Силовая", 6.0),
|
||||
Workout(5L, "Плавание", "Кардио", 7.0),
|
||||
Workout(6L, "Велосипед", "Кардио", 7.5)
|
||||
)
|
||||
|
||||
var selectedWorkout by remember { mutableStateOf<Workout?>(null) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.FitnessCenter,
|
||||
contentDescription = "Выбор тренировки",
|
||||
tint = Color(0xFF10B981), // Зеленый цвет
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Выберите тренировку",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp)
|
||||
) {
|
||||
items(workouts) { workout ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = workout == selectedWorkout,
|
||||
onClick = { selectedWorkout = workout }
|
||||
)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = workout == selectedWorkout,
|
||||
onClick = { selectedWorkout = workout }
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = workout.name,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = "${workout.type} • ~${workout.caloriesPerMinute} ккал/мин",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Отмена")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
selectedWorkout?.let { onConfirm(it.id) }
|
||||
},
|
||||
enabled = selectedWorkout != null
|
||||
) {
|
||||
Text("Начать")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Workout(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val type: String,
|
||||
val caloriesPerMinute: Double
|
||||
)
|
||||
@@ -1,824 +1,201 @@
|
||||
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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
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.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
|
||||
onNavigateToSettings: () -> Unit = {}, // Параметр для навигации к настройкам
|
||||
onNavigateToEmergency: () -> Unit = {} // Добавляем параметр для навигации к экрану экстренной помощи
|
||||
) {
|
||||
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)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Текущий цикл",
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(200.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CycleProgressIndicator(
|
||||
progress = progress,
|
||||
currentPhase = currentPhase,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "День $cycleDay",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = PrimaryPink
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Менструальный цикл") },
|
||||
actions = {
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
contentDescription = "Настройки цикла"
|
||||
)
|
||||
)
|
||||
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)
|
||||
) {
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp)
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(12.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)
|
||||
) {
|
||||
QuickActionsCard(
|
||||
onMarkStart = { },
|
||||
onMarkEnd = { },
|
||||
onAddSymptom = { },
|
||||
onAddNote = { }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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?.nextPeriodStart?.format(
|
||||
DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
|
||||
) ?: "—",
|
||||
tone = Color(0xFF2196F3),
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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) }
|
||||
StatCard(
|
||||
title = "Овуляция",
|
||||
value = previewState.forecast?.nextOvulation?.format(
|
||||
DateTimeFormatter.ofPattern("dd MMM", Locale("ru"))
|
||||
) ?: "—",
|
||||
tone = Color(0xFF4CAF50),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (period != recentPeriods.last()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, locale = "ru")
|
||||
@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 CycleScreenPreviewRussian() {
|
||||
CycleScreenPreview()
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun CycleScreenPreviewDark() {
|
||||
CycleScreenPreview()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package kr.smartsoltech.wellshe.ui.cycle
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kr.smartsoltech.wellshe.model.CycleSettings
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
@Composable
|
||||
fun CycleSettingsDialog(
|
||||
initialSettings: CycleSettings,
|
||||
onSave: (CycleSettings) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var baselineLength by remember { mutableStateOf(initialSettings.baselineLength.toString()) }
|
||||
var periodLength by remember { mutableStateOf(initialSettings.periodLength.toString()) }
|
||||
var lutealDays by remember { mutableStateOf(initialSettings.lutealDays.toString()) }
|
||||
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
var lastPeriodStartStr by remember {
|
||||
mutableStateOf(initialSettings.lastPeriodStart.format(formatter))
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
try {
|
||||
val newSettings = CycleSettings(
|
||||
baselineLength = baselineLength.toIntOrNull() ?: initialSettings.baselineLength,
|
||||
periodLength = periodLength.toIntOrNull() ?: initialSettings.periodLength,
|
||||
lutealDays = lutealDays.toIntOrNull() ?: initialSettings.lutealDays,
|
||||
lastPeriodStart = try {
|
||||
LocalDate.parse(lastPeriodStartStr, formatter)
|
||||
} catch (e: DateTimeParseException) {
|
||||
initialSettings.lastPeriodStart
|
||||
}
|
||||
)
|
||||
onSave(newSettings)
|
||||
} catch (e: Exception) {
|
||||
// В случае ошибки сохраняем исходные настройки
|
||||
onSave(initialSettings)
|
||||
}
|
||||
}) {
|
||||
Text("Сохранить")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = onDismiss) { Text("Отмена") }
|
||||
},
|
||||
title = { Text("Настройки цикла") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = baselineLength,
|
||||
onValueChange = { baselineLength = it },
|
||||
label = { Text("Длина цикла (дней)") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = periodLength,
|
||||
onValueChange = { periodLength = it },
|
||||
label = { Text("Длина месячных (дней)") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = lutealDays,
|
||||
onValueChange = { lutealDays = it },
|
||||
label = { Text("Лютеиновая фаза (дней)") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = lastPeriodStartStr,
|
||||
onValueChange = { lastPeriodStartStr = it },
|
||||
label = { Text("Первый день последних месячных (гггг-мм-дд)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package kr.smartsoltech.wellshe.ui.cycle
|
||||
|
||||
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||
import kr.smartsoltech.wellshe.model.CycleSettings
|
||||
import java.time.LocalDate
|
||||
|
||||
data class CycleUiState(
|
||||
// Основные параметры цикла
|
||||
val currentPhase: String = "Фолликулярная",
|
||||
val currentCycleDay: Int = 1,
|
||||
val cycleLength: Int = 28,
|
||||
val daysUntilNextPeriod: Int = 0,
|
||||
val isPeriodActive: Boolean = false,
|
||||
|
||||
// Прогнозы
|
||||
val nextPeriodDate: LocalDate? = null,
|
||||
val ovulationDate: LocalDate? = null,
|
||||
val fertilityWindow: Pair<LocalDate, LocalDate>? = null,
|
||||
|
||||
// Симптомы и настроение сегодня
|
||||
val todaySymptoms: List<String> = emptyList(),
|
||||
val todayMood: String = "",
|
||||
val showSymptomsEdit: Boolean = false,
|
||||
|
||||
// Аналитика
|
||||
val averageCycleLength: Float = 0f,
|
||||
val insights: List<String> = emptyList(),
|
||||
val recentPeriods: List<CyclePeriodEntity> = emptyList(),
|
||||
|
||||
// Параметры цикла пользователя
|
||||
val cycleSettings: CycleSettings = CycleSettings(
|
||||
baselineLength = 28,
|
||||
periodLength = 5,
|
||||
lutealDays = 14,
|
||||
lastPeriodStart = LocalDate.now().minusDays(14)
|
||||
),
|
||||
|
||||
// Состояние загрузки и ошибки
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user