Merge pull request 'emergency' (#2) from emergency into main

Reviewed-on: #2
This commit is contained in:
2025-10-16 07:00:28 +00:00
168 changed files with 29638 additions and 1886 deletions

123
.idea/codeStyles/Project.xml generated Normal file
View 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
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

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

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

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

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -17,6 +17,16 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Добавляем путь для экспорта схемы Room
javaCompileOptions {
annotationProcessorOptions {
arguments += mapOf(
"room.schemaLocation" to "$projectDir/schemas",
"room.incremental" to "true"
)
}
}
}
buildTypes {
@@ -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")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -5,30 +5,79 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import kr.smartsoltech.wellshe.data.entity.*
import kr.smartsoltech.wellshe.data.dao.*
import kr.smartsoltech.wellshe.data.converter.DateConverters
import java.time.LocalDate
import androidx.room.TypeConverter
@Database(
entities = [
// Основные сущности
WaterLogEntity::class,
WorkoutEntity::class,
SleepLogEntity::class,
CyclePeriodEntity::class,
HealthRecordEntity::class,
WorkoutEntity::class,
CalorieEntity::class,
StepsEntity::class,
UserProfileEntity::class
UserProfileEntity::class,
WorkoutSession::class,
WorkoutSessionParam::class,
WorkoutEvent::class,
CyclePeriodEntity::class,
HealthRecordEntity::class,
// Новые сущности модуля "Настройки цикла"
CycleSettingsEntity::class,
CycleHistoryEntity::class,
CycleForecastEntity::class,
// Дополнительные сущности из BodyEntities.kt
Nutrient::class,
Beverage::class,
BeverageServing::class,
BeverageServingNutrient::class,
WaterLog::class,
BeverageLog::class,
BeverageLogNutrient::class,
WeightLog::class,
Exercise::class,
ExerciseParam::class,
ExerciseFormula::class,
ExerciseFormulaVar::class,
CatalogVersion::class
],
version = 1,
exportSchema = false
version = 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()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,51 +5,6 @@ import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.data.entity.*
import java.time.LocalDate
@Dao
interface WaterLogDao {
@Query("SELECT * FROM water_logs WHERE date = :date ORDER BY timestamp DESC")
fun getWaterLogsForDate(date: LocalDate): Flow<List<WaterLogEntity>>
@Query("SELECT SUM(amount) FROM water_logs WHERE date = :date")
suspend fun getTotalWaterForDate(date: LocalDate): Int?
@Insert
suspend fun insertWaterLog(waterLog: WaterLogEntity)
@Delete
suspend fun deleteWaterLog(waterLog: WaterLogEntity)
@Query("SELECT * FROM water_logs WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
fun getWaterLogsForPeriod(startDate: LocalDate, endDate: LocalDate): Flow<List<WaterLogEntity>>
}
@Dao
interface CyclePeriodDao {
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC")
fun getAllPeriods(): Flow<List<CyclePeriodEntity>>
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT 1")
suspend fun getLastPeriod(): CyclePeriodEntity?
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT 1")
fun getCurrentPeriod(): Flow<CyclePeriodEntity?>
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT :limit")
fun getRecentPeriods(limit: Int): Flow<List<CyclePeriodEntity>>
@Insert
suspend fun insertPeriod(period: CyclePeriodEntity)
@Update
suspend fun updatePeriod(period: CyclePeriodEntity)
@Delete
suspend fun deletePeriod(period: CyclePeriodEntity)
@Query("SELECT * FROM cycle_periods WHERE startDate BETWEEN :startDate AND :endDate")
fun getPeriodsInRange(startDate: LocalDate, endDate: LocalDate): Flow<List<CyclePeriodEntity>>
}
@Dao
interface SleepLogDao {
@Query("SELECT * FROM sleep_logs WHERE date = :date")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 час
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
// Этот файл больше не используется, все классы перенесены в CycleSettingsEvents.kt

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
package kr.smartsoltech.wellshe.model.auth
// Файл больше не используется
// Типонимы перенесены в AuthResponse.kt

View File

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

View File

@@ -0,0 +1,4 @@
package kr.smartsoltech.wellshe.model.auth
// Файл больше не используется
// Все классы и типонимы перенесены в AuthResponse.kt

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More