init commit
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Пример переменных окружения для WellShe
|
||||||
|
WATER_GOAL_DEFAULT=2000
|
||||||
|
THEME=light
|
||||||
|
ONBOARDING_COMPLETE=false
|
||||||
|
NOTIFICATIONS_ENABLED=true
|
||||||
|
|
||||||
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
3
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
26
.idea/appInsightsSettings.xml
generated
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AppInsightsSettings">
|
||||||
|
<option name="tabSettings">
|
||||||
|
<map>
|
||||||
|
<entry key="Firebase Crashlytics">
|
||||||
|
<value>
|
||||||
|
<InsightsFilterSettings>
|
||||||
|
<option name="connection">
|
||||||
|
<ConnectionSetting>
|
||||||
|
<option name="appId" value="PLACEHOLDER" />
|
||||||
|
<option name="mobileSdkAppId" value="" />
|
||||||
|
<option name="projectId" value="" />
|
||||||
|
<option name="projectNumber" value="" />
|
||||||
|
</ConnectionSetting>
|
||||||
|
</option>
|
||||||
|
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||||
|
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||||
|
<option name="visibilityType" value="ALL" />
|
||||||
|
</InsightsFilterSettings>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="21" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AskMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Ask2AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="EditMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/deploymentTargetSelector.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetSelector">
|
||||||
|
<selectionStates>
|
||||||
|
<SelectionState runConfigName="app">
|
||||||
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
</SelectionState>
|
||||||
|
</selectionStates>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
13
.idea/deviceManager.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
18
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
61
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
10
.idea/migrations.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectMigrations">
|
||||||
|
<option name="MigrateToGradleLocalJavaHome">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?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">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
17
.idea/runConfigurations.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
55
README.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# WellShe — MVP Android приложение для женщин
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
WellShe — офлайн-приложение для учёта воды, домашних тренировок, дневника сна, контроля осанки и женского цикла. Все данные хранятся локально (Room + DataStore), уведомления работают без сети, экспорт/импорт — зашифрованный JSON.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
- Room: вода, тренировки, сон, осанка, цикл, настройки
|
||||||
|
- DataStore: цели, темы, уведомления, онбординг
|
||||||
|
- DI: Hilt
|
||||||
|
- UI: Jetpack Compose + Material3
|
||||||
|
- Фон: WorkManager, AlarmManager, SensorManager
|
||||||
|
- Уведомления: Notification API
|
||||||
|
- ML/аналитика: локальные алгоритмы
|
||||||
|
|
||||||
|
## Сборка и запуск
|
||||||
|
```bash
|
||||||
|
# Сборка APK
|
||||||
|
./gradlew assembleRelease
|
||||||
|
|
||||||
|
# Запуск на эмуляторе
|
||||||
|
./gradlew installDebug
|
||||||
|
|
||||||
|
# Запуск unit-тестов
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Экспорт / импорт данных
|
||||||
|
- В настройках приложения доступны кнопки "Экспорт данных" и "Импорт данных".
|
||||||
|
- Для экспорта/импорта требуется PIN (шифрование AES-256).
|
||||||
|
|
||||||
|
## Смена темы
|
||||||
|
- В настройках приложения выберите светлую или тёмную тему.
|
||||||
|
|
||||||
|
## Сброс онбординга
|
||||||
|
- В настройках приложения доступен сброс онбординга.
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
- Пример: .env.example, local.properties
|
||||||
|
|
||||||
|
## Разрешения
|
||||||
|
- POST_NOTIFICATIONS
|
||||||
|
- SCHEDULE_EXACT_ALARM
|
||||||
|
- FOREGROUND_SERVICE
|
||||||
|
|
||||||
|
## Дисклеймер
|
||||||
|
Приложение не является медицинским устройством.
|
||||||
|
|
||||||
|
## Acceptance Checklist
|
||||||
|
- Все данные офлайн (Room + DataStore)
|
||||||
|
- Уведомления без сети
|
||||||
|
- Прогноз цикла с меткой уверенности
|
||||||
|
- Сон и будильник офлайн
|
||||||
|
- Старт < 800 мс, APK < 30 МБ
|
||||||
|
- Экспорт / импорт JSON успешен
|
||||||
|
|
||||||
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
79
app/build.gradle.kts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.hilt)
|
||||||
|
id("kotlin-kapt")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "kr.smartsoltech.wellshe"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "kr.smartsoltech.wellshe"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
kapt(libs.hilt.compiler)
|
||||||
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
|
kapt("androidx.room:room-compiler:2.6.1")
|
||||||
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
|
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
|
implementation("androidx.compose.runtime:runtime-livedata:1.5.4")
|
||||||
|
implementation(libs.androidx.compose.ui.tooling)
|
||||||
|
implementation("androidx.compose.material:material-icons-extended:1.5.4")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||||
|
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")
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation("io.mockk:mockk:1.13.8")
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
21
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package kr.smartsoltech.wellshe
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("kr.smartsoltech.wellshe", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Разрешения для уведомлений -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<!-- Разрешения для датчиков -->
|
||||||
|
<uses-permission android:name="android.permission.BODY_SENSORS" />
|
||||||
|
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||||
|
|
||||||
|
<!-- Разрешения для сети (для возможного экспорта данных) -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".WellSheApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.WellShe">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.WellShe"
|
||||||
|
android:screenOrientation="portrait">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- WorkManager для фоновых задач -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
android:exported="false"
|
||||||
|
tools:node="merge">
|
||||||
|
<meta-data
|
||||||
|
android:name="androidx.work.WorkManagerInitializer"
|
||||||
|
android:value="androidx.startup" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
22
app/src/main/java/kr/smartsoltech/wellshe/MainActivity.kt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package kr.smartsoltech.wellshe
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kr.smartsoltech.wellshe.ui.navigation.WellSheNavigation
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
WellSheTheme {
|
||||||
|
WellSheNavigation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.smartsoltech.wellshe
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class WellSheApplication : Application() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
// TODO: Initialize app components when repositories are ready
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
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
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
WaterLogEntity::class,
|
||||||
|
WorkoutEntity::class,
|
||||||
|
SleepLogEntity::class,
|
||||||
|
CyclePeriodEntity::class,
|
||||||
|
HealthRecordEntity::class,
|
||||||
|
CalorieEntity::class,
|
||||||
|
StepsEntity::class,
|
||||||
|
UserProfileEntity::class
|
||||||
|
],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
@TypeConverters(DateConverters::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 calorieDao(): CalorieDao
|
||||||
|
abstract fun stepsDao(): StepsDao
|
||||||
|
abstract fun userProfileDao(): UserProfileDao
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.converter
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
class DateConverters {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromLocalDate(date: LocalDate?): String? {
|
||||||
|
return date?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toLocalDate(dateString: String?): LocalDate? {
|
||||||
|
return dateString?.let { LocalDate.parse(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
144
app/src/main/java/kr/smartsoltech/wellshe/data/dao/Daos.kt
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
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")
|
||||||
|
suspend fun getSleepForDate(date: LocalDate): SleepLogEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sleep_logs ORDER BY date DESC LIMIT 7")
|
||||||
|
fun getRecentSleepLogs(): Flow<List<SleepLogEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertSleepLog(sleepLog: SleepLogEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateSleepLog(sleepLog: SleepLogEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteSleepLog(sleepLog: SleepLogEntity)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sleep_logs WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
||||||
|
fun getSleepLogsForPeriod(startDate: LocalDate, endDate: LocalDate): Flow<List<SleepLogEntity>>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WorkoutDao {
|
||||||
|
@Query("SELECT * FROM workouts WHERE date = :date ORDER BY id DESC")
|
||||||
|
fun getWorkoutsForDate(date: LocalDate): Flow<List<WorkoutEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM workouts ORDER BY date DESC LIMIT 10")
|
||||||
|
fun getRecentWorkouts(): Flow<List<WorkoutEntity>>
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
suspend fun insertWorkout(workout: WorkoutEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateWorkout(workout: WorkoutEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteWorkout(workout: WorkoutEntity)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM workouts WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
||||||
|
fun getWorkoutsForPeriod(startDate: LocalDate, endDate: LocalDate): Flow<List<WorkoutEntity>>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CalorieDao {
|
||||||
|
@Query("SELECT * FROM calories WHERE date = :date")
|
||||||
|
suspend fun getCaloriesForDate(date: LocalDate): CalorieEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calories ORDER BY date DESC LIMIT 30")
|
||||||
|
fun getRecentCalories(): Flow<List<CalorieEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertCalorieRecord(calorie: CalorieEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateCalorieRecord(calorie: CalorieEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteCalorieRecord(calorie: CalorieEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface StepsDao {
|
||||||
|
@Query("SELECT * FROM steps WHERE date = :date")
|
||||||
|
suspend fun getStepsForDate(date: LocalDate): StepsEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM steps ORDER BY date DESC LIMIT 30")
|
||||||
|
fun getRecentSteps(): Flow<List<StepsEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertStepsRecord(steps: StepsEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateStepsRecord(steps: StepsEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteStepsRecord(steps: StepsEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface UserProfileDao {
|
||||||
|
@Query("SELECT * FROM user_profile WHERE id = 1")
|
||||||
|
suspend fun getUserProfile(): UserProfileEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM user_profile WHERE id = 1")
|
||||||
|
fun getUserProfileFlow(): Flow<UserProfileEntity?>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertUserProfile(profile: UserProfileEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateUserProfile(profile: UserProfileEntity)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertHealthRecord(record: HealthRecordEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateHealthRecord(record: HealthRecordEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteHealthRecord(record: HealthRecordEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM health_records WHERE id = :id")
|
||||||
|
suspend fun deleteHealthRecordById(id: Long)
|
||||||
|
|
||||||
|
@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 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?
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.datastore
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.*
|
||||||
|
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.dataStore: DataStore<Preferences> by preferencesDataStore(name = "wellshe_preferences")
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class DataStoreManager @Inject constructor(
|
||||||
|
private val context: Context
|
||||||
|
) {
|
||||||
|
private val dataStore = context.dataStore
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val WATER_GOAL_KEY = intPreferencesKey("water_goal")
|
||||||
|
val THEME_KEY = stringPreferencesKey("selected_theme")
|
||||||
|
val NOTIFICATIONS_ENABLED_KEY = booleanPreferencesKey("notifications_enabled")
|
||||||
|
val CYCLE_LENGTH_KEY = intPreferencesKey("average_cycle_length")
|
||||||
|
val PERIOD_LENGTH_KEY = intPreferencesKey("average_period_length")
|
||||||
|
val USER_NAME_KEY = stringPreferencesKey("user_name")
|
||||||
|
val USER_AGE_KEY = intPreferencesKey("user_age")
|
||||||
|
val FIRST_LAUNCH_KEY = booleanPreferencesKey("first_launch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Water goal
|
||||||
|
suspend fun setWaterGoal(goal: Int) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[WATER_GOAL_KEY] = goal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getWaterGoal(): Flow<Int> = dataStore.data.map { preferences ->
|
||||||
|
preferences[WATER_GOAL_KEY] ?: 2000 // Default 2L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
suspend fun setTheme(theme: String) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[THEME_KEY] = theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTheme(): Flow<String> = dataStore.data.map { preferences ->
|
||||||
|
preferences[THEME_KEY] ?: "pink"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
suspend fun setNotificationsEnabled(enabled: Boolean) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[NOTIFICATIONS_ENABLED_KEY] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNotificationsEnabled(): Flow<Boolean> = dataStore.data.map { preferences ->
|
||||||
|
preferences[NOTIFICATIONS_ENABLED_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle settings
|
||||||
|
suspend fun setCycleLength(length: Int) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[CYCLE_LENGTH_KEY] = length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCycleLength(): Flow<Int> = dataStore.data.map { preferences ->
|
||||||
|
preferences[CYCLE_LENGTH_KEY] ?: 28
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setPeriodLength(length: Int) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[PERIOD_LENGTH_KEY] = length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPeriodLength(): Flow<Int> = dataStore.data.map { preferences ->
|
||||||
|
preferences[PERIOD_LENGTH_KEY] ?: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// User info
|
||||||
|
suspend fun setUserName(name: String) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[USER_NAME_KEY] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUserName(): Flow<String> = dataStore.data.map { preferences ->
|
||||||
|
preferences[USER_NAME_KEY] ?: "Пользователь"
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setUserAge(age: Int) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[USER_AGE_KEY] = age
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUserAge(): Flow<Int> = dataStore.data.map { preferences ->
|
||||||
|
preferences[USER_AGE_KEY] ?: 25
|
||||||
|
}
|
||||||
|
|
||||||
|
// First launch
|
||||||
|
suspend fun setFirstLaunch(isFirst: Boolean) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[FIRST_LAUNCH_KEY] = isFirst
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isFirstLaunch(): Flow<Boolean> = dataStore.data.map { preferences ->
|
||||||
|
preferences[FIRST_LAUNCH_KEY] ?: true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Entity(tableName = "cycle_stats")
|
||||||
|
data class CycleStatsEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
val cycleId: Long,
|
||||||
|
val startDate: LocalDate,
|
||||||
|
val endDate: LocalDate?,
|
||||||
|
val cycleLength: Int,
|
||||||
|
val periodLength: Int,
|
||||||
|
val startTs: Long, // timestamp начала
|
||||||
|
val endTs: Long?, // timestamp окончания
|
||||||
|
val averageFlow: String = "medium",
|
||||||
|
val commonSymptoms: String = "", // JSON строка симптомов
|
||||||
|
val averageMood: String = "neutral",
|
||||||
|
val createdAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Entity(tableName = "water_logs")
|
||||||
|
data class WaterLogEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
val date: LocalDate,
|
||||||
|
val amount: Int, // мл
|
||||||
|
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)
|
||||||
|
val id: Long = 0,
|
||||||
|
val date: LocalDate,
|
||||||
|
val bedTime: String, // HH:mm
|
||||||
|
val wakeTime: String, // HH:mm
|
||||||
|
val duration: Float, // часы
|
||||||
|
val quality: String = "good", // poor, fair, good, excellent
|
||||||
|
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)
|
||||||
|
val id: Long = 0,
|
||||||
|
val date: LocalDate,
|
||||||
|
val type: String, // cardio, strength, yoga, etc.
|
||||||
|
val name: String,
|
||||||
|
val duration: Int, // минуты
|
||||||
|
val caloriesBurned: Int = 0,
|
||||||
|
val intensity: String = "moderate", // low, moderate, high, intense
|
||||||
|
val notes: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "calories")
|
||||||
|
data class CalorieEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
val date: LocalDate,
|
||||||
|
val consumed: Int = 0, // потребленные калории
|
||||||
|
val burned: Int = 0, // сожженные калории
|
||||||
|
val target: Int = 2000 // целевые калории
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "steps")
|
||||||
|
data class StepsEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
val date: LocalDate,
|
||||||
|
val steps: Int = 0,
|
||||||
|
val distance: Float = 0f, // км
|
||||||
|
val caloriesBurned: Int = 0,
|
||||||
|
val target: Int = 10000
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "user_profile")
|
||||||
|
data class UserProfileEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: Long = 1, // всегда один профиль
|
||||||
|
val name: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val age: Int = 0,
|
||||||
|
val height: Int = 0, // см
|
||||||
|
val weight: Float = 0f, // кг
|
||||||
|
val targetWeight: Float = 0f,
|
||||||
|
val activityLevel: String = "moderate", // sedentary, light, moderate, active, very_active
|
||||||
|
val dailyWaterGoal: Int = 2000, // мл
|
||||||
|
val dailyCalorieGoal: Int = 2000,
|
||||||
|
val dailyStepsGoal: Int = 10000,
|
||||||
|
val cycleLength: Int = 28,
|
||||||
|
val periodLength: Int = 5,
|
||||||
|
val lastPeriodDate: LocalDate? = null,
|
||||||
|
val profileImagePath: String = ""
|
||||||
|
)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repo
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.Workout
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class TestDataProvider @Inject constructor() {
|
||||||
|
|
||||||
|
fun getDefaultWorkouts(): List<Workout> = listOf(
|
||||||
|
// Йога
|
||||||
|
Workout(
|
||||||
|
id = 1,
|
||||||
|
name = "Утренняя йога",
|
||||||
|
durationMin = 15,
|
||||||
|
calories = 50,
|
||||||
|
gifAsset = "yoga_morning"
|
||||||
|
),
|
||||||
|
Workout(
|
||||||
|
id = 2,
|
||||||
|
name = "Йога для расслабления",
|
||||||
|
durationMin = 20,
|
||||||
|
calories = 70,
|
||||||
|
gifAsset = "yoga_relax"
|
||||||
|
),
|
||||||
|
Workout(
|
||||||
|
id = 3,
|
||||||
|
name = "Силовая йога",
|
||||||
|
durationMin = 30,
|
||||||
|
calories = 120,
|
||||||
|
gifAsset = "yoga_power"
|
||||||
|
),
|
||||||
|
|
||||||
|
// Кардио
|
||||||
|
Workout(
|
||||||
|
id = 4,
|
||||||
|
name = "HIIT тренировка",
|
||||||
|
durationMin = 20,
|
||||||
|
calories = 200,
|
||||||
|
gifAsset = "hiit_workout"
|
||||||
|
),
|
||||||
|
Workout(
|
||||||
|
id = 5,
|
||||||
|
name = "Танцевальная аэробика",
|
||||||
|
durationMin = 30,
|
||||||
|
calories = 150,
|
||||||
|
gifAsset = "dance_aerobics"
|
||||||
|
),
|
||||||
|
Workout(
|
||||||
|
id = 6,
|
||||||
|
name = "Прыжки на скакалке",
|
||||||
|
durationMin = 15,
|
||||||
|
calories = 180,
|
||||||
|
gifAsset = "jump_rope"
|
||||||
|
),
|
||||||
|
|
||||||
|
// Силовые
|
||||||
|
Workout(
|
||||||
|
id = 7,
|
||||||
|
name = "Тренировка ног",
|
||||||
|
durationMin = 25,
|
||||||
|
calories = 100,
|
||||||
|
gifAsset = "leg_workout"
|
||||||
|
),
|
||||||
|
Workout(
|
||||||
|
id = 8,
|
||||||
|
name = "Руки и плечи",
|
||||||
|
durationMin = 20,
|
||||||
|
calories = 90,
|
||||||
|
gifAsset = "arms_workout"
|
||||||
|
),
|
||||||
|
Workout(
|
||||||
|
id = 9,
|
||||||
|
name = "Пресс",
|
||||||
|
durationMin = 15,
|
||||||
|
calories = 80,
|
||||||
|
gifAsset = "abs_workout"
|
||||||
|
),
|
||||||
|
|
||||||
|
// Растяжка
|
||||||
|
Workout(
|
||||||
|
id = 10,
|
||||||
|
name = "Утренняя растяжка",
|
||||||
|
durationMin = 10,
|
||||||
|
calories = 30,
|
||||||
|
gifAsset = "morning_stretch"
|
||||||
|
),
|
||||||
|
Workout(
|
||||||
|
id = 11,
|
||||||
|
name = "Растяжка спины",
|
||||||
|
durationMin = 15,
|
||||||
|
calories = 40,
|
||||||
|
gifAsset = "back_stretch"
|
||||||
|
),
|
||||||
|
Workout(
|
||||||
|
id = 12,
|
||||||
|
name = "Полная растяжка",
|
||||||
|
durationMin = 25,
|
||||||
|
calories = 60,
|
||||||
|
gifAsset = "full_stretch"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun initializeDefaultWorkouts(repository: WellSheRepository) {
|
||||||
|
val workouts = getDefaultWorkouts()
|
||||||
|
workouts.forEach { workout ->
|
||||||
|
repository.addWorkout(workout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repo
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.*
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.*
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.*
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class WellSheRepository @Inject constructor(
|
||||||
|
private val waterLogDao: WaterLogDao,
|
||||||
|
private val workoutDao: WorkoutDao,
|
||||||
|
private val workoutSessionDao: WorkoutSessionDao,
|
||||||
|
private val sleepLogDao: SleepLogDao,
|
||||||
|
private val postureEventDao: PostureEventDao,
|
||||||
|
private val cyclePeriodDao: CyclePeriodDao,
|
||||||
|
private val cycleSymptomDao: CycleSymptomDao,
|
||||||
|
private val cycleStatsDao: CycleStatsDao,
|
||||||
|
private val settingDao: SettingDao
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Water Tracking
|
||||||
|
suspend fun addWaterLog(amountMl: Int) {
|
||||||
|
val entity = WaterLogEntity(
|
||||||
|
ts = System.currentTimeMillis(),
|
||||||
|
amountMl = amountMl
|
||||||
|
)
|
||||||
|
waterLogDao.insert(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getWaterLogsFlow(): Flow<List<WaterLog>> = waterLogDao.getAllFlow().map { entities ->
|
||||||
|
entities.map { it.toDomainModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getTodayWaterIntake(): Int {
|
||||||
|
val startOfDay = System.currentTimeMillis() - (System.currentTimeMillis() % (24 * 60 * 60 * 1000))
|
||||||
|
val endOfDay = startOfDay + (24 * 60 * 60 * 1000)
|
||||||
|
return waterLogDao.getTotalForPeriod(startOfDay, endOfDay) ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workouts
|
||||||
|
suspend fun addWorkout(workout: Workout) {
|
||||||
|
workoutDao.insert(workout.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getWorkoutsFlow(): Flow<List<Workout>> = workoutDao.getAllFlow().map { entities ->
|
||||||
|
entities.map { it.toDomainModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startWorkoutSession(workoutId: Long) {
|
||||||
|
val session = WorkoutSessionEntity(
|
||||||
|
workoutId = workoutId,
|
||||||
|
ts = System.currentTimeMillis(),
|
||||||
|
completed = false
|
||||||
|
)
|
||||||
|
workoutSessionDao.insert(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun completeWorkoutSession(sessionId: Long) {
|
||||||
|
// Implementation would require getting the session and updating it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep Tracking
|
||||||
|
suspend fun startSleepTracking(): Long {
|
||||||
|
val sleepLog = SleepLogEntity(
|
||||||
|
startTs = System.currentTimeMillis(),
|
||||||
|
endTs = null,
|
||||||
|
quality = 3
|
||||||
|
)
|
||||||
|
sleepLogDao.insert(sleepLog)
|
||||||
|
return sleepLog.id
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun endSleepTracking(quality: SleepQuality) {
|
||||||
|
val currentSession = sleepLogDao.getCurrentSleepSession()
|
||||||
|
currentSession?.let { session ->
|
||||||
|
val updatedSession = session.copy(
|
||||||
|
endTs = System.currentTimeMillis(),
|
||||||
|
quality = quality.value
|
||||||
|
)
|
||||||
|
sleepLogDao.update(updatedSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSleepLogsFlow(): Flow<List<SleepLog>> = sleepLogDao.getAllFlow().map { entities ->
|
||||||
|
entities.map { it.toDomainModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle Tracking
|
||||||
|
suspend fun startPeriod(notes: String? = null): Long {
|
||||||
|
val period = CyclePeriodEntity(
|
||||||
|
startTs = System.currentTimeMillis(),
|
||||||
|
endTs = null,
|
||||||
|
notes = notes
|
||||||
|
)
|
||||||
|
cyclePeriodDao.insert(period)
|
||||||
|
return period.id
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun endPeriod() {
|
||||||
|
val currentPeriod = cyclePeriodDao.getCurrentPeriod()
|
||||||
|
currentPeriod?.let { period ->
|
||||||
|
val updatedPeriod = period.copy(endTs = System.currentTimeMillis())
|
||||||
|
cyclePeriodDao.update(updatedPeriod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addSymptom(symptom: SymptomType, mood: MoodType? = null) {
|
||||||
|
val currentPeriod = cyclePeriodDao.getCurrentPeriod()
|
||||||
|
currentPeriod?.let { period ->
|
||||||
|
val symptomEntity = CycleSymptomEntity(
|
||||||
|
periodId = period.id,
|
||||||
|
ts = System.currentTimeMillis(),
|
||||||
|
symptom = symptom.name,
|
||||||
|
mood = mood?.name
|
||||||
|
)
|
||||||
|
cycleSymptomDao.insert(symptomEntity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCyclePeriodsFlow(): Flow<List<CyclePeriod>> = cyclePeriodDao.getAllFlow().map { entities ->
|
||||||
|
entities.map { it.toDomainModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posture Tracking
|
||||||
|
suspend fun addPostureEvent(angle: Float, exceeded: Boolean) {
|
||||||
|
val event = PostureEventEntity(
|
||||||
|
ts = System.currentTimeMillis(),
|
||||||
|
angle = angle,
|
||||||
|
exceeded = exceeded
|
||||||
|
)
|
||||||
|
postureEventDao.insert(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getTodayPostureEvents(): List<PostureEvent> {
|
||||||
|
val startOfDay = System.currentTimeMillis() - (System.currentTimeMillis() % (24 * 60 * 60 * 1000))
|
||||||
|
val endOfDay = startOfDay + (24 * 60 * 60 * 1000)
|
||||||
|
return postureEventDao.getEventsForPeriod(startOfDay, endOfDay).map { it.toDomainModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
suspend fun saveSetting(key: String, value: String) {
|
||||||
|
settingDao.insert(SettingEntity(key, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getSetting(key: String): String? {
|
||||||
|
return settingDao.getByKey(key)?.valueJson
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension functions for mapping between entities and domain models
|
||||||
|
private fun WaterLogEntity.toDomainModel() = WaterLog(
|
||||||
|
id = id,
|
||||||
|
timestamp = LocalDateTime.ofInstant(Instant.ofEpochMilli(ts), ZoneId.systemDefault()),
|
||||||
|
amountMl = amountMl
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun WorkoutEntity.toDomainModel() = Workout(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
durationMin = durationMin,
|
||||||
|
calories = calories,
|
||||||
|
gifAsset = gifAsset
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Workout.toEntity() = WorkoutEntity(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
durationMin = durationMin,
|
||||||
|
calories = calories,
|
||||||
|
gifAsset = gifAsset
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun SleepLogEntity.toDomainModel() = SleepLog(
|
||||||
|
id = id,
|
||||||
|
startTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(startTs), ZoneId.systemDefault()),
|
||||||
|
endTime = endTs?.let { LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) },
|
||||||
|
quality = SleepQuality.values().find { it.value == quality } ?: SleepQuality.FAIR
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CyclePeriodEntity.toDomainModel() = CyclePeriod(
|
||||||
|
id = id,
|
||||||
|
startDate = LocalDateTime.ofInstant(Instant.ofEpochMilli(startTs), ZoneId.systemDefault()).toLocalDate(),
|
||||||
|
endDate = endTs?.let { LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()).toLocalDate() },
|
||||||
|
notes = notes
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun PostureEventEntity.toDomainModel() = PostureEvent(
|
||||||
|
id = id,
|
||||||
|
timestamp = LocalDateTime.ofInstant(Instant.ofEpochMilli(ts), ZoneId.systemDefault()),
|
||||||
|
angle = angle,
|
||||||
|
exceeded = exceeded
|
||||||
|
)
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repository
|
||||||
|
|
||||||
|
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 java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.LocalTime
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class WellSheRepository @Inject constructor(
|
||||||
|
private val waterLogDao: WaterLogDao,
|
||||||
|
private val cyclePeriodDao: CyclePeriodDao,
|
||||||
|
private val sleepLogDao: SleepLogDao,
|
||||||
|
private val healthRecordDao: HealthRecordDao,
|
||||||
|
private val workoutDao: WorkoutDao,
|
||||||
|
private val calorieDao: CalorieDao,
|
||||||
|
private val stepsDao: StepsDao,
|
||||||
|
private val userProfileDao: UserProfileDao
|
||||||
|
) {
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ
|
||||||
|
// =================
|
||||||
|
|
||||||
|
fun getUserProfile(): Flow<User> {
|
||||||
|
// TODO: Реализовать получение профиля пользователя из БД
|
||||||
|
return flowOf(
|
||||||
|
User(
|
||||||
|
id = 1,
|
||||||
|
name = "Пользователь",
|
||||||
|
email = "user@example.com",
|
||||||
|
age = 25,
|
||||||
|
height = 165f,
|
||||||
|
weight = 60f,
|
||||||
|
dailyWaterGoal = 2.5f,
|
||||||
|
dailyStepsGoal = 10000,
|
||||||
|
dailyCaloriesGoal = 2000,
|
||||||
|
dailySleepGoal = 8.0f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateUserProfile(user: User) {
|
||||||
|
// TODO: Реализовать обновление профиля пользователя
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// ВОДНЫЙ БАЛАНС
|
||||||
|
// =================
|
||||||
|
|
||||||
|
suspend fun addWaterIntake(waterIntake: WaterIntake) {
|
||||||
|
waterLogDao.insertWaterLog(
|
||||||
|
WaterLogEntity(
|
||||||
|
date = waterIntake.date,
|
||||||
|
amount = (waterIntake.amount * 1000).toInt() // конвертируем в мл
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeWaterIntake(id: Long) {
|
||||||
|
// TODO: Реализовать удаление записи о воде
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getWaterIntakeForDate(date: LocalDate): Flow<List<WaterIntake>> {
|
||||||
|
return waterLogDao.getWaterLogsForDate(date).map { entities ->
|
||||||
|
entities.map { entity ->
|
||||||
|
WaterIntake(
|
||||||
|
id = entity.id,
|
||||||
|
date = entity.date,
|
||||||
|
time = LocalTime.ofInstant(
|
||||||
|
java.time.Instant.ofEpochMilli(entity.timestamp),
|
||||||
|
java.time.ZoneId.systemDefault()
|
||||||
|
),
|
||||||
|
amount = entity.amount / 1000f // конвертируем в литры
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getWaterIntakeForDateSync(date: LocalDate): List<WaterIntake> {
|
||||||
|
// TODO: Реализовать синхронное получение данных
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateWaterGoal(goal: Float) {
|
||||||
|
// TODO: Реализовать обновление цели по воде
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// ФИТНЕС И ШАГИ
|
||||||
|
// =================
|
||||||
|
|
||||||
|
fun getFitnessDataForDate(date: LocalDate): Flow<FitnessData> {
|
||||||
|
// TODO: Реализовать получение фитнес данных
|
||||||
|
return flowOf(
|
||||||
|
FitnessData(
|
||||||
|
id = 1,
|
||||||
|
date = date,
|
||||||
|
steps = 5000,
|
||||||
|
distance = 4.0f,
|
||||||
|
caloriesBurned = 200,
|
||||||
|
activeMinutes = 45
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getFitnessDataForDateSync(date: LocalDate): FitnessData {
|
||||||
|
// TODO: Реализовать синхронное получение фитнес данных
|
||||||
|
return FitnessData(
|
||||||
|
id = 1,
|
||||||
|
date = date,
|
||||||
|
steps = 0,
|
||||||
|
distance = 0f,
|
||||||
|
caloriesBurned = 0,
|
||||||
|
activeMinutes = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateTodaySteps(steps: Int) {
|
||||||
|
// TODO: Реализовать обновление шагов
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startStepTracking() {
|
||||||
|
// TODO: Реализовать запуск отслеживания шагов
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun stopStepTracking() {
|
||||||
|
// TODO: Реализовать остановку отслеживания шагов
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// ТРЕНИРОВКИ
|
||||||
|
// =================
|
||||||
|
|
||||||
|
fun getRecentWorkouts(): Flow<List<WorkoutSession>> {
|
||||||
|
// TODO: Реализовать получение последних тренировок
|
||||||
|
return flowOf(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startWorkout(workout: WorkoutSession) {
|
||||||
|
// TODO: Реализовать начало тренировки
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun endWorkout(workoutId: Long, duration: Int, caloriesBurned: Int, distance: Float) {
|
||||||
|
// TODO: Реализовать окончание тренировки
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// СОН
|
||||||
|
// =================
|
||||||
|
|
||||||
|
suspend fun getSleepForDate(date: LocalDate): SleepLogEntity? {
|
||||||
|
return sleepLogDao.getSleepForDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRecentSleepLogs(): Flow<List<SleepLogEntity>> {
|
||||||
|
return sleepLogDao.getRecentSleepLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addSleepRecord(date: LocalDate, bedTime: String, wakeTime: String, quality: String, notes: String) {
|
||||||
|
// Вычисляем продолжительность сна
|
||||||
|
val duration = calculateSleepDuration(bedTime, wakeTime)
|
||||||
|
|
||||||
|
sleepLogDao.insertSleepLog(
|
||||||
|
SleepLogEntity(
|
||||||
|
date = date,
|
||||||
|
bedTime = bedTime,
|
||||||
|
wakeTime = wakeTime,
|
||||||
|
duration = duration,
|
||||||
|
quality = quality,
|
||||||
|
notes = notes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateSleepDuration(bedTime: String, wakeTime: String): Float {
|
||||||
|
// TODO: Реализовать правильный расчет продолжительности сна
|
||||||
|
return 8.0f
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// МЕНСТРУАЛЬНЫЙ ЦИКЛ
|
||||||
|
// =================
|
||||||
|
|
||||||
|
suspend fun addPeriod(startDate: LocalDate, endDate: LocalDate?, flow: String, symptoms: List<String>, mood: String) {
|
||||||
|
cyclePeriodDao.insertPeriod(
|
||||||
|
CyclePeriodEntity(
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate,
|
||||||
|
flow = flow,
|
||||||
|
symptoms = symptoms.joinToString(","),
|
||||||
|
mood = mood
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentCyclePeriod(): Flow<CyclePeriodEntity?> {
|
||||||
|
return cyclePeriodDao.getCurrentPeriod()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRecentPeriods(): Flow<List<CyclePeriodEntity>> {
|
||||||
|
return cyclePeriodDao.getRecentPeriods(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// НАСТРОЙКИ
|
||||||
|
// =================
|
||||||
|
|
||||||
|
fun getSettings(): Flow<AppSettings> {
|
||||||
|
// TODO: Реализовать получение настроек из БД
|
||||||
|
return flowOf(
|
||||||
|
AppSettings(
|
||||||
|
isWaterReminderEnabled = true,
|
||||||
|
isCycleReminderEnabled = true,
|
||||||
|
isSleepReminderEnabled = true,
|
||||||
|
cycleLength = 28,
|
||||||
|
periodLength = 5,
|
||||||
|
waterGoal = 2.5f,
|
||||||
|
stepsGoal = 10000,
|
||||||
|
sleepGoal = 8.0f,
|
||||||
|
isDarkTheme = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateWaterReminderSetting(enabled: Boolean) {
|
||||||
|
// TODO: Реализовать обновление настройки напоминаний о воде
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateCycleReminderSetting(enabled: Boolean) {
|
||||||
|
// TODO: Реализовать обновление настройки напоминаний о цикле
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateSleepReminderSetting(enabled: Boolean) {
|
||||||
|
// TODO: Реализовать обновление настройки напоминаний о сне
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateCycleLength(length: Int) {
|
||||||
|
// TODO: Реализовать обновление длины цикла
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updatePeriodLength(length: Int) {
|
||||||
|
// TODO: Реализовать обновление длины менструации
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateStepsGoal(goal: Int) {
|
||||||
|
// TODO: Реализовать обновление цели по шагам
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateSleepGoal(goal: Float) {
|
||||||
|
// TODO: Реализовать обновление цели по сну
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateThemeSetting(isDark: Boolean) {
|
||||||
|
// TODO: Реализовать обновление темы
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// УПРАВЛЕНИЕ ДАННЫМИ
|
||||||
|
// =================
|
||||||
|
|
||||||
|
suspend fun exportUserData() {
|
||||||
|
// TODO: Реализовать экспорт данных пользователя
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importUserData() {
|
||||||
|
// TODO: Реализовать импорт данных пользователя
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearAllUserData() {
|
||||||
|
// TODO: Реализовать очистку всех данных пользователя
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// ЗДОРОВЬЕ
|
||||||
|
// =================
|
||||||
|
|
||||||
|
fun getTodayHealthData(): Flow<HealthRecordEntity?> {
|
||||||
|
// TODO: Реализовать получение данных о здоровье за сегодня
|
||||||
|
return flowOf(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateHealthRecord(record: HealthRecord) {
|
||||||
|
// TODO: Реализовать обновление записи о здоровье
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// DASHBOARD
|
||||||
|
// =================
|
||||||
|
|
||||||
|
fun getDashboardData(): Flow<DashboardData> {
|
||||||
|
// TODO: Реализовать получение данных для главного экрана
|
||||||
|
return flowOf(
|
||||||
|
DashboardData(
|
||||||
|
user = User(),
|
||||||
|
todayHealth = null,
|
||||||
|
sleepData = null,
|
||||||
|
cycleData = null,
|
||||||
|
recentWorkouts = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// УСТАРЕВШИЕ МЕТОДЫ (для совместимости)
|
||||||
|
// =================
|
||||||
|
|
||||||
|
suspend fun addWater(amount: Int, date: LocalDate = LocalDate.now()) {
|
||||||
|
waterLogDao.insertWaterLog(
|
||||||
|
WaterLogEntity(date = date, amount = amount)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getTodayWaterIntake(date: LocalDate = LocalDate.now()): Int {
|
||||||
|
return waterLogDao.getTotalWaterForDate(date) ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getWaterLogsForDate(date: LocalDate): Flow<List<WaterLogEntity>> {
|
||||||
|
return waterLogDao.getWaterLogsForDate(date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательные data классы
|
||||||
|
data class DashboardData(
|
||||||
|
val user: User,
|
||||||
|
val todayHealth: HealthRecord?,
|
||||||
|
val sleepData: SleepLogEntity?,
|
||||||
|
val cycleData: CyclePeriodEntity?,
|
||||||
|
val recentWorkouts: List<WorkoutSession>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HealthRecord(
|
||||||
|
val id: Long = 0,
|
||||||
|
val date: LocalDate,
|
||||||
|
val bloodPressureSystolic: Int = 0,
|
||||||
|
val bloodPressureDiastolic: Int = 0,
|
||||||
|
val heartRate: Int = 0,
|
||||||
|
val weight: Float = 0f,
|
||||||
|
val mood: String = "neutral", // Добавляем поле настроения
|
||||||
|
val energyLevel: Int = 5, // Добавляем уровень энергии (1-10)
|
||||||
|
val stressLevel: Int = 5, // Добавляем уровень стресса (1-10)
|
||||||
|
val notes: String = ""
|
||||||
|
)
|
||||||
75
app/src/main/java/kr/smartsoltech/wellshe/di/AppModule.kt
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package kr.smartsoltech.wellshe.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
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.AppDatabase
|
||||||
|
import kr.smartsoltech.wellshe.data.datastore.DataStoreManager
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.*
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AppModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDataStoreManager(@ApplicationContext context: Context): DataStoreManager =
|
||||||
|
DataStoreManager(context)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||||
|
Room.databaseBuilder(
|
||||||
|
context,
|
||||||
|
AppDatabase::class.java,
|
||||||
|
"well_she_db"
|
||||||
|
).fallbackToDestructiveMigration().build()
|
||||||
|
|
||||||
|
// DAO providers
|
||||||
|
@Provides
|
||||||
|
fun provideWaterLogDao(database: AppDatabase): WaterLogDao = database.waterLogDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideCyclePeriodDao(database: AppDatabase): CyclePeriodDao = database.cyclePeriodDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideSleepLogDao(database: AppDatabase): SleepLogDao = database.sleepLogDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideHealthRecordDao(database: AppDatabase): HealthRecordDao = database.healthRecordDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideWorkoutDao(database: AppDatabase): WorkoutDao = database.workoutDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideCalorieDao(database: AppDatabase): CalorieDao = database.calorieDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideStepsDao(database: AppDatabase): StepsDao = database.stepsDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao()
|
||||||
|
|
||||||
|
// Repository
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideWellSheRepository(
|
||||||
|
waterLogDao: WaterLogDao,
|
||||||
|
cyclePeriodDao: CyclePeriodDao,
|
||||||
|
sleepLogDao: SleepLogDao,
|
||||||
|
healthRecordDao: HealthRecordDao,
|
||||||
|
workoutDao: WorkoutDao,
|
||||||
|
calorieDao: CalorieDao,
|
||||||
|
stepsDao: StepsDao,
|
||||||
|
userProfileDao: UserProfileDao
|
||||||
|
): kr.smartsoltech.wellshe.data.repository.WellSheRepository =
|
||||||
|
kr.smartsoltech.wellshe.data.repository.WellSheRepository(
|
||||||
|
waterLogDao, cyclePeriodDao, sleepLogDao, healthRecordDao,
|
||||||
|
workoutDao, calorieDao, stepsDao, userProfileDao
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.analytics
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
object CycleAnalytics {
|
||||||
|
/**
|
||||||
|
* Прогноз следующей менструации и фертильного окна
|
||||||
|
* @param periods список последних периодов
|
||||||
|
* @param stats статистика цикла (вычисляется автоматически)
|
||||||
|
* @return прогноз: дата, фертильное окно, доверие
|
||||||
|
*/
|
||||||
|
fun forecast(periods: List<CyclePeriodEntity>, stats: CycleStats? = null): CycleForecast {
|
||||||
|
if (periods.isEmpty()) return CycleForecast(null, null, "низкая")
|
||||||
|
|
||||||
|
val calculatedStats = stats ?: calculateStats(periods)
|
||||||
|
val lastPeriod = periods.first()
|
||||||
|
val lastStartDate = lastPeriod.startDate
|
||||||
|
val lastStartTs = lastStartDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000
|
||||||
|
|
||||||
|
val avgCycle = calculatedStats.avgCycle
|
||||||
|
val variance = calculatedStats.variance
|
||||||
|
val lutealLen = calculatedStats.lutealLen
|
||||||
|
|
||||||
|
val nextStart = lastStartTs + avgCycle * 24 * 60 * 60 * 1000L
|
||||||
|
val confidence = when {
|
||||||
|
variance < 2 -> "высокая"
|
||||||
|
variance < 5 -> "средняя"
|
||||||
|
else -> "низкая"
|
||||||
|
}
|
||||||
|
val ovulation = nextStart - lutealLen * 24 * 60 * 60 * 1000L
|
||||||
|
val fertileStart = ovulation - 2 * 24 * 60 * 60 * 1000L
|
||||||
|
val fertileEnd = ovulation + 1 * 24 * 60 * 60 * 1000L
|
||||||
|
return CycleForecast(
|
||||||
|
nextStart,
|
||||||
|
fertileStart to fertileEnd,
|
||||||
|
confidence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вычисляет статистику цикла на основе периодов
|
||||||
|
*/
|
||||||
|
private fun calculateStats(periods: List<CyclePeriodEntity>): CycleStats {
|
||||||
|
if (periods.size < 2) {
|
||||||
|
return CycleStats(avgCycle = 28, variance = 5.0, lutealLen = 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
val avgCycle = cycleLengths.average().toInt()
|
||||||
|
val variance = cycleLengths.map { (it - avgCycle) * (it - avgCycle) }.average()
|
||||||
|
|
||||||
|
return CycleStats(
|
||||||
|
avgCycle = avgCycle,
|
||||||
|
variance = variance,
|
||||||
|
lutealLen = 14 // стандартная лютеиновая фаза
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CycleForecast(
|
||||||
|
val nextStart: Long?,
|
||||||
|
val fertileWindow: Pair<Long, Long>?,
|
||||||
|
val confidence: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CycleStats(
|
||||||
|
val avgCycle: Int,
|
||||||
|
val variance: Double,
|
||||||
|
val lutealLen: Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.analytics
|
||||||
|
|
||||||
|
object PostureAnalytics {
|
||||||
|
/**
|
||||||
|
* Проверка превышения угла
|
||||||
|
*/
|
||||||
|
fun isExceeded(baseAngle: Float, currentAngle: Float, threshold: Float): Boolean {
|
||||||
|
return kotlin.math.abs(currentAngle - baseAngle) > threshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.analytics
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.SleepLogEntity
|
||||||
|
|
||||||
|
object SleepAnalytics {
|
||||||
|
/**
|
||||||
|
* Расчёт долга сна и недельного тренда
|
||||||
|
*/
|
||||||
|
fun sleepDebt(logs: List<SleepLogEntity>, targetHours: Int = 8): Int {
|
||||||
|
val total = logs.sumOf { it.duration.toDouble() }
|
||||||
|
val expected = logs.size * targetHours
|
||||||
|
return (expected - total).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.analytics
|
||||||
|
|
||||||
|
object WaterAnalytics {
|
||||||
|
/**
|
||||||
|
* Адаптивная цель по формуле: вес × 30 мл ± коэф. активности
|
||||||
|
*/
|
||||||
|
fun calcGoal(weightKg: Int, activityCoef: Float = 0f): Int {
|
||||||
|
val base = weightKg * 30
|
||||||
|
return (base + activityCoef * 200).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.model
|
||||||
|
|
||||||
|
data class AppSettings(
|
||||||
|
val id: Long = 0,
|
||||||
|
val isWaterReminderEnabled: Boolean = true,
|
||||||
|
val waterReminderInterval: Int = 2, // часы
|
||||||
|
val isCycleReminderEnabled: Boolean = true,
|
||||||
|
val isSleepReminderEnabled: Boolean = true,
|
||||||
|
val sleepReminderTime: String = "22:00",
|
||||||
|
val wakeUpReminderTime: String = "07:00",
|
||||||
|
val cycleLength: Int = 28,
|
||||||
|
val periodLength: Int = 5,
|
||||||
|
val waterGoal: Float = 2.5f,
|
||||||
|
val stepsGoal: Int = 10000,
|
||||||
|
val sleepGoal: Float = 8.0f,
|
||||||
|
val isDarkTheme: Boolean = false,
|
||||||
|
val language: String = "ru",
|
||||||
|
val isFirstLaunch: Boolean = true
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
data class FitnessData(
|
||||||
|
val id: Long = 0,
|
||||||
|
val date: LocalDate,
|
||||||
|
val steps: Int = 0,
|
||||||
|
val distance: Float = 0f, // в километрах
|
||||||
|
val caloriesBurned: Int = 0,
|
||||||
|
val activeMinutes: Int = 0,
|
||||||
|
val heartRate: Int = 0 // средний пульс за день
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
// Модель цикла
|
||||||
|
data class CycleData(
|
||||||
|
val id: String = "",
|
||||||
|
val userId: String = "",
|
||||||
|
val cycleLength: Int = 28, // дней
|
||||||
|
val periodLength: Int = 5, // дней
|
||||||
|
val lastPeriodDate: LocalDate = LocalDate.now(),
|
||||||
|
val nextPeriodDate: LocalDate = LocalDate.now().plusDays(28),
|
||||||
|
val ovulationDate: LocalDate = LocalDate.now().plusDays(14)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Модель сна
|
||||||
|
data class SleepData(
|
||||||
|
val id: String = "",
|
||||||
|
val userId: String = "",
|
||||||
|
val date: LocalDate = LocalDate.now(),
|
||||||
|
val bedTime: LocalTime = LocalTime.of(22, 0),
|
||||||
|
val wakeTime: LocalTime = LocalTime.of(7, 0),
|
||||||
|
val sleepDuration: Float = 8.0f, // часов
|
||||||
|
val sleepQuality: SleepQuality = SleepQuality.GOOD
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class SleepQuality {
|
||||||
|
POOR, FAIR, GOOD, EXCELLENT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модель тренировки
|
||||||
|
data class WorkoutData(
|
||||||
|
val id: String = "",
|
||||||
|
val userId: String = "",
|
||||||
|
val date: LocalDate = LocalDate.now(),
|
||||||
|
val type: WorkoutType = WorkoutType.CARDIO,
|
||||||
|
val duration: Int = 30, // минут
|
||||||
|
val intensity: WorkoutIntensity = WorkoutIntensity.MODERATE,
|
||||||
|
val caloriesBurned: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class WorkoutType {
|
||||||
|
CARDIO, STRENGTH, YOGA, PILATES, RUNNING, WALKING, CYCLING, SWIMMING
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class WorkoutIntensity {
|
||||||
|
LOW, MODERATE, HIGH, INTENSE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модель здоровья
|
||||||
|
data class HealthData(
|
||||||
|
val id: String = "",
|
||||||
|
val userId: String = "",
|
||||||
|
val date: LocalDate = LocalDate.now(),
|
||||||
|
val weight: Float = 0f,
|
||||||
|
val heartRate: Int = 70,
|
||||||
|
val bloodPressureSystolic: Int = 120,
|
||||||
|
val bloodPressureDiastolic: Int = 80,
|
||||||
|
val mood: Mood = Mood.NEUTRAL,
|
||||||
|
val energyLevel: Int = 5, // 1-10
|
||||||
|
val stressLevel: Int = 5, // 1-10
|
||||||
|
val symptoms: List<String> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class Mood {
|
||||||
|
VERY_SAD, SAD, NEUTRAL, HAPPY, VERY_HAPPY
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI состояния
|
||||||
|
data class DashboardUiState(
|
||||||
|
val user: User = User(),
|
||||||
|
val cycleData: CycleData = CycleData(),
|
||||||
|
val todayHealth: HealthData = HealthData(),
|
||||||
|
val recentWorkouts: List<WorkoutData> = emptyList(),
|
||||||
|
val sleepData: SleepData = SleepData(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProfileUiState(
|
||||||
|
val user: User = User(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SettingsUiState(
|
||||||
|
val notificationsEnabled: Boolean = true,
|
||||||
|
val darkModeEnabled: Boolean = false,
|
||||||
|
val reminderTime: LocalTime = LocalTime.of(9, 0),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
data class User(
|
||||||
|
val id: Long = 0,
|
||||||
|
val name: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val age: Int = 0,
|
||||||
|
val height: Float = 0f, // в сантиметрах
|
||||||
|
val weight: Float = 0f, // в килограммах
|
||||||
|
val profileImageUrl: String? = null,
|
||||||
|
val dailyWaterGoal: Float = 2.5f, // в литрах
|
||||||
|
val dailyStepsGoal: Int = 10000,
|
||||||
|
val dailyCaloriesGoal: Int = 2000,
|
||||||
|
val dailySleepGoal: Float = 8.0f, // в часах
|
||||||
|
val cycleLength: Int = 28, // дней
|
||||||
|
val periodLength: Int = 5, // дней
|
||||||
|
val lastPeriodStart: LocalDate? = null,
|
||||||
|
val createdAt: LocalDate = LocalDate.now(),
|
||||||
|
val updatedAt: LocalDate = LocalDate.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
data class WaterIntake(
|
||||||
|
val id: Long = 0,
|
||||||
|
val date: LocalDate,
|
||||||
|
val time: LocalTime,
|
||||||
|
val amount: Float, // в литрах
|
||||||
|
val note: String = ""
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
data class WorkoutSession(
|
||||||
|
val id: Long = 0,
|
||||||
|
val type: String, // Тип тренировки: "Ходьба", "Бег", "Йога", "Кардио" и т.д.
|
||||||
|
val date: LocalDate,
|
||||||
|
val startTime: LocalDateTime,
|
||||||
|
val endTime: LocalDateTime? = null,
|
||||||
|
val duration: Int = 0, // в минутах
|
||||||
|
val caloriesBurned: Int = 0,
|
||||||
|
val distance: Float = 0f, // в километрах
|
||||||
|
val averageHeartRate: Int = 0,
|
||||||
|
val maxHeartRate: Int = 0,
|
||||||
|
val notes: String = "",
|
||||||
|
val isCompleted: Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,824 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.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.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CycleScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: CycleViewModel = hiltViewModel(),
|
||||||
|
onNavigateBack: () -> Boolean
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "из $cycleLength дней",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = currentPhase,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = PrimaryPink
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = PrimaryPinkLight.copy(alpha = 0.1f)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (daysUntilNext > 0) {
|
||||||
|
"До следующих месячных"
|
||||||
|
} else {
|
||||||
|
"Месячные уже начались"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (daysUntilNext > 0) {
|
||||||
|
"$daysUntilNext дней"
|
||||||
|
} else {
|
||||||
|
"Отметьте начало"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = PrimaryPink
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CycleProgressIndicator(
|
||||||
|
progress: Float,
|
||||||
|
currentPhase: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
val center = this.center
|
||||||
|
val radius = size.minDimension / 2 - 20.dp.toPx()
|
||||||
|
val strokeWidth = 12.dp.toPx()
|
||||||
|
|
||||||
|
// Фон круга
|
||||||
|
drawCircle(
|
||||||
|
color = PrimaryPinkLight.copy(alpha = 0.3f),
|
||||||
|
radius = radius,
|
||||||
|
center = center,
|
||||||
|
style = androidx.compose.ui.graphics.drawscope.Stroke(width = strokeWidth)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Прогресс-дуга
|
||||||
|
val sweepAngle = 360f * progress
|
||||||
|
drawArc(
|
||||||
|
brush = Brush.sweepGradient(
|
||||||
|
colors = listOf(
|
||||||
|
PrimaryPink,
|
||||||
|
PrimaryPinkDark
|
||||||
|
)
|
||||||
|
),
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = sweepAngle,
|
||||||
|
useCenter = false,
|
||||||
|
style = androidx.compose.ui.graphics.drawscope.Stroke(
|
||||||
|
width = strokeWidth,
|
||||||
|
cap = androidx.compose.ui.graphics.StrokeCap.Round
|
||||||
|
),
|
||||||
|
topLeft = Offset(center.x - radius, center.y - radius),
|
||||||
|
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Индикаторы фаз цикла
|
||||||
|
drawPhaseIndicators(center, radius, strokeWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawPhaseIndicators(center: Offset, radius: Float, strokeWidth: Float) {
|
||||||
|
val phases = listOf(
|
||||||
|
Triple(0f, "М", Color(0xFFE91E63)), // Менструация
|
||||||
|
Triple(90f, "Ф", Color(0xFF9C27B0)), // Фолликулярная
|
||||||
|
Triple(180f, "О", Color(0xFF673AB7)), // Овуляция
|
||||||
|
Triple(270f, "Л", Color(0xFF3F51B5)) // Лютеиновая
|
||||||
|
)
|
||||||
|
|
||||||
|
phases.forEach { (angle, label, color) ->
|
||||||
|
val angleRad = Math.toRadians(angle.toDouble())
|
||||||
|
val x = center.x + (radius + strokeWidth / 2) * cos(angleRad).toFloat()
|
||||||
|
val y = center.y + (radius + strokeWidth / 2) * sin(angleRad).toFloat()
|
||||||
|
|
||||||
|
drawCircle(
|
||||||
|
color = color,
|
||||||
|
radius = 8.dp.toPx(),
|
||||||
|
center = Offset(x, y)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CycleTrackerCard(
|
||||||
|
isPeriodActive: Boolean,
|
||||||
|
onStartPeriod: () -> Unit,
|
||||||
|
onEndPeriod: () -> Unit,
|
||||||
|
onLogSymptoms: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Отслеживание",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
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("Начать месячные")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onLogSymptoms,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Assignment,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Симптомы")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SymptomsTrackingCard(
|
||||||
|
selectedSymptoms: List<String>,
|
||||||
|
selectedMood: String,
|
||||||
|
onSymptomsUpdate: (List<String>) -> Unit,
|
||||||
|
onMoodUpdate: (String) -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Симптомы и настроение",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Симптомы
|
||||||
|
Text(
|
||||||
|
text = "Симптомы",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
val symptoms = listOf(
|
||||||
|
"Боли в животе", "Головная боль", "Тошнота", "Вздутие",
|
||||||
|
"Усталость", "Раздражительность", "Боли в спине", "Акне"
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
items(symptoms) { symptom ->
|
||||||
|
FilterChip(
|
||||||
|
onClick = {
|
||||||
|
val newSymptoms = if (selectedSymptoms.contains(symptom)) {
|
||||||
|
selectedSymptoms - symptom
|
||||||
|
} else {
|
||||||
|
selectedSymptoms + symptom
|
||||||
|
}
|
||||||
|
onSymptomsUpdate(newSymptoms)
|
||||||
|
},
|
||||||
|
label = { Text(symptom, style = MaterialTheme.typography.bodySmall) },
|
||||||
|
selected = selectedSymptoms.contains(symptom),
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = PrimaryPink,
|
||||||
|
selectedLabelColor = NeutralWhite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настроение
|
||||||
|
Text(
|
||||||
|
text = "Настроение",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
val moods = listOf("Отличное", "Хорошее", "Нейтральное", "Плохое", "Ужасное")
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
items(moods) { mood ->
|
||||||
|
FilterChip(
|
||||||
|
onClick = { onMoodUpdate(mood) },
|
||||||
|
label = { Text(mood) },
|
||||||
|
selected = selectedMood == mood,
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = PrimaryPink,
|
||||||
|
selectedLabelColor = NeutralWhite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onSave,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = PrimaryPink
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Сохранить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CyclePredictionCard(
|
||||||
|
nextPeriodDate: LocalDate?,
|
||||||
|
ovulationDate: LocalDate?,
|
||||||
|
fertilityWindow: Pair<LocalDate, LocalDate>?,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Прогнозы",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
PredictionItem(
|
||||||
|
icon = Icons.Default.CalendarMonth,
|
||||||
|
title = "Следующие месячные",
|
||||||
|
date = nextPeriodDate,
|
||||||
|
color = PrimaryPink
|
||||||
|
)
|
||||||
|
|
||||||
|
PredictionItem(
|
||||||
|
icon = Icons.Default.Favorite,
|
||||||
|
title = "Овуляция",
|
||||||
|
date = ovulationDate,
|
||||||
|
color = Color(0xFF9C27B0)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fertilityWindow != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Spa,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Период фертильности",
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${fertilityWindow.first.format(DateTimeFormatter.ofPattern("dd.MM"))} - ${fertilityWindow.second.format(DateTimeFormatter.ofPattern("dd.MM"))}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = Color(0xFF4CAF50),
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PredictionItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
date: LocalDate?,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = date?.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) ?: "Недостаточно данных",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = if (date != null) color else TextSecondary,
|
||||||
|
fontWeight = if (date != null) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CycleInsightsCard(
|
||||||
|
insights: List<String>,
|
||||||
|
averageCycleLength: Float,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = PrimaryPinkLight.copy(alpha = 0.1f)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Analytics,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = PrimaryPink,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Анализ цикла",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (averageCycleLength > 0) {
|
||||||
|
Text(
|
||||||
|
text = "Средняя длина цикла: %.1f дней".format(averageCycleLength),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextPrimary,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insights.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Отслеживайте цикл несколько месяцев для получения персональных рекомендаций.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
insights.forEach { insight ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Circle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = PrimaryPink,
|
||||||
|
modifier = Modifier.size(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = insight,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PeriodHistoryCard(
|
||||||
|
recentPeriods: List<CyclePeriodEntity>,
|
||||||
|
onPeriodClick: (CyclePeriodEntity) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "История циклов",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (recentPeriods.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Пока нет записей о циклах",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
recentPeriods.take(3).forEach { period ->
|
||||||
|
PeriodHistoryItem(
|
||||||
|
period = period,
|
||||||
|
onClick = { onPeriodClick(period) }
|
||||||
|
)
|
||||||
|
if (period != recentPeriods.last()) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PeriodHistoryItem(
|
||||||
|
period: CyclePeriodEntity,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CalendarMonth,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = PrimaryPink,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.cycle
|
||||||
|
|
||||||
|
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 kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.WellSheRepository
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class CycleUiState(
|
||||||
|
val currentPhase: String = "Фолликулярная",
|
||||||
|
val currentCycleDay: Int = 1,
|
||||||
|
val cycleLength: Int = 28,
|
||||||
|
val daysUntilNextPeriod: Int = 0,
|
||||||
|
val isPeriodActive: Boolean = false,
|
||||||
|
val nextPeriodDate: LocalDate? = null,
|
||||||
|
val ovulationDate: LocalDate? = null,
|
||||||
|
val fertilityWindow: Pair<LocalDate, LocalDate>? = null,
|
||||||
|
val recentPeriods: List<CyclePeriodEntity> = emptyList(),
|
||||||
|
val averageCycleLength: Float = 0f,
|
||||||
|
val insights: List<String> = emptyList(),
|
||||||
|
val showSymptomsEdit: Boolean = false,
|
||||||
|
val todaySymptoms: List<String> = emptyList(),
|
||||||
|
val todayMood: String = "",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class CycleViewModel @Inject constructor(
|
||||||
|
private val repository: WellSheRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(CycleUiState())
|
||||||
|
val uiState: StateFlow<CycleUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadCycleData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Загружаем текущий период
|
||||||
|
repository.getCurrentCyclePeriod().collect { currentPeriod ->
|
||||||
|
val isPeriodActive = currentPeriod != null && currentPeriod.endDate == null
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isPeriodActive = isPeriodActive,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
|
||||||
|
// Вычисляем текущий день цикла и фазу
|
||||||
|
calculateCycleInfo(currentPeriod)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем историю периодов
|
||||||
|
repository.getRecentPeriods().collect { periods ->
|
||||||
|
val averageLength = calculateAverageCycleLength(periods)
|
||||||
|
val insights = generateCycleInsights(periods)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
recentPeriods = periods,
|
||||||
|
averageCycleLength = averageLength,
|
||||||
|
insights = insights
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем настройки цикла пользователя
|
||||||
|
repository.getUserProfile().collect { user ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
cycleLength = user.cycleLength
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateCycleInfo(currentPeriod: CyclePeriodEntity?) {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val cycleLength = _uiState.value.cycleLength
|
||||||
|
|
||||||
|
if (currentPeriod != null) {
|
||||||
|
val daysSinceStart = ChronoUnit.DAYS.between(currentPeriod.startDate, today).toInt() + 1
|
||||||
|
val currentCycleDay = if (daysSinceStart > cycleLength) {
|
||||||
|
// Если прошло больше дней чем длина цикла, начинаем новый цикл
|
||||||
|
(daysSinceStart - 1) % cycleLength + 1
|
||||||
|
} else {
|
||||||
|
daysSinceStart
|
||||||
|
}
|
||||||
|
|
||||||
|
val phase = calculatePhase(currentCycleDay, cycleLength)
|
||||||
|
val daysUntilNext = cycleLength - currentCycleDay
|
||||||
|
|
||||||
|
// Прогнозы
|
||||||
|
val nextPeriodDate = currentPeriod.startDate.plusDays(cycleLength.toLong())
|
||||||
|
val ovulationDay = cycleLength / 2 // Примерно в середине цикла
|
||||||
|
val ovulationDate = currentPeriod.startDate.plusDays(ovulationDay.toLong())
|
||||||
|
val fertilityStart = ovulationDate.minusDays(5)
|
||||||
|
val fertilityEnd = ovulationDate.plusDays(1)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
currentCycleDay = currentCycleDay,
|
||||||
|
currentPhase = phase,
|
||||||
|
daysUntilNextPeriod = daysUntilNext.coerceAtLeast(0),
|
||||||
|
nextPeriodDate = nextPeriodDate,
|
||||||
|
ovulationDate = ovulationDate,
|
||||||
|
fertilityWindow = Pair(fertilityStart, fertilityEnd)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Нет данных о текущем цикле
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
currentCycleDay = 1,
|
||||||
|
currentPhase = "Нет данных",
|
||||||
|
daysUntilNextPeriod = 0,
|
||||||
|
nextPeriodDate = null,
|
||||||
|
ovulationDate = null,
|
||||||
|
fertilityWindow = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculatePhase(cycleDay: Int, cycleLength: Int): String {
|
||||||
|
return when {
|
||||||
|
cycleDay <= 5 -> "Менструация"
|
||||||
|
cycleDay <= cycleLength / 2 - 2 -> "Фолликулярная"
|
||||||
|
cycleDay <= cycleLength / 2 + 2 -> "Овуляция"
|
||||||
|
else -> "Лютеиновая"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateAverageCycleLength(periods: List<CyclePeriodEntity>): Float {
|
||||||
|
if (periods.size < 2) return 0f
|
||||||
|
|
||||||
|
val cycleLengths = mutableListOf<Int>()
|
||||||
|
for (i in 0 until periods.size - 1) {
|
||||||
|
val currentPeriod = periods[i]
|
||||||
|
val nextPeriod = periods[i + 1]
|
||||||
|
val length = ChronoUnit.DAYS.between(nextPeriod.startDate, currentPeriod.startDate).toInt()
|
||||||
|
if (length > 0) {
|
||||||
|
cycleLengths.add(length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (cycleLengths.isNotEmpty()) {
|
||||||
|
cycleLengths.average().toFloat()
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateCycleInsights(periods: List<CyclePeriodEntity>): List<String> {
|
||||||
|
val insights = mutableListOf<String>()
|
||||||
|
|
||||||
|
if (periods.size >= 3) {
|
||||||
|
val averageLength = calculateAverageCycleLength(periods)
|
||||||
|
|
||||||
|
when {
|
||||||
|
averageLength < 21 -> {
|
||||||
|
insights.add("Ваши циклы короче обычного. Рекомендуем консультацию с врачом.")
|
||||||
|
}
|
||||||
|
averageLength > 35 -> {
|
||||||
|
insights.add("Ваши циклы длиннее обычного. Стоит обратиться к специалисту.")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
insights.add("Длина ваших циклов в пределах нормы.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Анализ регулярности
|
||||||
|
val cycleLengths = mutableListOf<Int>()
|
||||||
|
for (i in 0 until periods.size - 1) {
|
||||||
|
val length = ChronoUnit.DAYS.between(periods[i + 1].startDate, periods[i].startDate).toInt()
|
||||||
|
if (length > 0) cycleLengths.add(length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cycleLengths.size >= 2) {
|
||||||
|
val deviation = cycleLengths.map { kotlin.math.abs(it - averageLength) }.average()
|
||||||
|
|
||||||
|
if (deviation <= 3) {
|
||||||
|
insights.add("У вас очень регулярный цикл.")
|
||||||
|
} else if (deviation <= 7) {
|
||||||
|
insights.add("Ваш цикл достаточно регулярный.")
|
||||||
|
} else {
|
||||||
|
insights.add("Циклы нерегулярные. Рекомендуем отслеживать факторы, влияющие на цикл.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Анализ симптомов
|
||||||
|
val symptomsData = periods.mapNotNull { period ->
|
||||||
|
period.symptoms.split(",").filter { it.isNotBlank() }
|
||||||
|
}.flatten()
|
||||||
|
|
||||||
|
if (symptomsData.isNotEmpty()) {
|
||||||
|
val commonSymptoms = symptomsData.groupBy { it }.maxByOrNull { it.value.size }?.key
|
||||||
|
if (commonSymptoms != null) {
|
||||||
|
insights.add("Наиболее частый симптом: $commonSymptoms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return insights
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startPeriod() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
|
||||||
|
repository.addPeriod(
|
||||||
|
startDate = today,
|
||||||
|
endDate = null,
|
||||||
|
flow = "Средний",
|
||||||
|
symptoms = emptyList(),
|
||||||
|
mood = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(isPeriodActive = true)
|
||||||
|
loadCycleData() // Перезагружаем данные
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endPeriod() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val currentPeriod = _uiState.value.recentPeriods.firstOrNull { it.endDate == null }
|
||||||
|
|
||||||
|
if (currentPeriod != null) {
|
||||||
|
repository.addPeriod(
|
||||||
|
startDate = currentPeriod.startDate,
|
||||||
|
endDate = today,
|
||||||
|
flow = currentPeriod.flow,
|
||||||
|
symptoms = currentPeriod.symptoms.split(","),
|
||||||
|
mood = currentPeriod.mood
|
||||||
|
)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(isPeriodActive = false)
|
||||||
|
loadCycleData() // Перезагружаем данные
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleSymptomsEdit() {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showSymptomsEdit = !_uiState.value.showSymptomsEdit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSymptoms(symptoms: List<String>) {
|
||||||
|
_uiState.value = _uiState.value.copy(todaySymptoms = symptoms)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateMood(mood: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(todayMood = mood)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveTodayData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val symptoms = _uiState.value.todaySymptoms
|
||||||
|
val mood = _uiState.value.todayMood
|
||||||
|
|
||||||
|
// TODO: Сохранить симптомы и настроение за сегодня
|
||||||
|
// Это может быть отдельная таблица или обновление текущего периода
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showSymptomsEdit = false,
|
||||||
|
todaySymptoms = emptyList(),
|
||||||
|
todayMood = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
loadCycleData() // Перезагружаем данные
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,720 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.dashboard
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
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.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.*
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DashboardScreen(
|
||||||
|
onNavigate: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: DashboardViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val currentTime = remember { LocalTime.now() }
|
||||||
|
|
||||||
|
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 {
|
||||||
|
WelcomeHeader(
|
||||||
|
user = uiState.user,
|
||||||
|
currentTime = currentTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
CycleCard(
|
||||||
|
cycleData = uiState.cycleData,
|
||||||
|
onClick = { onNavigate("cycle") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
QuickActionsRow(
|
||||||
|
onNavigate = onNavigate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
HealthOverviewCard(
|
||||||
|
healthData = uiState.todayHealth,
|
||||||
|
onClick = { onNavigate("health") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SleepCard(
|
||||||
|
sleepData = uiState.sleepData,
|
||||||
|
onClick = { onNavigate("sleep") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RecentWorkoutsCard(
|
||||||
|
workouts = uiState.recentWorkouts,
|
||||||
|
onClick = { onNavigate("workouts") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Отступ для нижней навигации
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WelcomeHeader(
|
||||||
|
user: User,
|
||||||
|
currentTime: LocalTime,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val greeting = when (currentTime.hour) {
|
||||||
|
in 5..11 -> "Доброе утро"
|
||||||
|
in 12..17 -> "Добрый день"
|
||||||
|
in 18..22 -> "Добрый вечер"
|
||||||
|
else -> "Доброй ночи"
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = NeutralWhite
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = greeting,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = user.name.ifEmpty { "Пользователь" },
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Сегодня ${LocalDate.now().format(DateTimeFormatter.ofPattern("d MMMM"))}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(60.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
Brush.radialGradient(
|
||||||
|
colors = listOf(PrimaryPink, PrimaryPinkDark)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = NeutralWhite,
|
||||||
|
modifier = Modifier.size(30.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CycleCard(
|
||||||
|
cycleData: CycleData,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val daysUntilPeriod = ChronoUnit.DAYS.between(LocalDate.now(), cycleData.nextPeriodDate).toInt()
|
||||||
|
val progressValue = 1f - (daysUntilPeriod.toFloat() / cycleData.cycleLength.toFloat())
|
||||||
|
val animatedProgress by animateFloatAsState(
|
||||||
|
targetValue = progressValue,
|
||||||
|
animationSpec = tween(1000),
|
||||||
|
label = "cycle_progress"
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() },
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = NeutralWhite
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Менструальный цикл",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = NeutralGray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(80.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { animatedProgress },
|
||||||
|
modifier = Modifier.size(80.dp),
|
||||||
|
color = PrimaryPink,
|
||||||
|
strokeWidth = 6.dp,
|
||||||
|
trackColor = PrimaryPinkLight.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "$daysUntilPeriod",
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = PrimaryPink
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(20.dp))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
if (daysUntilPeriod > 0) {
|
||||||
|
Text(
|
||||||
|
text = "дней до начала",
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "следующих месячных",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Месячные начались",
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = PrimaryPink
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickActionsRow(
|
||||||
|
onNavigate: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Быстрые действия",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 4.dp)
|
||||||
|
) {
|
||||||
|
items(quickActions) { action ->
|
||||||
|
QuickActionCard(
|
||||||
|
action = action,
|
||||||
|
onClick = { onNavigate(action.route) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickActionCard(
|
||||||
|
action: QuickAction,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.width(120.dp)
|
||||||
|
.clickable { onClick() },
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = action.backgroundColor
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = action.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = action.iconColor,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = action.title,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = action.textColor
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HealthOverviewCard(
|
||||||
|
healthData: HealthData,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() },
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Здоровье сегодня",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = NeutralGray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
HealthMetric(
|
||||||
|
label = "Пульс",
|
||||||
|
value = "${healthData.heartRate}",
|
||||||
|
unit = "bpm",
|
||||||
|
icon = Icons.Default.Favorite
|
||||||
|
)
|
||||||
|
|
||||||
|
HealthMetric(
|
||||||
|
label = "Настроение",
|
||||||
|
value = getMoodEmoji(healthData.mood),
|
||||||
|
unit = "",
|
||||||
|
icon = Icons.Default.Mood
|
||||||
|
)
|
||||||
|
|
||||||
|
HealthMetric(
|
||||||
|
label = "Энергия",
|
||||||
|
value = "${healthData.energyLevel}",
|
||||||
|
unit = "/10",
|
||||||
|
icon = Icons.Default.Battery6Bar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HealthMetric(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
unit: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = PrimaryPink,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (unit.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = unit,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepCard(
|
||||||
|
sleepData: SleepData,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() },
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Bedtime,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = PrimaryPink,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Сон",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${sleepData.sleepDuration}ч • ${getSleepQualityText(sleepData.sleepQuality)}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = NeutralGray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentWorkoutsCard(
|
||||||
|
workouts: List<WorkoutData>,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() },
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Тренировки",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = NeutralGray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (workouts.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Пока нет записей о тренировках",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
workouts.take(2).forEach { workout ->
|
||||||
|
WorkoutItem(workout = workout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WorkoutItem(
|
||||||
|
workout: WorkoutData,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = getWorkoutIcon(workout.type),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = PrimaryPink,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = getWorkoutTypeText(workout.type),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${workout.duration} мин • ${workout.caloriesBurned} ккал",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательные данные и функции
|
||||||
|
private data class QuickAction(
|
||||||
|
val title: String,
|
||||||
|
val icon: ImageVector,
|
||||||
|
val route: String,
|
||||||
|
val backgroundColor: Color,
|
||||||
|
val iconColor: Color,
|
||||||
|
val textColor: Color
|
||||||
|
)
|
||||||
|
|
||||||
|
private val quickActions = listOf(
|
||||||
|
QuickAction(
|
||||||
|
title = "Добавить симптомы",
|
||||||
|
icon = Icons.Default.Add,
|
||||||
|
route = "health",
|
||||||
|
backgroundColor = PrimaryPinkLight,
|
||||||
|
iconColor = PrimaryPink,
|
||||||
|
textColor = PrimaryPink
|
||||||
|
),
|
||||||
|
QuickAction(
|
||||||
|
title = "Записать тренировку",
|
||||||
|
icon = Icons.Default.FitnessCenter,
|
||||||
|
route = "workouts",
|
||||||
|
backgroundColor = SecondaryBlueLight,
|
||||||
|
iconColor = SecondaryBlue,
|
||||||
|
textColor = SecondaryBlue
|
||||||
|
),
|
||||||
|
QuickAction(
|
||||||
|
title = "Отметить сон",
|
||||||
|
icon = Icons.Default.Bedtime,
|
||||||
|
route = "sleep",
|
||||||
|
backgroundColor = AccentPurpleLight,
|
||||||
|
iconColor = AccentPurple,
|
||||||
|
textColor = AccentPurple
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getMoodEmoji(mood: Mood): String {
|
||||||
|
return when (mood) {
|
||||||
|
Mood.VERY_SAD -> "😢"
|
||||||
|
Mood.SAD -> "😔"
|
||||||
|
Mood.NEUTRAL -> "😐"
|
||||||
|
Mood.HAPPY -> "😊"
|
||||||
|
Mood.VERY_HAPPY -> "😄"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSleepQualityText(quality: SleepQuality): String {
|
||||||
|
return when (quality) {
|
||||||
|
SleepQuality.POOR -> "Плохо"
|
||||||
|
SleepQuality.FAIR -> "Нормально"
|
||||||
|
SleepQuality.GOOD -> "Хорошо"
|
||||||
|
SleepQuality.EXCELLENT -> "Отлично"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getWorkoutIcon(type: WorkoutType): ImageVector {
|
||||||
|
return when (type) {
|
||||||
|
WorkoutType.CARDIO -> Icons.Default.DirectionsRun
|
||||||
|
WorkoutType.STRENGTH -> Icons.Default.FitnessCenter
|
||||||
|
WorkoutType.YOGA -> Icons.Default.SelfImprovement
|
||||||
|
WorkoutType.PILATES -> Icons.Default.SelfImprovement
|
||||||
|
WorkoutType.RUNNING -> Icons.Default.DirectionsRun
|
||||||
|
WorkoutType.WALKING -> Icons.Default.DirectionsWalk
|
||||||
|
WorkoutType.CYCLING -> Icons.Default.DirectionsBike
|
||||||
|
WorkoutType.SWIMMING -> Icons.Default.Pool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getWorkoutTypeText(type: WorkoutType): String {
|
||||||
|
return when (type) {
|
||||||
|
WorkoutType.CARDIO -> "Кардио"
|
||||||
|
WorkoutType.STRENGTH -> "Силовая"
|
||||||
|
WorkoutType.YOGA -> "Йога"
|
||||||
|
WorkoutType.PILATES -> "Пилатес"
|
||||||
|
WorkoutType.RUNNING -> "Бег"
|
||||||
|
WorkoutType.WALKING -> "Ходьба"
|
||||||
|
WorkoutType.CYCLING -> "Велосипед"
|
||||||
|
WorkoutType.SWIMMING -> "Плавание"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.dashboard
|
||||||
|
|
||||||
|
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 kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.SleepLogEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.WellSheRepository
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
data class DashboardUiState(
|
||||||
|
val user: User = User(),
|
||||||
|
val todayHealth: HealthData = HealthData(),
|
||||||
|
val sleepData: SleepData = SleepData(),
|
||||||
|
val cycleData: CycleData = CycleData(),
|
||||||
|
val recentWorkouts: List<WorkoutData> = emptyList(),
|
||||||
|
val todaySteps: Int = 0,
|
||||||
|
val todayWater: Float = 0f,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class DashboardViewModel @Inject constructor(
|
||||||
|
private val repository: WellSheRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(DashboardUiState())
|
||||||
|
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadDashboardData()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadDashboardData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Загружаем данные пользователя
|
||||||
|
repository.getUserProfile().collect { user ->
|
||||||
|
_uiState.value = _uiState.value.copy(user = user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем данные о здоровье
|
||||||
|
repository.getTodayHealthData().collect { healthEntity ->
|
||||||
|
val healthData = healthEntity?.let { convertHealthEntityToModel(it) } ?: HealthData()
|
||||||
|
_uiState.value = _uiState.value.copy(todayHealth = healthData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем данные о сне
|
||||||
|
loadSleepData()
|
||||||
|
|
||||||
|
// Загружаем данные о цикле
|
||||||
|
repository.getCurrentCyclePeriod().collect { cycleEntity ->
|
||||||
|
val cycleData = cycleEntity?.let { convertCycleEntityToModel(it) } ?: CycleData()
|
||||||
|
_uiState.value = _uiState.value.copy(cycleData = cycleData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем тренировки
|
||||||
|
repository.getRecentWorkouts().collect { workoutEntities ->
|
||||||
|
val workouts = workoutEntities.map { convertWorkoutEntityToModel(it) }
|
||||||
|
_uiState.value = _uiState.value.copy(recentWorkouts = workouts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем шаги за сегодня
|
||||||
|
loadTodayFitnessData()
|
||||||
|
|
||||||
|
// Загружаем воду за сегодня
|
||||||
|
loadTodayWaterData()
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadSleepData() {
|
||||||
|
try {
|
||||||
|
val yesterday = LocalDate.now().minusDays(1)
|
||||||
|
val sleepEntity = repository.getSleepForDate(yesterday)
|
||||||
|
val sleepData = sleepEntity?.let { convertSleepEntityToModel(it) } ?: SleepData()
|
||||||
|
_uiState.value = _uiState.value.copy(sleepData = sleepData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Игнорируем ошибки загрузки сна
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadTodayFitnessData() {
|
||||||
|
try {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
repository.getFitnessDataForDate(today).collect { fitnessData ->
|
||||||
|
_uiState.value = _uiState.value.copy(todaySteps = fitnessData.steps)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Игнорируем ошибки загрузки фитнеса
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadTodayWaterData() {
|
||||||
|
try {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
repository.getWaterIntakeForDate(today).collect { waterIntakes ->
|
||||||
|
val totalAmount = waterIntakes.sumOf { it.amount.toDouble() }.toFloat()
|
||||||
|
_uiState.value = _uiState.value.copy(todayWater = totalAmount)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Игнорируем ошибки загрузки воды
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функции преобразования Entity -> Model
|
||||||
|
private fun convertHealthEntityToModel(entity: HealthRecordEntity): HealthData {
|
||||||
|
return HealthData(
|
||||||
|
id = entity.id.toString(),
|
||||||
|
userId = "current_user",
|
||||||
|
date = entity.date,
|
||||||
|
weight = entity.weight ?: 0f,
|
||||||
|
heartRate = entity.heartRate ?: 70,
|
||||||
|
bloodPressureSystolic = entity.bloodPressureS ?: 120,
|
||||||
|
bloodPressureDiastolic = entity.bloodPressureD ?: 80,
|
||||||
|
mood = convertMoodStringToEnum(entity.mood),
|
||||||
|
energyLevel = entity.energyLevel,
|
||||||
|
stressLevel = entity.stressLevel,
|
||||||
|
symptoms = entity.symptoms.split(",").filter { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convertSleepEntityToModel(entity: SleepLogEntity): SleepData {
|
||||||
|
return SleepData(
|
||||||
|
id = entity.id.toString(),
|
||||||
|
userId = "current_user",
|
||||||
|
date = entity.date,
|
||||||
|
bedTime = java.time.LocalTime.parse(entity.bedTime),
|
||||||
|
wakeTime = java.time.LocalTime.parse(entity.wakeTime),
|
||||||
|
sleepDuration = entity.duration,
|
||||||
|
sleepQuality = convertSleepQualityStringToEnum(entity.quality)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convertCycleEntityToModel(entity: CyclePeriodEntity): CycleData {
|
||||||
|
return CycleData(
|
||||||
|
id = entity.id.toString(),
|
||||||
|
userId = "current_user",
|
||||||
|
cycleLength = entity.cycleLength,
|
||||||
|
periodLength = entity.endDate?.let {
|
||||||
|
java.time.temporal.ChronoUnit.DAYS.between(entity.startDate, it).toInt() + 1
|
||||||
|
} ?: 5,
|
||||||
|
lastPeriodDate = entity.startDate,
|
||||||
|
nextPeriodDate = entity.startDate.plusDays(entity.cycleLength.toLong()),
|
||||||
|
ovulationDate = entity.startDate.plusDays((entity.cycleLength / 2).toLong())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convertWorkoutEntityToModel(entity: kr.smartsoltech.wellshe.domain.model.WorkoutSession): WorkoutData {
|
||||||
|
return WorkoutData(
|
||||||
|
id = entity.id.toString(),
|
||||||
|
userId = "current_user",
|
||||||
|
date = entity.date,
|
||||||
|
type = convertWorkoutTypeStringToEnum(entity.type),
|
||||||
|
duration = entity.duration,
|
||||||
|
intensity = WorkoutIntensity.MODERATE, // По умолчанию, так как в WorkoutSession нет intensity
|
||||||
|
caloriesBurned = entity.caloriesBurned
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательные функции преобразования
|
||||||
|
private fun convertMoodStringToEnum(mood: String): Mood {
|
||||||
|
return when (mood.lowercase()) {
|
||||||
|
"very_sad" -> Mood.VERY_SAD
|
||||||
|
"sad" -> Mood.SAD
|
||||||
|
"neutral" -> Mood.NEUTRAL
|
||||||
|
"happy" -> Mood.HAPPY
|
||||||
|
"very_happy" -> Mood.VERY_HAPPY
|
||||||
|
else -> Mood.NEUTRAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convertSleepQualityStringToEnum(quality: String): SleepQuality {
|
||||||
|
return when (quality.lowercase()) {
|
||||||
|
"poor" -> SleepQuality.POOR
|
||||||
|
"fair" -> SleepQuality.FAIR
|
||||||
|
"good" -> SleepQuality.GOOD
|
||||||
|
"excellent" -> SleepQuality.EXCELLENT
|
||||||
|
else -> SleepQuality.GOOD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convertWorkoutTypeStringToEnum(type: String): WorkoutType {
|
||||||
|
return when (type.lowercase()) {
|
||||||
|
"кардио", "cardio" -> WorkoutType.CARDIO
|
||||||
|
"силовая", "strength" -> WorkoutType.STRENGTH
|
||||||
|
"йога", "yoga" -> WorkoutType.YOGA
|
||||||
|
"пилатес", "pilates" -> WorkoutType.PILATES
|
||||||
|
"бег", "running" -> WorkoutType.RUNNING
|
||||||
|
"ходьба", "walking" -> WorkoutType.WALKING
|
||||||
|
"велосипед", "cycling" -> WorkoutType.CYCLING
|
||||||
|
"плавание", "swimming" -> WorkoutType.SWIMMING
|
||||||
|
else -> WorkoutType.CARDIO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convertWorkoutIntensityStringToEnum(intensity: String): WorkoutIntensity {
|
||||||
|
return when (intensity.lowercase()) {
|
||||||
|
"low" -> WorkoutIntensity.LOW
|
||||||
|
"moderate" -> WorkoutIntensity.MODERATE
|
||||||
|
"high" -> WorkoutIntensity.HIGH
|
||||||
|
"intense" -> WorkoutIntensity.INTENSE
|
||||||
|
else -> WorkoutIntensity.MODERATE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.fitness
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.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.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.*
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun FitnessScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: FitnessViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadFitnessData()
|
||||||
|
viewModel.startStepTracking()
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(0xFF4CAF50).copy(alpha = 0.2f),
|
||||||
|
NeutralWhite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
StepsCard(
|
||||||
|
currentSteps = uiState.todaySteps,
|
||||||
|
stepsGoal = uiState.stepsGoal,
|
||||||
|
caloriesBurned = uiState.caloriesBurned,
|
||||||
|
distance = uiState.distance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
CaloriesCard(
|
||||||
|
caloriesBurned = uiState.caloriesBurned,
|
||||||
|
caloriesGoal = uiState.caloriesGoal,
|
||||||
|
activeMinutes = uiState.activeMinutes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
QuickWorkoutSection(
|
||||||
|
onStartWorkout = viewModel::startWorkout
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
WeeklyProgressCard(
|
||||||
|
weeklyData = uiState.weeklyFitnessData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RecentWorkoutsCard(
|
||||||
|
workouts = uiState.recentWorkouts,
|
||||||
|
onWorkoutClick = { /* TODO: Navigate to workout details */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.error != null) {
|
||||||
|
LaunchedEffect(uiState.error) {
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StepsCard(
|
||||||
|
currentSteps: Int,
|
||||||
|
stepsGoal: Int,
|
||||||
|
caloriesBurned: Int,
|
||||||
|
distance: Float,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val progress by animateFloatAsState(
|
||||||
|
targetValue = if (stepsGoal > 0) (currentSteps.toFloat() / stepsGoal).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
|
||||||
|
) {
|
||||||
|
StepsProgressIndicator(
|
||||||
|
progress = progress,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.DirectionsWalk,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = currentSteps.toString(),
|
||||||
|
style = MaterialTheme.typography.headlineLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFF4CAF50)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "из $stepsGoal шагов",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${(progress * 100).toInt()}%",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFF4CAF50)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
FitnessStatItem(
|
||||||
|
icon = Icons.Default.LocalFireDepartment,
|
||||||
|
label = "Калории",
|
||||||
|
value = "$caloriesBurned ккал",
|
||||||
|
color = Color(0xFFFF5722)
|
||||||
|
)
|
||||||
|
|
||||||
|
FitnessStatItem(
|
||||||
|
icon = Icons.Default.Route,
|
||||||
|
label = "Расстояние",
|
||||||
|
value = "%.2f км".format(distance),
|
||||||
|
color = Color(0xFF2196F3)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StepsProgressIndicator(
|
||||||
|
progress: Float,
|
||||||
|
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 = NeutralLightGray.copy(alpha = 0.3f),
|
||||||
|
radius = radius,
|
||||||
|
center = center,
|
||||||
|
style = androidx.compose.ui.graphics.drawscope.Stroke(width = strokeWidth)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Прогресс-дуга
|
||||||
|
val sweepAngle = 360f * progress
|
||||||
|
drawArc(
|
||||||
|
color = Color(0xFF4CAF50),
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = sweepAngle,
|
||||||
|
useCenter = false,
|
||||||
|
style = androidx.compose.ui.graphics.drawscope.Stroke(
|
||||||
|
width = strokeWidth,
|
||||||
|
cap = StrokeCap.Round
|
||||||
|
),
|
||||||
|
topLeft = Offset(center.x - radius, center.y - radius),
|
||||||
|
size = Size(radius * 2, radius * 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CaloriesCard(
|
||||||
|
caloriesBurned: Int,
|
||||||
|
caloriesGoal: Int,
|
||||||
|
activeMinutes: Int,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val progress by animateFloatAsState(
|
||||||
|
targetValue = if (caloriesGoal > 0) (caloriesBurned.toFloat() / caloriesGoal).coerceIn(0f, 1f) else 0f,
|
||||||
|
animationSpec = tween(durationMillis = 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.LocalFireDepartment,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFFFF5722),
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Калории",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "$caloriesBurned",
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFFFF5722)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "из $caloriesGoal ккал",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$activeMinutes мин",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "активности",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = progress,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(8.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
|
color = Color(0xFFFF5722),
|
||||||
|
trackColor = Color(0xFFFFE5DB)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${(progress * 100).toInt()}% от цели",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickWorkoutSection(
|
||||||
|
onStartWorkout: (String) -> 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)
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
val workouts = listOf(
|
||||||
|
"Ходьба" to Icons.Default.DirectionsWalk,
|
||||||
|
"Бег" to Icons.Default.DirectionsRun,
|
||||||
|
"Йога" to Icons.Default.SelfImprovement,
|
||||||
|
"Кардио" to Icons.Default.FitnessCenter
|
||||||
|
)
|
||||||
|
|
||||||
|
items(workouts) { (name, icon) ->
|
||||||
|
QuickWorkoutButton(
|
||||||
|
name = name,
|
||||||
|
icon = icon,
|
||||||
|
onClick = { onStartWorkout(name) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickWorkoutButton(
|
||||||
|
name: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF4CAF50),
|
||||||
|
contentColor = NeutralWhite
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeeklyProgressCard(
|
||||||
|
weeklyData: Map<LocalDate, FitnessData>,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Прогресс за неделю",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
weeklyData.entries.toList().takeLast(7).forEach { (date, data) ->
|
||||||
|
WeeklyFitnessBar(
|
||||||
|
date = date,
|
||||||
|
steps = data.steps,
|
||||||
|
stepsGoal = 10000, // TODO: Получить из настроек
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeeklyFitnessBar(
|
||||||
|
date: LocalDate,
|
||||||
|
steps: Int,
|
||||||
|
stepsGoal: Int,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val progress = if (stepsGoal > 0) (steps.toFloat() / stepsGoal).coerceIn(0f, 1f) else 0f
|
||||||
|
val animatedProgress by animateFloatAsState(
|
||||||
|
targetValue = progress,
|
||||||
|
animationSpec = tween(durationMillis = 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date.dayOfWeek.name.take(3),
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(24.dp)
|
||||||
|
.height(80.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color(0xFFE8F5E8))
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(animatedProgress)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(0xFF81C784),
|
||||||
|
Color(0xFF4CAF50)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${steps / 1000}k",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextPrimary,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentWorkoutsCard(
|
||||||
|
workouts: List<WorkoutSession>,
|
||||||
|
onWorkoutClick: (WorkoutSession) -> 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 (workouts.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Пока нет тренировок",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
workouts.take(3).forEach { workout ->
|
||||||
|
WorkoutItem(
|
||||||
|
workout = workout,
|
||||||
|
onClick = { onWorkoutClick(workout) }
|
||||||
|
)
|
||||||
|
if (workout != workouts.last()) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WorkoutItem(
|
||||||
|
workout: WorkoutSession,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
val workoutIcon = when (workout.type.lowercase()) {
|
||||||
|
"ходьба" -> Icons.Default.DirectionsWalk
|
||||||
|
"бег" -> Icons.Default.DirectionsRun
|
||||||
|
"йога" -> Icons.Default.SelfImprovement
|
||||||
|
"кардио" -> Icons.Default.FitnessCenter
|
||||||
|
else -> Icons.Default.FitnessCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = workoutIcon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = workout.type,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${workout.duration} мин • ${workout.caloriesBurned} ккал",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = workout.date.format(DateTimeFormatter.ofPattern("dd.MM")),
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FitnessStatItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextPrimary,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.fitness
|
||||||
|
|
||||||
|
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 kr.smartsoltech.wellshe.data.repository.WellSheRepository
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.FitnessData
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.WorkoutSession
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class FitnessUiState(
|
||||||
|
val todaySteps: Int = 0,
|
||||||
|
val stepsGoal: Int = 10000,
|
||||||
|
val caloriesBurned: Int = 0,
|
||||||
|
val caloriesGoal: Int = 2000,
|
||||||
|
val distance: Float = 0f,
|
||||||
|
val activeMinutes: Int = 0,
|
||||||
|
val weeklyFitnessData: Map<LocalDate, FitnessData> = emptyMap(),
|
||||||
|
val recentWorkouts: List<WorkoutSession> = emptyList(),
|
||||||
|
val isTrackingSteps: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class FitnessViewModel @Inject constructor(
|
||||||
|
private val repository: WellSheRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(FitnessUiState())
|
||||||
|
val uiState: StateFlow<FitnessUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadFitnessData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
|
||||||
|
// Загружаем данные фитнеса за сегодня
|
||||||
|
repository.getFitnessDataForDate(today).collect { fitnessData ->
|
||||||
|
val calories = calculateCaloriesFromSteps(fitnessData.steps)
|
||||||
|
val distance = calculateDistanceFromSteps(fitnessData.steps)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
todaySteps = fitnessData.steps,
|
||||||
|
caloriesBurned = calories,
|
||||||
|
distance = distance,
|
||||||
|
activeMinutes = fitnessData.activeMinutes,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем недельные данные
|
||||||
|
loadWeeklyFitnessData()
|
||||||
|
|
||||||
|
// Загружаем последние тренировки
|
||||||
|
repository.getRecentWorkouts().collect { workouts ->
|
||||||
|
_uiState.value = _uiState.value.copy(recentWorkouts = workouts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем цели пользователя
|
||||||
|
repository.getUserProfile().collect { user ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
stepsGoal = user.dailyStepsGoal,
|
||||||
|
caloriesGoal = user.dailyCaloriesGoal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadWeeklyFitnessData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val weeklyData = mutableMapOf<LocalDate, FitnessData>()
|
||||||
|
val today = LocalDate.now()
|
||||||
|
|
||||||
|
for (i in 0..6) {
|
||||||
|
val date = today.minusDays(i.toLong())
|
||||||
|
val fitnessData = repository.getFitnessDataForDateSync(date)
|
||||||
|
weeklyData[date] = fitnessData
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(weeklyFitnessData = weeklyData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startStepTracking() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_uiState.value = _uiState.value.copy(isTrackingSteps = true)
|
||||||
|
repository.startStepTracking()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopStepTracking() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.stopStepTracking()
|
||||||
|
_uiState.value = _uiState.value.copy(isTrackingSteps = false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startWorkout(workoutType: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val workout = WorkoutSession(
|
||||||
|
id = 0,
|
||||||
|
type = workoutType,
|
||||||
|
date = LocalDate.now(),
|
||||||
|
startTime = LocalDateTime.now(),
|
||||||
|
duration = 0,
|
||||||
|
caloriesBurned = 0,
|
||||||
|
distance = 0f
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.startWorkout(workout)
|
||||||
|
loadFitnessData() // Перезагружаем данные
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endWorkout(workoutId: Long, duration: Int, caloriesBurned: Int, distance: Float) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.endWorkout(workoutId, duration, caloriesBurned, distance)
|
||||||
|
loadFitnessData() // Перезагружаем данные
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSteps(steps: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.updateTodaySteps(steps)
|
||||||
|
val calories = calculateCaloriesFromSteps(steps)
|
||||||
|
val distance = calculateDistanceFromSteps(steps)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
todaySteps = steps,
|
||||||
|
caloriesBurned = calories,
|
||||||
|
distance = distance
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateCaloriesFromSteps(steps: Int): Int {
|
||||||
|
// Приблизительная формула: 1 шаг = 0.04 калории
|
||||||
|
return (steps * 0.04).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateDistanceFromSteps(steps: Int): Float {
|
||||||
|
// Приблизительная формула: 1 шаг = 0.8 метра
|
||||||
|
return (steps * 0.8f) / 1000f // конвертируем в километры
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.health
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
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.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HealthOverviewScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: HealthViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadHealthData()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
SuccessGreenLight.copy(alpha = 0.3f),
|
||||||
|
NeutralWhite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Здоровье",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowBack,
|
||||||
|
contentDescription = "Назад",
|
||||||
|
tint = TextPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.toggleEditMode() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (uiState.isEditMode) Icons.Default.Save else Icons.Default.Edit,
|
||||||
|
contentDescription = if (uiState.isEditMode) "Сохранить" else "Редактировать",
|
||||||
|
tint = SuccessGreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = NeutralWhite.copy(alpha = 0.95f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
TodayHealthCard(
|
||||||
|
uiState = uiState,
|
||||||
|
onUpdateVitals = viewModel::updateVitals,
|
||||||
|
onUpdateMood = viewModel::updateMood,
|
||||||
|
onUpdateEnergy = viewModel::updateEnergyLevel,
|
||||||
|
onUpdateStress = viewModel::updateStressLevel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SymptomsCard(
|
||||||
|
selectedSymptoms = uiState.todaySymptoms,
|
||||||
|
onSymptomsChange = viewModel::updateSymptoms,
|
||||||
|
isEditMode = uiState.isEditMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
VitalsHistoryCard(
|
||||||
|
recentRecords = uiState.recentRecords,
|
||||||
|
onDeleteRecord = viewModel::deleteHealthRecord
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
NotesCard(
|
||||||
|
notes = uiState.todayNotes,
|
||||||
|
onNotesChange = viewModel::updateNotes,
|
||||||
|
isEditMode = uiState.isEditMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TodayHealthCard(
|
||||||
|
uiState: HealthUiState,
|
||||||
|
onUpdateVitals: (Float?, Int?, Int?, Int?, Float?) -> Unit,
|
||||||
|
onUpdateMood: (String) -> Unit,
|
||||||
|
onUpdateEnergy: (Int) -> Unit,
|
||||||
|
onUpdateStress: (Int) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var weight by remember { mutableStateOf(uiState.todayRecord?.weight?.toString() ?: "") }
|
||||||
|
var heartRate by remember { mutableStateOf(uiState.todayRecord?.heartRate?.toString() ?: "") }
|
||||||
|
var bpSystolic by remember { mutableStateOf(uiState.todayRecord?.bloodPressureS?.toString() ?: "") }
|
||||||
|
var bpDiastolic by remember { mutableStateOf(uiState.todayRecord?.bloodPressureD?.toString() ?: "") }
|
||||||
|
var temperature by remember { mutableStateOf("36.6") } // Добавляем температуру по умолчанию
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
.animateContentSize()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Показатели на ${LocalDate.now().format(DateTimeFormatter.ofPattern("d MMMM"))}",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (uiState.isEditMode) {
|
||||||
|
// Режим редактирования
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = weight,
|
||||||
|
onValueChange = { weight = it },
|
||||||
|
label = { Text("Вес (кг)") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = heartRate,
|
||||||
|
onValueChange = { heartRate = it },
|
||||||
|
label = { Text("Пульс") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = bpSystolic,
|
||||||
|
onValueChange = { bpSystolic = it },
|
||||||
|
label = { Text("АД сист.") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = bpDiastolic,
|
||||||
|
onValueChange = { bpDiastolic = it },
|
||||||
|
label = { Text("АД диаст.") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = temperature,
|
||||||
|
onValueChange = { temperature = it },
|
||||||
|
label = { Text("Темп. °C") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onUpdateVitals(
|
||||||
|
weight.toFloatOrNull(),
|
||||||
|
heartRate.toIntOrNull(),
|
||||||
|
bpSystolic.toIntOrNull(),
|
||||||
|
bpDiastolic.toIntOrNull(),
|
||||||
|
temperature.toFloatOrNull()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = SuccessGreen)
|
||||||
|
) {
|
||||||
|
Text("Сохранить показатели")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Режим просмотра
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
VitalMetric(
|
||||||
|
label = "Вес",
|
||||||
|
value = uiState.todayRecord?.weight?.toString() ?: "—",
|
||||||
|
unit = "кг",
|
||||||
|
icon = Icons.Default.MonitorWeight
|
||||||
|
)
|
||||||
|
|
||||||
|
VitalMetric(
|
||||||
|
label = "Пульс",
|
||||||
|
value = uiState.todayRecord?.heartRate?.toString() ?: "—",
|
||||||
|
unit = "bpm",
|
||||||
|
icon = Icons.Default.Favorite
|
||||||
|
)
|
||||||
|
|
||||||
|
VitalMetric(
|
||||||
|
label = "Давление",
|
||||||
|
value = if (uiState.todayRecord?.bloodPressureS != null && uiState.todayRecord.bloodPressureD != null)
|
||||||
|
"${uiState.todayRecord.bloodPressureS}/${uiState.todayRecord.bloodPressureD}" else "—",
|
||||||
|
unit = "",
|
||||||
|
icon = Icons.Default.Favorite // Заменяем на существующую иконку
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Настроение
|
||||||
|
MoodSection(
|
||||||
|
currentMood = uiState.todayRecord?.mood ?: "neutral",
|
||||||
|
onMoodChange = onUpdateMood,
|
||||||
|
isEditMode = uiState.isEditMode
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Уровень энергии и стресса
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
LevelSlider(
|
||||||
|
label = "Энергия",
|
||||||
|
value = uiState.todayRecord?.energyLevel ?: 5,
|
||||||
|
onValueChange = onUpdateEnergy,
|
||||||
|
isEditMode = uiState.isEditMode,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
color = WarningOrange
|
||||||
|
)
|
||||||
|
|
||||||
|
LevelSlider(
|
||||||
|
label = "Стресс",
|
||||||
|
value = uiState.todayRecord?.stressLevel ?: 5,
|
||||||
|
onValueChange = onUpdateStress,
|
||||||
|
isEditMode = uiState.isEditMode,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
color = ErrorRed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VitalMetric(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
unit: String,
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = SuccessGreen,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (unit.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = unit,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MoodSection(
|
||||||
|
currentMood: String,
|
||||||
|
onMoodChange: (String) -> Unit,
|
||||||
|
isEditMode: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = "Настроение",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (isEditMode) {
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(healthMoods) { mood ->
|
||||||
|
FilterChip(
|
||||||
|
onClick = { onMoodChange(mood.key) },
|
||||||
|
label = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(mood.emoji)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(mood.name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selected = currentMood == mood.key,
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = SuccessGreenLight,
|
||||||
|
selectedLabelColor = SuccessGreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val currentMoodData = healthMoods.find { it.key == currentMood } ?: healthMoods[2]
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = currentMoodData.emoji,
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = currentMoodData.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LevelSlider(
|
||||||
|
label: String,
|
||||||
|
value: Int,
|
||||||
|
onValueChange: (Int) -> Unit,
|
||||||
|
isEditMode: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
color: androidx.compose.ui.graphics.Color = SuccessGreen
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = "$label: $value/10",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
if (isEditMode) {
|
||||||
|
Slider(
|
||||||
|
value = value.toFloat(),
|
||||||
|
onValueChange = { onValueChange(it.toInt()) },
|
||||||
|
valueRange = 1f..10f,
|
||||||
|
steps = 8,
|
||||||
|
colors = SliderDefaults.colors(
|
||||||
|
thumbColor = color,
|
||||||
|
activeTrackColor = color
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { value / 10f },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = color,
|
||||||
|
trackColor = color.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SymptomsCard(
|
||||||
|
selectedSymptoms: List<String>,
|
||||||
|
onSymptomsChange: (List<String>) -> Unit,
|
||||||
|
isEditMode: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Симптомы",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
if (selectedSymptoms.isEmpty() && !isEditMode) {
|
||||||
|
Text(
|
||||||
|
text = "Симптомы не отмечены",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(healthSymptoms) { symptom ->
|
||||||
|
FilterChip(
|
||||||
|
onClick = {
|
||||||
|
if (isEditMode) {
|
||||||
|
val newSymptoms = if (selectedSymptoms.contains(symptom)) {
|
||||||
|
selectedSymptoms - symptom
|
||||||
|
} else {
|
||||||
|
selectedSymptoms + symptom
|
||||||
|
}
|
||||||
|
onSymptomsChange(newSymptoms)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(symptom) },
|
||||||
|
selected = selectedSymptoms.contains(symptom),
|
||||||
|
enabled = isEditMode,
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = ErrorRedLight,
|
||||||
|
selectedLabelColor = ErrorRed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VitalsHistoryCard(
|
||||||
|
recentRecords: List<kr.smartsoltech.wellshe.data.entity.HealthRecordEntity>,
|
||||||
|
onDeleteRecord: (kr.smartsoltech.wellshe.data.entity.HealthRecordEntity) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (recentRecords.isNotEmpty()) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "История записей",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
recentRecords.take(5).forEach { record ->
|
||||||
|
HealthRecordItem(
|
||||||
|
record = record,
|
||||||
|
onDelete = { onDeleteRecord(record) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HealthRecordItem(
|
||||||
|
record: kr.smartsoltech.wellshe.data.entity.HealthRecordEntity,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.HealthAndSafety,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = SuccessGreen,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = record.date.format(DateTimeFormatter.ofPattern("d MMMM yyyy")),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val details = mutableListOf<String>()
|
||||||
|
record.weight?.let { details.add("Вес: $it кг") }
|
||||||
|
record.heartRate?.let { details.add("Пульс: $it") }
|
||||||
|
|
||||||
|
if (details.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = details.joinToString(" • "),
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onDelete,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = "Удалить",
|
||||||
|
tint = ErrorRed,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotesCard(
|
||||||
|
notes: String,
|
||||||
|
onNotesChange: (String) -> Unit,
|
||||||
|
isEditMode: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Заметки",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
if (isEditMode) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = notes,
|
||||||
|
onValueChange = onNotesChange,
|
||||||
|
placeholder = { Text("Добавьте заметки о самочувствии...") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 3
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (notes.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = notes,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Заметки не добавлены",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Данные для UI
|
||||||
|
private data class HealthMoodData(val key: String, val name: String, val emoji: String)
|
||||||
|
|
||||||
|
private val healthMoods = listOf(
|
||||||
|
HealthMoodData("very_sad", "Очень плохо", "😢"),
|
||||||
|
HealthMoodData("sad", "Плохо", "😔"),
|
||||||
|
HealthMoodData("neutral", "Нормально", "😐"),
|
||||||
|
HealthMoodData("happy", "Хорошо", "😊"),
|
||||||
|
HealthMoodData("very_happy", "Отлично", "😄")
|
||||||
|
)
|
||||||
|
|
||||||
|
private val healthSymptoms = listOf(
|
||||||
|
"Головная боль", "Усталость", "Тошнота", "Головокружение",
|
||||||
|
"Боль в спине", "Боль в суставах", "Бессонница", "Стресс",
|
||||||
|
"Простуда", "Аллергия", "Боль в животе", "Другое"
|
||||||
|
)
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.health
|
||||||
|
|
||||||
|
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.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HealthScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: HealthViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadHealthData()
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(0xFFE8F5E8).copy(alpha = 0.7f),
|
||||||
|
NeutralWhite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
HealthHeaderCard(
|
||||||
|
lastUpdate = uiState.lastUpdateDate,
|
||||||
|
onAddRecord = { viewModel.toggleEditMode() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
VitalSignsCard(
|
||||||
|
healthRecord = uiState.todayRecord,
|
||||||
|
isEditMode = uiState.isEditMode,
|
||||||
|
onRecordUpdate = { /* Заглушка - метод updateTodayRecord не существует */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
QuickMetricsRow(
|
||||||
|
healthRecord = uiState.todayRecord
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
WeightTrackingCard(
|
||||||
|
currentWeight = uiState.todayRecord?.weight ?: 0f,
|
||||||
|
weightHistory = uiState.weeklyWeights,
|
||||||
|
isEditMode = uiState.isEditMode,
|
||||||
|
onWeightUpdate = { weight ->
|
||||||
|
uiState.todayRecord?.let { record ->
|
||||||
|
// Заглушка - метод updateTodayRecord не существует
|
||||||
|
// viewModel.updateTodayRecord(record.copy(weight = weight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
HealthTipsCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RecentRecordsCard(
|
||||||
|
records = uiState.recentRecords,
|
||||||
|
onRecordClick = { /* TODO: Navigate to record details */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
if (uiState.isEditMode) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.toggleEditMode() },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Отменить")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { /* Заглушка - метод saveHealthRecord не существует */ },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Сохранить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.error != null) {
|
||||||
|
LaunchedEffect(uiState.error) {
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HealthHeaderCard(
|
||||||
|
lastUpdate: LocalDate?,
|
||||||
|
onAddRecord: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.HealthAndSafety,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Мониторинг здоровья",
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (lastUpdate != null) {
|
||||||
|
"Последнее обновление: ${lastUpdate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))}"
|
||||||
|
} else {
|
||||||
|
"Добавьте первую запись"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onAddRecord,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
containerColor = Color(0xFF4CAF50),
|
||||||
|
contentColor = NeutralWhite
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = "Добавить запись",
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VitalSignsCard(
|
||||||
|
healthRecord: HealthRecordEntity?,
|
||||||
|
isEditMode: Boolean,
|
||||||
|
onRecordUpdate: (HealthRecordEntity) -> 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 (isEditMode) {
|
||||||
|
var systolic by remember { mutableStateOf(healthRecord?.bloodPressureS?.toString() ?: "") }
|
||||||
|
var diastolic by remember { mutableStateOf(healthRecord?.bloodPressureD?.toString() ?: "") }
|
||||||
|
var heartRate by remember { mutableStateOf(healthRecord?.heartRate?.toString() ?: "") }
|
||||||
|
var notes by remember { mutableStateOf(healthRecord?.notes ?: "") }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = systolic,
|
||||||
|
onValueChange = {
|
||||||
|
systolic = it
|
||||||
|
it.toIntOrNull()?.let { sys ->
|
||||||
|
val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now())
|
||||||
|
onRecordUpdate(currentRecord.copy(bloodPressureS = sys))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Систолическое") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = diastolic,
|
||||||
|
onValueChange = {
|
||||||
|
diastolic = it
|
||||||
|
it.toIntOrNull()?.let { dia ->
|
||||||
|
val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now())
|
||||||
|
onRecordUpdate(currentRecord.copy(bloodPressureD = dia))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Диастолическое") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = heartRate,
|
||||||
|
onValueChange = {
|
||||||
|
heartRate = it
|
||||||
|
it.toIntOrNull()?.let { hr ->
|
||||||
|
val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now())
|
||||||
|
onRecordUpdate(currentRecord.copy(heartRate = hr))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Пульс (уд/мин)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = notes,
|
||||||
|
onValueChange = {
|
||||||
|
notes = it
|
||||||
|
val currentRecord = healthRecord ?: HealthRecordEntity(date = LocalDate.now())
|
||||||
|
onRecordUpdate(currentRecord.copy(notes = it))
|
||||||
|
},
|
||||||
|
label = { Text("Заметки") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (healthRecord == null) {
|
||||||
|
Text(
|
||||||
|
text = "Нет данных за сегодня",
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
VitalSignItem(
|
||||||
|
icon = Icons.Default.Favorite,
|
||||||
|
label = "Давление",
|
||||||
|
value = "${healthRecord.bloodPressureS ?: 0}/${healthRecord.bloodPressureD ?: 0}",
|
||||||
|
unit = "мм рт. ст.",
|
||||||
|
color = Color(0xFFE91E63)
|
||||||
|
)
|
||||||
|
|
||||||
|
VitalSignItem(
|
||||||
|
icon = Icons.Default.MonitorHeart,
|
||||||
|
label = "Пульс",
|
||||||
|
value = (healthRecord.heartRate ?: 0).toString(),
|
||||||
|
unit = "уд/мин",
|
||||||
|
color = Color(0xFFFF5722)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (healthRecord.notes.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color(0xFFF5F5F5)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = healthRecord.notes,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickMetricsRow(
|
||||||
|
healthRecord: HealthRecordEntity?,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
val metrics = listOf(
|
||||||
|
Triple("Температура", "36.6°C", Icons.Default.Thermostat),
|
||||||
|
Triple("ИМТ", if (healthRecord?.weight != null && healthRecord.weight > 0) "%.1f".format(calculateBMI(healthRecord.weight)) else "—", Icons.Default.Scale),
|
||||||
|
Triple("Гидратация", "85%", Icons.Default.WaterDrop),
|
||||||
|
Triple("Активность", "Умеренная", Icons.Default.DirectionsRun)
|
||||||
|
)
|
||||||
|
|
||||||
|
items(metrics) { (label, value, icon) ->
|
||||||
|
QuickMetricCard(
|
||||||
|
label = label,
|
||||||
|
value = value,
|
||||||
|
icon = icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickMetricCard(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.width(120.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeightTrackingCard(
|
||||||
|
currentWeight: Float,
|
||||||
|
weightHistory: Map<LocalDate, Float>,
|
||||||
|
isEditMode: Boolean,
|
||||||
|
onWeightUpdate: (Float) -> 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 (isEditMode) {
|
||||||
|
var weight by remember { mutableStateOf(if (currentWeight > 0) currentWeight.toString() else "") }
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = weight,
|
||||||
|
onValueChange = {
|
||||||
|
weight = it
|
||||||
|
it.toFloatOrNull()?.let { w ->
|
||||||
|
onWeightUpdate(w)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Вес (кг)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = if (currentWeight > 0) "%.1f кг".format(currentWeight) else "Не указан",
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFF2196F3)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Текущий вес",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentWeight > 0) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "ИМТ: %.1f".format(calculateBMI(currentWeight)),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = getBMICategory(calculateBMI(currentWeight)),
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HealthTipsCard(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color(0xFFE8F5E8)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Lightbulb,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Совет дня",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Регулярное измерение артериального давления поможет выявить проблемы на ранней стадии. Измеряйте давление в спокойном состоянии, желательно в одно и то же время.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentRecordsCard(
|
||||||
|
records: List<HealthRecordEntity>,
|
||||||
|
onRecordClick: (HealthRecordEntity) -> 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 (records.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Пока нет записей",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
records.take(3).forEach { record ->
|
||||||
|
HealthRecordItem(
|
||||||
|
record = record,
|
||||||
|
onClick = { onRecordClick(record) }
|
||||||
|
)
|
||||||
|
if (record != records.last()) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HealthRecordItem(
|
||||||
|
record: HealthRecordEntity,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.HealthAndSafety,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = record.date.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")),
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Давление: ${record.bloodPressureS ?: 0}/${record.bloodPressureD ?: 0}, Пульс: ${record.heartRate ?: 0}",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = "Просмотреть",
|
||||||
|
tint = TextSecondary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VitalSignItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
unit: String,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = unit,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateBMI(weight: Float, height: Float = 165f): Float {
|
||||||
|
return weight / ((height / 100) * (height / 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBMICategory(bmi: Float): String {
|
||||||
|
return when {
|
||||||
|
bmi < 18.5 -> "Недостаточный вес"
|
||||||
|
bmi < 25 -> "Нормальный вес"
|
||||||
|
bmi < 30 -> "Избыточный вес"
|
||||||
|
else -> "Ожирение"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.health
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.WellSheRepository
|
||||||
|
import java.time.LocalDate
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class HealthUiState(
|
||||||
|
val todayRecord: HealthRecordEntity? = null,
|
||||||
|
val recentRecords: List<HealthRecordEntity> = emptyList(),
|
||||||
|
val weeklyWeights: Map<LocalDate, Float> = emptyMap(),
|
||||||
|
val lastUpdateDate: LocalDate? = null,
|
||||||
|
val todaySymptoms: List<String> = emptyList(),
|
||||||
|
val todayNotes: String = "",
|
||||||
|
val isEditMode: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class HealthViewModel @Inject constructor(
|
||||||
|
private val repository: WellSheRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(HealthUiState())
|
||||||
|
val uiState: StateFlow<HealthUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadHealthData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Загружаем данные о здоровье за сегодня
|
||||||
|
repository.getTodayHealthData().collect { todayRecord ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
todayRecord = todayRecord,
|
||||||
|
lastUpdateDate = todayRecord?.date,
|
||||||
|
todaySymptoms = todayRecord?.symptoms?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
||||||
|
todayNotes = todayRecord?.notes ?: "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем недельные данные веса
|
||||||
|
loadWeeklyWeights()
|
||||||
|
|
||||||
|
// Загружаем последние записи
|
||||||
|
loadRecentRecords()
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadWeeklyWeights() {
|
||||||
|
try {
|
||||||
|
// Временная заглушка - методы репозитория пока не реализованы
|
||||||
|
val weightsMap = emptyMap<LocalDate, Float>()
|
||||||
|
_uiState.value = _uiState.value.copy(weeklyWeights = weightsMap)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Игнорируем ошибки загрузки весов
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadRecentRecords() {
|
||||||
|
try {
|
||||||
|
// Временная заглушка - методы репозитория пока не реализованы
|
||||||
|
val records = emptyList<HealthRecordEntity>()
|
||||||
|
_uiState.value = _uiState.value.copy(recentRecords = records)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Игнорируем ошибки загрузки записей
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateVitals(weight: Float?, heartRate: Int?, bpSystolic: Int?, bpDiastolic: Int?, temperature: Float?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val currentRecord = _uiState.value.todayRecord
|
||||||
|
val updatedRecord = if (currentRecord != null) {
|
||||||
|
currentRecord.copy(
|
||||||
|
weight = weight,
|
||||||
|
heartRate = heartRate,
|
||||||
|
bloodPressureS = bpSystolic,
|
||||||
|
bloodPressureD = bpDiastolic,
|
||||||
|
temperature = temperature
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
HealthRecordEntity(
|
||||||
|
date = LocalDate.now(),
|
||||||
|
weight = weight,
|
||||||
|
heartRate = heartRate,
|
||||||
|
bloodPressureS = bpSystolic,
|
||||||
|
bloodPressureD = bpDiastolic,
|
||||||
|
temperature = temperature
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Временная заглушка - метод saveHealthRecord пока не реализован
|
||||||
|
// repository.saveHealthRecord(updatedRecord)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateMood(mood: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val currentRecord = _uiState.value.todayRecord
|
||||||
|
val updatedRecord = if (currentRecord != null) {
|
||||||
|
currentRecord.copy(mood = mood)
|
||||||
|
} else {
|
||||||
|
HealthRecordEntity(
|
||||||
|
date = LocalDate.now(),
|
||||||
|
mood = mood
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Временная заглушка - метод saveHealthRecord пока не реализован
|
||||||
|
// repository.saveHealthRecord(updatedRecord)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateEnergyLevel(energy: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val currentRecord = _uiState.value.todayRecord
|
||||||
|
val updatedRecord = if (currentRecord != null) {
|
||||||
|
currentRecord.copy(energyLevel = energy)
|
||||||
|
} else {
|
||||||
|
HealthRecordEntity(
|
||||||
|
date = LocalDate.now(),
|
||||||
|
energyLevel = energy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Временная заглушка - метод saveHealthRecord пока не реализован
|
||||||
|
// repository.saveHealthRecord(updatedRecord)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateStressLevel(stress: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val currentRecord = _uiState.value.todayRecord
|
||||||
|
val updatedRecord = if (currentRecord != null) {
|
||||||
|
currentRecord.copy(stressLevel = stress)
|
||||||
|
} else {
|
||||||
|
HealthRecordEntity(
|
||||||
|
date = LocalDate.now(),
|
||||||
|
stressLevel = stress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Временная заглушка - метод saveHealthRecord пока не реализован
|
||||||
|
// repository.saveHealthRecord(updatedRecord)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSymptoms(symptoms: List<String>) {
|
||||||
|
_uiState.value = _uiState.value.copy(todaySymptoms = symptoms)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val currentRecord = _uiState.value.todayRecord
|
||||||
|
val symptomsString = symptoms.joinToString(",")
|
||||||
|
val updatedRecord = if (currentRecord != null) {
|
||||||
|
currentRecord.copy(symptoms = symptomsString)
|
||||||
|
} else {
|
||||||
|
HealthRecordEntity(
|
||||||
|
date = LocalDate.now(),
|
||||||
|
symptoms = symptomsString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Временная заглушка - метод saveHealthRecord пока не реализован
|
||||||
|
// repository.saveHealthRecord(updatedRecord)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNotes(notes: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(todayNotes = notes)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val currentRecord = _uiState.value.todayRecord
|
||||||
|
val updatedRecord = if (currentRecord != null) {
|
||||||
|
currentRecord.copy(notes = notes)
|
||||||
|
} else {
|
||||||
|
HealthRecordEntity(
|
||||||
|
date = LocalDate.now(),
|
||||||
|
notes = notes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Временная заглушка - метод saveHealthRecord пока не реализован
|
||||||
|
// repository.saveHealthRecord(updatedRecord)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteHealthRecord(record: HealthRecordEntity) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// Временная заглушка - метод deleteHealthRecord пока не реализован
|
||||||
|
// repository.deleteHealthRecord(record.id)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleEditMode() {
|
||||||
|
_uiState.value = _uiState.value.copy(isEditMode = !_uiState.value.isEditMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import kr.smartsoltech.wellshe.ui.dashboard.DashboardScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.cycle.CycleScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.workouts.WorkoutsScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.profile.ProfileScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.settings.SettingsScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.health.HealthOverviewScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.sleep.SleepTrackingScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
|
||||||
|
sealed class Screen(val route: String, val title: String, val icon: ImageVector) {
|
||||||
|
object Dashboard : Screen("dashboard", "Главная", Icons.Default.Home)
|
||||||
|
object Cycle : Screen("cycle", "Цикл", Icons.Default.Favorite)
|
||||||
|
object Workouts : Screen("workouts", "Тренировки", Icons.Default.FitnessCenter)
|
||||||
|
object Health : Screen("health", "Здоровье", Icons.Default.HealthAndSafety)
|
||||||
|
object Profile : Screen("profile", "Профиль", Icons.Default.Person)
|
||||||
|
|
||||||
|
// Дополнительные экраны без навигации в нижнем меню
|
||||||
|
object Settings : Screen("settings", "Настройки", Icons.Default.Settings)
|
||||||
|
object Sleep : Screen("sleep", "Сон", Icons.Default.Bedtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WellSheNavigation() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
BottomNavigationBar(
|
||||||
|
navController = navController,
|
||||||
|
onNavigate = { route ->
|
||||||
|
navController.navigate(route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Screen.Dashboard.route,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
composable(Screen.Dashboard.route) {
|
||||||
|
DashboardScreen(
|
||||||
|
onNavigate = { route -> navController.navigate(route) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.Cycle.route) {
|
||||||
|
CycleScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.Workouts.route) {
|
||||||
|
WorkoutsScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.Health.route) {
|
||||||
|
HealthOverviewScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.Profile.route) {
|
||||||
|
ProfileScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.Settings.route) {
|
||||||
|
SettingsScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.Sleep.route) {
|
||||||
|
SleepTrackingScreen(
|
||||||
|
onBackClick = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BottomNavigationBar(
|
||||||
|
navController: androidx.navigation.NavController,
|
||||||
|
onNavigate: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentDestination = navBackStackEntry?.destination
|
||||||
|
|
||||||
|
NavigationBar(
|
||||||
|
containerColor = NeutralWhite,
|
||||||
|
tonalElevation = 8.dp
|
||||||
|
) {
|
||||||
|
bottomNavItems.forEach { item ->
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = item.icon,
|
||||||
|
contentDescription = item.title,
|
||||||
|
tint = if (currentDestination?.hierarchy?.any { it.route == item.route } == true)
|
||||||
|
PrimaryPink else NeutralGray
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
color = if (currentDestination?.hierarchy?.any { it.route == item.route } == true)
|
||||||
|
PrimaryPink else NeutralGray
|
||||||
|
)
|
||||||
|
},
|
||||||
|
selected = currentDestination?.hierarchy?.any { it.route == item.route } == true,
|
||||||
|
onClick = {
|
||||||
|
onNavigate(item.route)
|
||||||
|
},
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = PrimaryPink,
|
||||||
|
selectedTextColor = PrimaryPink,
|
||||||
|
indicatorColor = PrimaryPinkLight
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class BottomNavItem(
|
||||||
|
val title: String,
|
||||||
|
val icon: ImageVector,
|
||||||
|
val route: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private val bottomNavItems = listOf(
|
||||||
|
BottomNavItem(
|
||||||
|
title = "Главная",
|
||||||
|
icon = Icons.Default.Home,
|
||||||
|
route = Screen.Dashboard.route
|
||||||
|
),
|
||||||
|
BottomNavItem(
|
||||||
|
title = "Цикл",
|
||||||
|
icon = Icons.Default.CalendarMonth,
|
||||||
|
route = Screen.Cycle.route
|
||||||
|
),
|
||||||
|
BottomNavItem(
|
||||||
|
title = "Здоровье",
|
||||||
|
icon = Icons.Default.Favorite,
|
||||||
|
route = Screen.Health.route
|
||||||
|
),
|
||||||
|
BottomNavItem(
|
||||||
|
title = "Профиль",
|
||||||
|
icon = Icons.Default.Person,
|
||||||
|
route = Screen.Profile.route
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.profile
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ProfileScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: ProfileViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Профиль") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.Filled.ArrowBack, contentDescription = "Назад")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProfileContent(
|
||||||
|
user = uiState.user,
|
||||||
|
onUpdateProfile = { user ->
|
||||||
|
viewModel.updateProfile(user)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.error?.let { error ->
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileContent(
|
||||||
|
user: kr.smartsoltech.wellshe.domain.model.User,
|
||||||
|
onUpdateProfile: (kr.smartsoltech.wellshe.domain.model.User) -> Unit
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf(user.name) }
|
||||||
|
var email by remember { mutableStateOf(user.email) }
|
||||||
|
var age by remember { mutableStateOf(user.age.toString()) }
|
||||||
|
var height by remember { mutableStateOf(user.height.toString()) }
|
||||||
|
var weight by remember { mutableStateOf(user.weight.toString()) }
|
||||||
|
|
||||||
|
Card {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Основная информация",
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Имя") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = { Text("Email") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = age,
|
||||||
|
onValueChange = { age = it },
|
||||||
|
label = { Text("Возраст") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = height,
|
||||||
|
onValueChange = { height = it },
|
||||||
|
label = { Text("Рост (см)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = weight,
|
||||||
|
onValueChange = { weight = it },
|
||||||
|
label = { Text("Вес (кг)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onUpdateProfile(
|
||||||
|
user.copy(
|
||||||
|
name = name,
|
||||||
|
email = email,
|
||||||
|
age = age.toIntOrNull() ?: user.age,
|
||||||
|
height = height.toFloatOrNull() ?: user.height,
|
||||||
|
weight = weight.toFloatOrNull() ?: user.weight
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Сохранить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Цели",
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
|
||||||
|
Text("Вода: ${user.dailyWaterGoal} л/день")
|
||||||
|
Text("Шаги: ${user.dailyStepsGoal} шагов/день")
|
||||||
|
Text("Калории: ${user.dailyCaloriesGoal} ккал/день")
|
||||||
|
Text("Сон: ${user.dailySleepGoal} часов/день")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.profile
|
||||||
|
|
||||||
|
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 kr.smartsoltech.wellshe.data.repository.WellSheRepository
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.User
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class ProfileUiState(
|
||||||
|
val user: User = User(),
|
||||||
|
val isEditMode: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ProfileViewModel @Inject constructor(
|
||||||
|
private val repository: WellSheRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ProfileUiState())
|
||||||
|
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadUserProfile() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
repository.getUserProfile().collect { user ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
user = user,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleEditMode() {
|
||||||
|
val newEditMode = !_uiState.value.isEditMode
|
||||||
|
_uiState.value = _uiState.value.copy(isEditMode = newEditMode)
|
||||||
|
|
||||||
|
if (!newEditMode) {
|
||||||
|
// Сохраняем данные при выходе из режима редактирования
|
||||||
|
saveUserProfile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProfile(user: User) {
|
||||||
|
_uiState.value = _uiState.value.copy(user = user)
|
||||||
|
saveUserProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateUserProfile(user: User) {
|
||||||
|
_uiState.value = _uiState.value.copy(user = user)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGoals(waterGoal: String, stepsGoal: String, caloriesGoal: String) {
|
||||||
|
val currentUser = _uiState.value.user
|
||||||
|
val updatedUser = currentUser.copy(
|
||||||
|
dailyWaterGoal = waterGoal.toFloatOrNull() ?: currentUser.dailyWaterGoal,
|
||||||
|
dailyStepsGoal = stepsGoal.toIntOrNull() ?: currentUser.dailyStepsGoal,
|
||||||
|
dailyCaloriesGoal = caloriesGoal.toIntOrNull() ?: currentUser.dailyCaloriesGoal
|
||||||
|
)
|
||||||
|
_uiState.value = _uiState.value.copy(user = updatedUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveUserProfile() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.updateUserProfile(_uiState.value.user)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.settings
|
||||||
|
|
||||||
|
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.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
PrimaryPinkLight.copy(alpha = 0.2f),
|
||||||
|
NeutralWhite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
SettingsHeader()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
NotificationSettingsCard(
|
||||||
|
isWaterReminderEnabled = uiState.isWaterReminderEnabled,
|
||||||
|
isCycleReminderEnabled = uiState.isCycleReminderEnabled,
|
||||||
|
isSleepReminderEnabled = uiState.isSleepReminderEnabled,
|
||||||
|
onWaterReminderToggle = viewModel::toggleWaterReminder,
|
||||||
|
onCycleReminderToggle = viewModel::toggleCycleReminder,
|
||||||
|
onSleepReminderToggle = viewModel::toggleSleepReminder
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
CycleSettingsCard(
|
||||||
|
cycleLength = uiState.cycleLength,
|
||||||
|
periodLength = uiState.periodLength,
|
||||||
|
onCycleLengthChange = viewModel::updateCycleLength,
|
||||||
|
onPeriodLengthChange = viewModel::updatePeriodLength
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
GoalsSettingsCard(
|
||||||
|
waterGoal = uiState.waterGoal,
|
||||||
|
stepsGoal = uiState.stepsGoal,
|
||||||
|
sleepGoal = uiState.sleepGoal,
|
||||||
|
onWaterGoalChange = viewModel::updateWaterGoal,
|
||||||
|
onStepsGoalChange = viewModel::updateStepsGoal,
|
||||||
|
onSleepGoalChange = viewModel::updateSleepGoal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
AppearanceSettingsCard(
|
||||||
|
isDarkTheme = uiState.isDarkTheme,
|
||||||
|
onThemeToggle = viewModel::toggleTheme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
DataManagementCard(
|
||||||
|
onExportData = viewModel::exportData,
|
||||||
|
onImportData = viewModel::importData,
|
||||||
|
onClearData = viewModel::clearAllData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsHeader(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Settings,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = PrimaryPink,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Настройки",
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Персонализируйте приложение",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotificationSettingsCard(
|
||||||
|
isWaterReminderEnabled: Boolean,
|
||||||
|
isCycleReminderEnabled: Boolean,
|
||||||
|
isSleepReminderEnabled: Boolean,
|
||||||
|
onWaterReminderToggle: (Boolean) -> Unit,
|
||||||
|
onCycleReminderToggle: (Boolean) -> Unit,
|
||||||
|
onSleepReminderToggle: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
SettingsCard(
|
||||||
|
title = "Уведомления",
|
||||||
|
icon = Icons.Default.Notifications,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
SettingsSwitchItem(
|
||||||
|
title = "Напоминания о воде",
|
||||||
|
subtitle = "Регулярные напоминания пить воду",
|
||||||
|
isChecked = isWaterReminderEnabled,
|
||||||
|
onCheckedChange = onWaterReminderToggle
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
SettingsSwitchItem(
|
||||||
|
title = "Уведомления цикла",
|
||||||
|
subtitle = "Напоминания о менструальном цикле",
|
||||||
|
isChecked = isCycleReminderEnabled,
|
||||||
|
onCheckedChange = onCycleReminderToggle
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
SettingsSwitchItem(
|
||||||
|
title = "Напоминания о сне",
|
||||||
|
subtitle = "Уведомления о режиме сна",
|
||||||
|
isChecked = isSleepReminderEnabled,
|
||||||
|
onCheckedChange = onSleepReminderToggle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CycleSettingsCard(
|
||||||
|
cycleLength: Int,
|
||||||
|
periodLength: Int,
|
||||||
|
onCycleLengthChange: (Int) -> Unit,
|
||||||
|
onPeriodLengthChange: (Int) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
SettingsCard(
|
||||||
|
title = "Настройки цикла",
|
||||||
|
icon = Icons.Default.CalendarMonth,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
SettingsSliderItem(
|
||||||
|
title = "Длина цикла",
|
||||||
|
subtitle = "Количество дней в цикле",
|
||||||
|
value = cycleLength.toFloat(),
|
||||||
|
valueRange = 21f..35f,
|
||||||
|
steps = 13,
|
||||||
|
onValueChange = { onCycleLengthChange(it.toInt()) },
|
||||||
|
valueFormatter = { "${it.toInt()} дней" }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
SettingsSliderItem(
|
||||||
|
title = "Длина менструации",
|
||||||
|
subtitle = "Количество дней менструации",
|
||||||
|
value = periodLength.toFloat(),
|
||||||
|
valueRange = 3f..8f,
|
||||||
|
steps = 4,
|
||||||
|
onValueChange = { onPeriodLengthChange(it.toInt()) },
|
||||||
|
valueFormatter = { "${it.toInt()} дней" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GoalsSettingsCard(
|
||||||
|
waterGoal: Float,
|
||||||
|
stepsGoal: Int,
|
||||||
|
sleepGoal: Float,
|
||||||
|
onWaterGoalChange: (Float) -> Unit,
|
||||||
|
onStepsGoalChange: (Int) -> Unit,
|
||||||
|
onSleepGoalChange: (Float) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
SettingsCard(
|
||||||
|
title = "Ежедневные цели",
|
||||||
|
icon = Icons.Default.TrackChanges,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
SettingsSliderItem(
|
||||||
|
title = "Цель по воде",
|
||||||
|
subtitle = "Количество воды в день",
|
||||||
|
value = waterGoal,
|
||||||
|
valueRange = 1.5f..4.0f,
|
||||||
|
steps = 24,
|
||||||
|
onValueChange = onWaterGoalChange,
|
||||||
|
valueFormatter = { "%.1f л".format(it) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
SettingsSliderItem(
|
||||||
|
title = "Цель по шагам",
|
||||||
|
subtitle = "Количество шагов в день",
|
||||||
|
value = stepsGoal.toFloat(),
|
||||||
|
valueRange = 5000f..20000f,
|
||||||
|
steps = 29,
|
||||||
|
onValueChange = { onStepsGoalChange(it.toInt()) },
|
||||||
|
valueFormatter = { "${(it/1000).toInt()}k шагов" }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
SettingsSliderItem(
|
||||||
|
title = "Цель по сну",
|
||||||
|
subtitle = "Количество часов сна",
|
||||||
|
value = sleepGoal,
|
||||||
|
valueRange = 6.0f..10.0f,
|
||||||
|
steps = 7,
|
||||||
|
onValueChange = onSleepGoalChange,
|
||||||
|
valueFormatter = { "%.1f часов".format(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppearanceSettingsCard(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
onThemeToggle: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
SettingsCard(
|
||||||
|
title = "Внешний вид",
|
||||||
|
icon = Icons.Default.Palette,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
SettingsSwitchItem(
|
||||||
|
title = "Темная тема",
|
||||||
|
subtitle = "Использовать темную тему приложения",
|
||||||
|
isChecked = isDarkTheme,
|
||||||
|
onCheckedChange = onThemeToggle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DataManagementCard(
|
||||||
|
onExportData: () -> Unit,
|
||||||
|
onImportData: () -> Unit,
|
||||||
|
onClearData: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
SettingsCard(
|
||||||
|
title = "Управление данными",
|
||||||
|
icon = Icons.Default.Storage,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
SettingsActionItem(
|
||||||
|
title = "Экспорт данных",
|
||||||
|
subtitle = "Сохранить данные в файл",
|
||||||
|
icon = Icons.Default.Download,
|
||||||
|
onClick = onExportData
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
SettingsActionItem(
|
||||||
|
title = "Импорт данных",
|
||||||
|
subtitle = "Загрузить данные из файла",
|
||||||
|
icon = Icons.Default.Upload,
|
||||||
|
onClick = onImportData
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
SettingsActionItem(
|
||||||
|
title = "Очистить все данные",
|
||||||
|
subtitle = "Удалить все сохраненные данные",
|
||||||
|
icon = Icons.Default.DeleteForever,
|
||||||
|
onClick = onClearData,
|
||||||
|
isDestructive = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsCard(
|
||||||
|
title: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable ColumnScope.() -> Unit
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = PrimaryPink,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsSwitchItem(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
isChecked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Switch(
|
||||||
|
checked = isChecked,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = NeutralWhite,
|
||||||
|
checkedTrackColor = PrimaryPink,
|
||||||
|
uncheckedThumbColor = NeutralWhite,
|
||||||
|
uncheckedTrackColor = Color.Gray.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsSliderItem(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
value: Float,
|
||||||
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
|
steps: Int,
|
||||||
|
onValueChange: (Float) -> Unit,
|
||||||
|
valueFormatter: (Float) -> String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = valueFormatter(value),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = PrimaryPink
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
valueRange = valueRange,
|
||||||
|
steps = steps,
|
||||||
|
colors = SliderDefaults.colors(
|
||||||
|
thumbColor = PrimaryPink,
|
||||||
|
activeTrackColor = PrimaryPink,
|
||||||
|
inactiveTrackColor = Color.Gray.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsActionItem(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
isDestructive: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (isDestructive) Color(0xFFE53E3E) else PrimaryPink,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (isDestructive) Color(0xFFE53E3E) else TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = if (isDestructive) Color(0xFFE53E3E).copy(alpha = 0.7f) else TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = "Выполнить",
|
||||||
|
tint = TextSecondary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.settings
|
||||||
|
|
||||||
|
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 kr.smartsoltech.wellshe.data.repository.WellSheRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class SettingsUiState(
|
||||||
|
val isWaterReminderEnabled: Boolean = true,
|
||||||
|
val isCycleReminderEnabled: Boolean = true,
|
||||||
|
val isSleepReminderEnabled: Boolean = true,
|
||||||
|
val cycleLength: Int = 28,
|
||||||
|
val periodLength: Int = 5,
|
||||||
|
val waterGoal: Float = 2.5f,
|
||||||
|
val stepsGoal: Int = 10000,
|
||||||
|
val sleepGoal: Float = 8.0f,
|
||||||
|
val isDarkTheme: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SettingsViewModel @Inject constructor(
|
||||||
|
private val repository: WellSheRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||||
|
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadSettings() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
repository.getSettings().collect { settings ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isWaterReminderEnabled = settings.isWaterReminderEnabled,
|
||||||
|
isCycleReminderEnabled = settings.isCycleReminderEnabled,
|
||||||
|
isSleepReminderEnabled = settings.isSleepReminderEnabled,
|
||||||
|
cycleLength = settings.cycleLength,
|
||||||
|
periodLength = settings.periodLength,
|
||||||
|
waterGoal = settings.waterGoal,
|
||||||
|
stepsGoal = settings.stepsGoal,
|
||||||
|
sleepGoal = settings.sleepGoal,
|
||||||
|
isDarkTheme = settings.isDarkTheme,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleWaterReminder(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.updateWaterReminderSetting(enabled)
|
||||||
|
_uiState.value = _uiState.value.copy(isWaterReminderEnabled = enabled)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleCycleReminder(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.updateCycleReminderSetting(enabled)
|
||||||
|
_uiState.value = _uiState.value.copy(isCycleReminderEnabled = enabled)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleSleepReminder(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.updateSleepReminderSetting(enabled)
|
||||||
|
_uiState.value = _uiState.value.copy(isSleepReminderEnabled = enabled)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCycleLength(length: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.updateCycleLength(length)
|
||||||
|
_uiState.value = _uiState.value.copy(cycleLength = length)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePeriodLength(length: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.updatePeriodLength(length)
|
||||||
|
_uiState.value = _uiState.value.copy(periodLength = length)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateWaterGoal(goal: Float) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.updateWaterGoal(goal)
|
||||||
|
_uiState.value = _uiState.value.copy(waterGoal = goal)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateStepsGoal(goal: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.updateStepsGoal(goal)
|
||||||
|
_uiState.value = _uiState.value.copy(stepsGoal = goal)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSleepGoal(goal: Float) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.updateSleepGoal(goal)
|
||||||
|
_uiState.value = _uiState.value.copy(sleepGoal = goal)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleTheme(isDark: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.updateThemeSetting(isDark)
|
||||||
|
_uiState.value = _uiState.value.copy(isDarkTheme = isDark)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.exportUserData()
|
||||||
|
// TODO: Показать уведомление об успешном экспорте
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.importUserData()
|
||||||
|
loadSettings() // Перезагружаем настройки
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAllData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.clearAllUserData()
|
||||||
|
loadSettings() // Перезагружаем настройки
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,875 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.sleep
|
||||||
|
|
||||||
|
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.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.SleepLogEntity
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SleepScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SleepViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadSleepData()
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(0xFF3F51B5).copy(alpha = 0.2f),
|
||||||
|
NeutralWhite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
SleepOverviewCard(
|
||||||
|
lastNightSleep = uiState.lastNightSleep,
|
||||||
|
sleepGoal = uiState.sleepGoal,
|
||||||
|
weeklyAverage = uiState.weeklyAverage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SleepTrackerCard(
|
||||||
|
isTracking = uiState.isTracking,
|
||||||
|
currentSleep = uiState.currentSleep,
|
||||||
|
onStartTracking = viewModel::startSleepTracking,
|
||||||
|
onStopTracking = viewModel::stopSleepTracking
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SleepQualityCard(
|
||||||
|
todayQuality = uiState.todayQuality,
|
||||||
|
isEditMode = uiState.isEditMode,
|
||||||
|
onQualityUpdate = viewModel::updateSleepQuality,
|
||||||
|
onToggleEdit = viewModel::toggleEditMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
WeeklySleepChart(
|
||||||
|
weeklyData = uiState.weeklyData,
|
||||||
|
sleepGoal = uiState.sleepGoal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SleepInsightsCard(
|
||||||
|
insights = uiState.insights
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SleepTipsCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RecentSleepLogsCard(
|
||||||
|
sleepLogs = uiState.recentLogs,
|
||||||
|
onLogClick = { /* TODO: Navigate to sleep log details */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.error != null) {
|
||||||
|
LaunchedEffect(uiState.error) {
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepOverviewCard(
|
||||||
|
lastNightSleep: SleepLogEntity?,
|
||||||
|
sleepGoal: Float,
|
||||||
|
weeklyAverage: Float,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val sleepDuration = lastNightSleep?.duration ?: 0f
|
||||||
|
val progress by animateFloatAsState(
|
||||||
|
targetValue = if (sleepGoal > 0) (sleepDuration / sleepGoal).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
|
||||||
|
) {
|
||||||
|
SleepProgressIndicator(
|
||||||
|
progress = progress,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Bedtime,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF3F51B5),
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = if (sleepDuration > 0) "%.1f ч".format(sleepDuration) else "—",
|
||||||
|
style = MaterialTheme.typography.headlineLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFF3F51B5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "из %.1f ч".format(sleepGoal),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (sleepDuration > 0) {
|
||||||
|
Text(
|
||||||
|
text = "${(progress * 100).toInt()}% от цели",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFF3F51B5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
SleepStatItem(
|
||||||
|
icon = Icons.Default.AccessTime,
|
||||||
|
label = "Время сна",
|
||||||
|
value = lastNightSleep?.bedTime ?: "—",
|
||||||
|
color = Color(0xFF9C27B0)
|
||||||
|
)
|
||||||
|
|
||||||
|
SleepStatItem(
|
||||||
|
icon = Icons.Default.WbSunny,
|
||||||
|
label = "Подъем",
|
||||||
|
value = lastNightSleep?.wakeTime ?: "—",
|
||||||
|
color = Color(0xFFFF9800)
|
||||||
|
)
|
||||||
|
|
||||||
|
SleepStatItem(
|
||||||
|
icon = Icons.Default.TrendingUp,
|
||||||
|
label = "Средний сон",
|
||||||
|
value = if (weeklyAverage > 0) "%.1f ч".format(weeklyAverage) else "—",
|
||||||
|
color = Color(0xFF4CAF50)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepProgressIndicator(
|
||||||
|
progress: Float,
|
||||||
|
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 = Color(0xFFE8EAF6),
|
||||||
|
radius = radius,
|
||||||
|
center = center,
|
||||||
|
style = Stroke(width = strokeWidth)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Прогресс-дуга
|
||||||
|
val sweepAngle = 360f * progress
|
||||||
|
drawArc(
|
||||||
|
color = Color(0xFF3F51B5),
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = sweepAngle,
|
||||||
|
useCenter = false,
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
|
||||||
|
topLeft = Offset(center.x - radius, center.y - radius),
|
||||||
|
size = Size(radius * 2, radius * 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepTrackerCard(
|
||||||
|
isTracking: Boolean,
|
||||||
|
currentSleep: SleepLogEntity?,
|
||||||
|
onStartTracking: () -> Unit,
|
||||||
|
onStopTracking: () -> 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),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Трекер сна",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isTracking) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Bedtime,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF3F51B5),
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Отслеживание сна активно",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Начало: ${currentSleep?.bedTime ?: "—"}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onStopTracking,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFFFF5722)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(24.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Stop,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Завершить сон")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Hotel,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF9E9E9E),
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Готовы ко сну?",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Нажмите кнопку, когда ложитесь спать",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onStartTracking,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF3F51B5)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(24.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Начать отслеживание")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepQualityCard(
|
||||||
|
todayQuality: String,
|
||||||
|
isEditMode: Boolean,
|
||||||
|
onQualityUpdate: (String) -> Unit,
|
||||||
|
onToggleEdit: () -> 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)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Качество сна",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(onClick = onToggleEdit) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isEditMode) Icons.Default.Check else Icons.Default.Edit,
|
||||||
|
contentDescription = if (isEditMode) "Сохранить" else "Редактировать",
|
||||||
|
tint = Color(0xFF3F51B5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (isEditMode) {
|
||||||
|
val qualities = listOf("Отличное", "Хорошее", "Удовлетворительное", "Плохое")
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(qualities) { quality ->
|
||||||
|
FilterChip(
|
||||||
|
onClick = { onQualityUpdate(quality) },
|
||||||
|
label = { Text(quality) },
|
||||||
|
selected = todayQuality == quality,
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = Color(0xFF3F51B5),
|
||||||
|
selectedLabelColor = NeutralWhite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
val qualityIcon = when (todayQuality) {
|
||||||
|
"Отличное" -> Icons.Default.SentimentVerySatisfied
|
||||||
|
"Хорошее" -> Icons.Default.SentimentSatisfied
|
||||||
|
"Удовлетворительное" -> Icons.Default.SentimentNeutral
|
||||||
|
"Плохое" -> Icons.Default.SentimentVeryDissatisfied
|
||||||
|
else -> Icons.Default.SentimentNeutral
|
||||||
|
}
|
||||||
|
|
||||||
|
val qualityColor = when (todayQuality) {
|
||||||
|
"Отличное" -> Color(0xFF4CAF50)
|
||||||
|
"Хорошее" -> Color(0xFF8BC34A)
|
||||||
|
"Удовлетворительное" -> Color(0xFFFF9800)
|
||||||
|
"Плохое" -> Color(0xFFE91E63)
|
||||||
|
else -> Color(0xFF9E9E9E)
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = qualityIcon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = qualityColor,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = todayQuality.ifEmpty { "Не оценено" },
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeeklySleepChart(
|
||||||
|
weeklyData: Map<LocalDate, Float>,
|
||||||
|
sleepGoal: Float,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Сон за неделю",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
weeklyData.entries.toList().takeLast(7).forEach { (date, duration) ->
|
||||||
|
WeeklySleepBar(
|
||||||
|
date = date,
|
||||||
|
duration = duration,
|
||||||
|
goal = sleepGoal,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeeklySleepBar(
|
||||||
|
date: LocalDate,
|
||||||
|
duration: Float,
|
||||||
|
goal: Float,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val progress = if (goal > 0) (duration / goal).coerceIn(0f, 1f) else 0f
|
||||||
|
val animatedProgress by animateFloatAsState(
|
||||||
|
targetValue = progress,
|
||||||
|
animationSpec = tween(durationMillis = 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date.dayOfWeek.name.take(3),
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(24.dp)
|
||||||
|
.height(80.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color(0xFFE8EAF6))
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(animatedProgress)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(0xFF7986CB),
|
||||||
|
Color(0xFF3F51B5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (duration > 0) "%.1f".format(duration) else "—",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextPrimary,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepInsightsCard(
|
||||||
|
insights: List<String>,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color(0xFFE8EAF6)
|
||||||
|
),
|
||||||
|
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 = Color(0xFF3F51B5),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Анализ сна",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insights.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Недостаточно данных для анализа. Отслеживайте сон несколько дней для получения персональных рекомендаций.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
insights.forEach { insight ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Circle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF3F51B5),
|
||||||
|
modifier = Modifier.size(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = insight,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepTipsCard(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color(0xFFF3E5F5)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Lightbulb,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF9C27B0),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Совет для лучшего сна",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Создайте ритуал перед сном: выключите экраны за час до сна, примите теплую ванну или выпейте травяной чай. Регулярный режим поможет организму подготовиться ко сну.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentSleepLogsCard(
|
||||||
|
sleepLogs: List<SleepLogEntity>,
|
||||||
|
onLogClick: (SleepLogEntity) -> 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 (sleepLogs.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Пока нет записей о сне",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sleepLogs.take(3).forEach { log ->
|
||||||
|
SleepLogItem(
|
||||||
|
sleepLog = log,
|
||||||
|
onClick = { onLogClick(log) }
|
||||||
|
)
|
||||||
|
if (log != sleepLogs.last()) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepLogItem(
|
||||||
|
sleepLog: SleepLogEntity,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Bedtime,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF3F51B5),
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = sleepLog.date.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")),
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${sleepLog.bedTime} - ${sleepLog.wakeTime} (%.1f ч)".format(sleepLog.duration),
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = sleepLog.quality,
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepStatItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextPrimary,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.sleep
|
||||||
|
|
||||||
|
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.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.SleepLogEntity
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SleepTrackingScreen(
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SleepViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadSleepData()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
AccentPurpleLight.copy(alpha = 0.2f),
|
||||||
|
NeutralWhite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Отслеживание сна",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBackClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowBack,
|
||||||
|
contentDescription = "Назад",
|
||||||
|
tint = TextPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.toggleEditMode() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (uiState.isEditMode) Icons.Default.Save else Icons.Default.Edit,
|
||||||
|
contentDescription = if (uiState.isEditMode) "Сохранить" else "Редактировать",
|
||||||
|
tint = AccentPurple
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = NeutralWhite.copy(alpha = 0.95f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
TodaySleepCard(
|
||||||
|
uiState = uiState,
|
||||||
|
onUpdateSleep = { bedTime, wakeTime, quality, notes ->
|
||||||
|
// Создаем SleepLogEntity и передаем его в viewModel
|
||||||
|
val sleepLog = SleepLogEntity(
|
||||||
|
date = java.time.LocalDate.now(),
|
||||||
|
bedTime = bedTime,
|
||||||
|
wakeTime = wakeTime,
|
||||||
|
duration = calculateSleepDuration(bedTime, wakeTime),
|
||||||
|
quality = quality,
|
||||||
|
notes = notes
|
||||||
|
)
|
||||||
|
viewModel.updateSleepRecord(sleepLog)
|
||||||
|
},
|
||||||
|
onUpdateQuality = viewModel::updateSleepQuality,
|
||||||
|
onUpdateNotes = viewModel::updateNotes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SleepStatsCard(
|
||||||
|
recentSleep = uiState.recentSleepLogs,
|
||||||
|
averageDuration = uiState.averageSleepDuration,
|
||||||
|
averageQuality = uiState.averageQuality
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SleepHistoryCard(
|
||||||
|
sleepLogs = uiState.recentSleepLogs,
|
||||||
|
onDeleteLog = viewModel::deleteSleepLog
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SleepTipsCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TodaySleepCard(
|
||||||
|
uiState: SleepUiState,
|
||||||
|
onUpdateSleep: (String, String, String, String) -> Unit,
|
||||||
|
onUpdateQuality: (String) -> Unit,
|
||||||
|
onUpdateNotes: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var bedTime by remember { mutableStateOf(uiState.todaySleep?.bedTime ?: "22:00") }
|
||||||
|
var wakeTime by remember { mutableStateOf(uiState.todaySleep?.wakeTime ?: "07:00") }
|
||||||
|
var notes by remember { mutableStateOf(uiState.todaySleep?.notes ?: "") }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Сон за ${LocalDate.now().format(DateTimeFormatter.ofPattern("d MMMM"))}",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Bedtime,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AccentPurple,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (uiState.isEditMode) {
|
||||||
|
// Режим редактирования
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = bedTime,
|
||||||
|
onValueChange = { bedTime = it },
|
||||||
|
label = { Text("Время сна") },
|
||||||
|
placeholder = { Text("22:00") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = wakeTime,
|
||||||
|
onValueChange = { wakeTime = it },
|
||||||
|
label = { Text("Время пробуждения") },
|
||||||
|
placeholder = { Text("07:00") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Качество сна",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(sleepQualities) { quality ->
|
||||||
|
FilterChip(
|
||||||
|
onClick = { onUpdateQuality(quality.key) },
|
||||||
|
label = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(quality.emoji)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(quality.name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selected = uiState.todaySleep?.quality == quality.key,
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = AccentPurpleLight,
|
||||||
|
selectedLabelColor = AccentPurple
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = notes,
|
||||||
|
onValueChange = {
|
||||||
|
notes = it
|
||||||
|
onUpdateNotes(it)
|
||||||
|
},
|
||||||
|
label = { Text("Заметки о сне") },
|
||||||
|
placeholder = { Text("Как спалось, что снилось...") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onUpdateSleep(bedTime, wakeTime, uiState.todaySleep?.quality ?: "good", notes)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = AccentPurple)
|
||||||
|
) {
|
||||||
|
Text("Сохранить данные сна")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Режим просмотра
|
||||||
|
if (uiState.todaySleep != null) {
|
||||||
|
val sleep = uiState.todaySleep
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
SleepMetric(
|
||||||
|
label = "Время сна",
|
||||||
|
value = sleep.bedTime,
|
||||||
|
icon = Icons.Default.NightsStay
|
||||||
|
)
|
||||||
|
|
||||||
|
SleepMetric(
|
||||||
|
label = "Пробуждение",
|
||||||
|
value = sleep.wakeTime,
|
||||||
|
icon = Icons.Default.WbSunny
|
||||||
|
)
|
||||||
|
|
||||||
|
SleepMetric(
|
||||||
|
label = "Длительность",
|
||||||
|
value = "${sleep.duration}ч",
|
||||||
|
icon = Icons.Default.AccessTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Качество сна
|
||||||
|
val qualityData = sleepQualities.find { it.key == sleep.quality } ?: sleepQualities[2]
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Качество сна: ",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = qualityData.emoji,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = qualityData.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sleep.notes.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Заметки: ${sleep.notes}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Данные о сне за сегодня не добавлены",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepMetric(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AccentPurple,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepStatsCard(
|
||||||
|
recentSleep: List<kr.smartsoltech.wellshe.data.entity.SleepLogEntity>,
|
||||||
|
averageDuration: Float,
|
||||||
|
averageQuality: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Статистика за неделю",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
SleepStatItem(
|
||||||
|
label = "Средняя длительность",
|
||||||
|
value = "${String.format("%.1f", averageDuration)}ч",
|
||||||
|
icon = Icons.Default.AccessTime
|
||||||
|
)
|
||||||
|
|
||||||
|
SleepStatItem(
|
||||||
|
label = "Записей сна",
|
||||||
|
value = "${recentSleep.size}",
|
||||||
|
icon = Icons.Default.EventNote
|
||||||
|
)
|
||||||
|
|
||||||
|
val qualityData = sleepQualities.find { it.key == averageQuality } ?: sleepQualities[2]
|
||||||
|
SleepStatItem(
|
||||||
|
label = "Среднее качество",
|
||||||
|
value = qualityData.emoji,
|
||||||
|
icon = Icons.Default.Star
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepStatItem(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AccentPurple,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepHistoryCard(
|
||||||
|
sleepLogs: List<kr.smartsoltech.wellshe.data.entity.SleepLogEntity>,
|
||||||
|
onDeleteLog: (kr.smartsoltech.wellshe.data.entity.SleepLogEntity) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (sleepLogs.isNotEmpty()) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "История сна",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
sleepLogs.take(7).forEach { log ->
|
||||||
|
SleepHistoryItem(
|
||||||
|
log = log,
|
||||||
|
onDelete = { onDeleteLog(log) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepHistoryItem(
|
||||||
|
log: kr.smartsoltech.wellshe.data.entity.SleepLogEntity,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Bedtime,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AccentPurple,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = log.date.format(DateTimeFormatter.ofPattern("d MMMM yyyy")),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${log.bedTime} - ${log.wakeTime} (${log.duration}ч)",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val qualityData = sleepQualities.find { it.key == log.quality } ?: sleepQualities[2]
|
||||||
|
Text(
|
||||||
|
text = qualityData.emoji,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onDelete,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = "Удалить",
|
||||||
|
tint = ErrorRed,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepTipsCard(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = AccentPurpleLight.copy(alpha = 0.3f)),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Lightbulb,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AccentPurple,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Советы для лучшего сна",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
sleepTips.forEach { tip ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "• ",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = AccentPurple,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = tip,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Данные для UI
|
||||||
|
private data class SleepQualityData(val key: String, val name: String, val emoji: String)
|
||||||
|
|
||||||
|
private val sleepQualities = listOf(
|
||||||
|
SleepQualityData("poor", "Плохо", "😴"),
|
||||||
|
SleepQualityData("fair", "Нормально", "😐"),
|
||||||
|
SleepQualityData("good", "Хорошо", "😊"),
|
||||||
|
SleepQualityData("excellent", "Отлично", "😄")
|
||||||
|
)
|
||||||
|
|
||||||
|
private val sleepTips = listOf(
|
||||||
|
"Ложитесь спать в одно и то же время",
|
||||||
|
"Избегайте кофеина за 6 часов до сна",
|
||||||
|
"Создайте прохладную и темную атмосферу",
|
||||||
|
"Ограничьте использование экранов перед сном",
|
||||||
|
"Проветривайте спальню перед сном",
|
||||||
|
"Делайте расслабляющие упражнения"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Вспомогательная функция для расчета продолжительности сна
|
||||||
|
private fun calculateSleepDuration(bedTime: String, wakeTime: String): Float {
|
||||||
|
return try {
|
||||||
|
val bedLocalTime = LocalTime.parse(bedTime)
|
||||||
|
val wakeLocalTime = LocalTime.parse(wakeTime)
|
||||||
|
|
||||||
|
val duration = if (wakeLocalTime.isAfter(bedLocalTime)) {
|
||||||
|
// Сон в пределах одного дня
|
||||||
|
java.time.Duration.between(bedLocalTime, wakeLocalTime)
|
||||||
|
} else {
|
||||||
|
// Сон через полночь
|
||||||
|
val endOfDay = LocalTime.of(23, 59, 59)
|
||||||
|
val startOfDay = LocalTime.MIDNIGHT
|
||||||
|
val beforeMidnight = java.time.Duration.between(bedLocalTime, endOfDay)
|
||||||
|
val afterMidnight = java.time.Duration.between(startOfDay, wakeLocalTime)
|
||||||
|
beforeMidnight.plus(afterMidnight).plusMinutes(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
duration.toMinutes() / 60.0f
|
||||||
|
} catch (e: Exception) {
|
||||||
|
8.0f // Возвращаем значение по умолчанию
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.sleep
|
||||||
|
|
||||||
|
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 kr.smartsoltech.wellshe.data.entity.SleepLogEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.WellSheRepository
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class SleepUiState(
|
||||||
|
val lastNightSleep: SleepLogEntity? = null,
|
||||||
|
val currentSleep: SleepLogEntity? = null,
|
||||||
|
val todaySleep: SleepLogEntity? = null,
|
||||||
|
val recentLogs: List<SleepLogEntity> = emptyList(),
|
||||||
|
val recentSleepLogs: List<SleepLogEntity> = emptyList(), // Добавляем недостающее поле
|
||||||
|
val averageSleepDuration: Float = 0f, // Добавляем недостающее поле
|
||||||
|
val averageQuality: String = "", // Добавляем недостающее поле
|
||||||
|
val weeklyData: Map<LocalDate, Float> = emptyMap(),
|
||||||
|
val sleepGoal: Float = 8.0f,
|
||||||
|
val weeklyAverage: Float = 0f,
|
||||||
|
val todayQuality: String = "",
|
||||||
|
val insights: List<String> = emptyList(),
|
||||||
|
val isTracking: Boolean = false,
|
||||||
|
val isEditMode: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SleepViewModel @Inject constructor(
|
||||||
|
private val repository: WellSheRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(SleepUiState())
|
||||||
|
val uiState: StateFlow<SleepUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadSleepData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val yesterday = today.minusDays(1)
|
||||||
|
|
||||||
|
// Загружаем сон прошлой ночи
|
||||||
|
val lastNightSleep = repository.getSleepForDate(yesterday)
|
||||||
|
|
||||||
|
// Загружаем последние записи сна
|
||||||
|
repository.getRecentSleepLogs().collect { logs ->
|
||||||
|
val weeklyAverage = calculateWeeklyAverage(logs)
|
||||||
|
val weeklyData = createWeeklyData(logs)
|
||||||
|
val insights = generateInsights(logs)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
lastNightSleep = lastNightSleep,
|
||||||
|
recentLogs = logs,
|
||||||
|
weeklyData = weeklyData,
|
||||||
|
weeklyAverage = weeklyAverage,
|
||||||
|
insights = insights,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем цель сна пользователя
|
||||||
|
repository.getUserProfile().collect { user ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
sleepGoal = user.dailySleepGoal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем текущее качество сна
|
||||||
|
val todaySleep = repository.getSleepForDate(today)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
todayQuality = todaySleep?.quality ?: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startSleepTracking() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val now = LocalTime.now()
|
||||||
|
val bedTime = now.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||||
|
|
||||||
|
val sleepLog = SleepLogEntity(
|
||||||
|
date = LocalDate.now(),
|
||||||
|
bedTime = bedTime,
|
||||||
|
wakeTime = "",
|
||||||
|
duration = 0f,
|
||||||
|
quality = "",
|
||||||
|
notes = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: Сохранить в базу данных и получить ID
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isTracking = true,
|
||||||
|
currentSleep = sleepLog
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopSleepTracking() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val currentSleep = _uiState.value.currentSleep
|
||||||
|
if (currentSleep != null) {
|
||||||
|
val now = LocalTime.now()
|
||||||
|
val wakeTime = now.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||||
|
|
||||||
|
// Вычисляем продолжительность сна
|
||||||
|
val duration = calculateSleepDuration(currentSleep.bedTime, wakeTime)
|
||||||
|
|
||||||
|
repository.addSleepRecord(
|
||||||
|
date = currentSleep.date,
|
||||||
|
bedTime = currentSleep.bedTime,
|
||||||
|
wakeTime = wakeTime,
|
||||||
|
quality = "Хорошее", // По умолчанию
|
||||||
|
notes = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isTracking = false,
|
||||||
|
currentSleep = null
|
||||||
|
)
|
||||||
|
|
||||||
|
loadSleepData() // Перезагружаем данные
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSleepQuality(quality: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val existingSleep = repository.getSleepForDate(today)
|
||||||
|
|
||||||
|
if (existingSleep != null) {
|
||||||
|
// Обновляем существующую запись
|
||||||
|
repository.addSleepRecord(
|
||||||
|
date = today,
|
||||||
|
bedTime = existingSleep.bedTime,
|
||||||
|
wakeTime = existingSleep.wakeTime,
|
||||||
|
quality = quality,
|
||||||
|
notes = existingSleep.notes
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Создаем новую запись только с качеством
|
||||||
|
repository.addSleepRecord(
|
||||||
|
date = today,
|
||||||
|
bedTime = "",
|
||||||
|
wakeTime = "",
|
||||||
|
quality = quality,
|
||||||
|
notes = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
todayQuality = quality,
|
||||||
|
isEditMode = false
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleEditMode() {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isEditMode = !_uiState.value.isEditMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSleepLog(sleepLog: SleepLogEntity) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// TODO: Реализовать удаление записи через repository
|
||||||
|
loadSleepData() // Перезагружаем данные
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSleepRecord(sleepLog: SleepLogEntity) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.addSleepRecord(
|
||||||
|
date = sleepLog.date,
|
||||||
|
bedTime = sleepLog.bedTime,
|
||||||
|
wakeTime = sleepLog.wakeTime,
|
||||||
|
quality = sleepLog.quality,
|
||||||
|
notes = sleepLog.notes
|
||||||
|
)
|
||||||
|
loadSleepData() // Перезагружаем данные
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNotes(notes: String) {
|
||||||
|
val currentSleep = _uiState.value.currentSleep
|
||||||
|
if (currentSleep != null) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
currentSleep = currentSleep.copy(notes = notes)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateWeeklyAverage(logs: List<SleepLogEntity>): Float {
|
||||||
|
if (logs.isEmpty()) return 0f
|
||||||
|
val totalDuration = logs.sumOf { it.duration.toDouble() }
|
||||||
|
return (totalDuration / logs.size).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createWeeklyData(logs: List<SleepLogEntity>): Map<LocalDate, Float> {
|
||||||
|
val weeklyData = mutableMapOf<LocalDate, Float>()
|
||||||
|
val today = LocalDate.now()
|
||||||
|
|
||||||
|
for (i in 0..6) {
|
||||||
|
val date = today.minusDays(i.toLong())
|
||||||
|
val sleepForDate = logs.find { it.date == date }
|
||||||
|
weeklyData[date] = sleepForDate?.duration ?: 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
return weeklyData
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateInsights(logs: List<SleepLogEntity>): List<String> {
|
||||||
|
val insights = mutableListOf<String>()
|
||||||
|
|
||||||
|
if (logs.size >= 7) {
|
||||||
|
val averageDuration = calculateWeeklyAverage(logs)
|
||||||
|
val goal = _uiState.value.sleepGoal
|
||||||
|
|
||||||
|
when {
|
||||||
|
averageDuration < goal - 1 -> {
|
||||||
|
insights.add("Вы спите в среднем на ${String.format("%.1f", goal - averageDuration)} часов меньше рекомендуемого")
|
||||||
|
}
|
||||||
|
averageDuration > goal + 1 -> {
|
||||||
|
insights.add("Вы спите больше рекомендуемого времени")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
insights.add("Ваш режим сна близок к оптимальному")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Анализ регулярности
|
||||||
|
val bedTimes = logs.mapNotNull {
|
||||||
|
if (it.bedTime.isNotEmpty()) {
|
||||||
|
val parts = it.bedTime.split(":")
|
||||||
|
if (parts.size == 2) {
|
||||||
|
parts[0].toIntOrNull()?.let { hour ->
|
||||||
|
hour * 60 + (parts[1].toIntOrNull() ?: 0)
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bedTimes.size >= 5) {
|
||||||
|
val avgBedTime = bedTimes.average()
|
||||||
|
val deviation = bedTimes.map { kotlin.math.abs(it - avgBedTime) }.average()
|
||||||
|
|
||||||
|
if (deviation > 60) { // Больше часа отклонения
|
||||||
|
insights.add("Старайтесь ложиться спать в одно и то же время")
|
||||||
|
} else {
|
||||||
|
insights.add("У вас хороший регулярный режим сна")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Анализ качества
|
||||||
|
val qualityGood = logs.count { it.quality in listOf("Отличное", "Хорошее") }
|
||||||
|
val qualityPercent = (qualityGood.toFloat() / logs.size) * 100
|
||||||
|
|
||||||
|
when {
|
||||||
|
qualityPercent >= 80 -> insights.add("Качество вашего сна отличное!")
|
||||||
|
qualityPercent >= 60 -> insights.add("Качество сна можно улучшить")
|
||||||
|
else -> insights.add("Рекомендуем обратить внимание на гигиену сна")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return insights
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateSleepDuration(bedTime: String, wakeTime: String): Float {
|
||||||
|
try {
|
||||||
|
val bedParts = bedTime.split(":")
|
||||||
|
val wakeParts = wakeTime.split(":")
|
||||||
|
|
||||||
|
if (bedParts.size == 2 && wakeParts.size == 2) {
|
||||||
|
val bedMinutes = bedParts[0].toInt() * 60 + bedParts[1].toInt()
|
||||||
|
val wakeMinutes = wakeParts[0].toInt() * 60 + wakeParts[1].toInt()
|
||||||
|
|
||||||
|
val sleepMinutes = if (wakeMinutes > bedMinutes) {
|
||||||
|
wakeMinutes - bedMinutes
|
||||||
|
} else {
|
||||||
|
// Переход через полночь
|
||||||
|
(24 * 60 - bedMinutes) + wakeMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
return sleepMinutes / 60f
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Если не удается рассчитать, возвращаем 8 часов по умолчанию
|
||||||
|
}
|
||||||
|
|
||||||
|
return 8.0f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Color.kt
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Основные цвета приложения WellShe
|
||||||
|
val PrimaryPink = Color(0xFFE91E63)
|
||||||
|
val PrimaryPinkLight = Color(0xFFF8BBD9)
|
||||||
|
val PrimaryPinkDark = Color(0xFFC2185B)
|
||||||
|
|
||||||
|
// Вторичные цвета
|
||||||
|
val SecondaryBlue = Color(0xFF2196F3)
|
||||||
|
val SecondaryBlueLight = Color(0xFFBBDEFB)
|
||||||
|
val SecondaryBlueDark = Color(0xFF1976D2)
|
||||||
|
|
||||||
|
// Акцентные цвета
|
||||||
|
val AccentPurple = Color(0xFF9C27B0)
|
||||||
|
val AccentPurpleLight = Color(0xFFE1BEE7)
|
||||||
|
val AccentPurpleDark = Color(0xFF7B1FA2)
|
||||||
|
|
||||||
|
// Нейтральные цвета
|
||||||
|
val NeutralWhite = Color(0xFFFFFFFF)
|
||||||
|
val NeutralLightGray = Color(0xFFF5F5F5)
|
||||||
|
val NeutralGray = Color(0xFF9E9E9E)
|
||||||
|
val NeutralDarkGray = Color(0xFF424242)
|
||||||
|
val NeutralBlack = Color(0xFF212121)
|
||||||
|
|
||||||
|
// Текстовые цвета
|
||||||
|
val TextPrimary = Color(0xFF212121)
|
||||||
|
val TextSecondary = Color(0xFF757575)
|
||||||
|
val TextDisabled = Color(0xFFBDBDBD)
|
||||||
|
|
||||||
|
// Семантические цвета
|
||||||
|
val SuccessGreen = Color(0xFF4CAF50)
|
||||||
|
val SuccessGreenLight = Color(0xFFC8E6C9)
|
||||||
|
val WarningOrange = Color(0xFFFF9800)
|
||||||
|
val WarningOrangeLight = Color(0xFFFFE0B2)
|
||||||
|
val ErrorRed = Color(0xFFF44336)
|
||||||
|
val ErrorRedLight = Color(0xFFFFCDD2)
|
||||||
|
|
||||||
|
// Фоновые цвета
|
||||||
|
val BackgroundPrimary = Color(0xFFFFFFFF)
|
||||||
|
val BackgroundSecondary = Color(0xFFFAFAFA)
|
||||||
|
val BackgroundTertiary = Color(0xFFF5F5F5)
|
||||||
|
|
||||||
|
// Цвета для графиков и статистики
|
||||||
|
val ChartPink = Color(0xFFE91E63)
|
||||||
|
val ChartBlue = Color(0xFF2196F3)
|
||||||
|
val ChartPurple = Color(0xFF9C27B0)
|
||||||
|
val ChartGreen = Color(0xFF4CAF50)
|
||||||
|
val ChartOrange = Color(0xFFFF9800)
|
||||||
|
val ChartRed = Color(0xFFF44336)
|
||||||
74
app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = PrimaryPink,
|
||||||
|
secondary = AccentPurple,
|
||||||
|
tertiary = SecondaryBlue,
|
||||||
|
background = NeutralBlack,
|
||||||
|
surface = NeutralDarkGray,
|
||||||
|
onPrimary = NeutralWhite,
|
||||||
|
onSecondary = NeutralWhite,
|
||||||
|
onTertiary = NeutralWhite,
|
||||||
|
onBackground = NeutralWhite,
|
||||||
|
onSurface = NeutralWhite,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = PrimaryPink,
|
||||||
|
secondary = AccentPurple,
|
||||||
|
tertiary = SecondaryBlue,
|
||||||
|
background = NeutralWhite,
|
||||||
|
surface = NeutralLightGray,
|
||||||
|
onPrimary = NeutralWhite,
|
||||||
|
onSecondary = NeutralWhite,
|
||||||
|
onTertiary = NeutralWhite,
|
||||||
|
onBackground = NeutralDarkGray,
|
||||||
|
onSurface = NeutralDarkGray,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WellSheTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
// Dynamic color is available on Android 12+
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
val view = LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as Activity).window
|
||||||
|
window.statusBarColor = colorScheme.primary.toArgb()
|
||||||
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
53
app/src/main/java/kr/smartsoltech/wellshe/ui/theme/Type.kt
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
// Set of Material typography styles to start with
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
),
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
),
|
||||||
|
headlineLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
lineHeight = 40.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
headlineMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
lineHeight = 36.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.25.sp
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.water
|
||||||
|
|
||||||
|
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.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
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.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.WaterIntake
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WaterTrackingScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: WaterTrackingViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadWaterData()
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(0xFF64B5F6).copy(alpha = 0.2f),
|
||||||
|
NeutralWhite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
WaterGoalCard(
|
||||||
|
currentAmount = uiState.todayWaterIntake,
|
||||||
|
goalAmount = uiState.dailyGoal,
|
||||||
|
onGoalUpdate = viewModel::updateDailyGoal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
QuickAddSection(
|
||||||
|
onAddWater = viewModel::addWaterIntake
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
TodayProgressCard(
|
||||||
|
waterIntakes = uiState.todayIntakes,
|
||||||
|
onRemoveIntake = viewModel::removeWaterIntake
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
WeeklyProgressCard(
|
||||||
|
weeklyData = uiState.weeklyData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.error != null) {
|
||||||
|
LaunchedEffect(uiState.error) {
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WaterGoalCard(
|
||||||
|
currentAmount: Float,
|
||||||
|
goalAmount: Float,
|
||||||
|
onGoalUpdate: (Float) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val progress by animateFloatAsState(
|
||||||
|
targetValue = if (goalAmount > 0) (currentAmount / goalAmount).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
|
||||||
|
) {
|
||||||
|
WaterProgressIndicator(
|
||||||
|
progress = progress,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "%.1f л".format(currentAmount),
|
||||||
|
style = MaterialTheme.typography.headlineLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFF1976D2)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "из %.1f л".format(goalAmount),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${(progress * 100).toInt()}%",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFF1976D2)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
val remaining = (goalAmount - currentAmount).coerceAtLeast(0f)
|
||||||
|
Text(
|
||||||
|
text = if (remaining > 0) "Осталось выпить: %.1f л".format(remaining) else "Цель достигнута! 🎉",
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
color = if (remaining > 0) TextSecondary else Color(0xFF4CAF50),
|
||||||
|
fontWeight = if (remaining > 0) FontWeight.Normal else FontWeight.Bold
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WaterProgressIndicator(
|
||||||
|
progress: Float,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
val center = this.center
|
||||||
|
val radius = size.minDimension / 2 - 20.dp.toPx()
|
||||||
|
|
||||||
|
// Фон круга
|
||||||
|
drawCircle(
|
||||||
|
color = Color(0xFFE3F2FD),
|
||||||
|
radius = radius,
|
||||||
|
center = center
|
||||||
|
)
|
||||||
|
|
||||||
|
// Прогресс-индикатор в виде воды
|
||||||
|
val waterHeight = radius * 2 * progress
|
||||||
|
val waterPath = Path().apply {
|
||||||
|
val waveAmplitude = 10.dp.toPx()
|
||||||
|
val waveFrequency = 0.02f
|
||||||
|
|
||||||
|
moveTo(center.x - radius, center.y + radius - waterHeight)
|
||||||
|
|
||||||
|
for (x in (-radius.toInt())..(radius.toInt())) {
|
||||||
|
val waveY = sin(x * waveFrequency) * waveAmplitude
|
||||||
|
lineTo(
|
||||||
|
center.x + x,
|
||||||
|
center.y + radius - waterHeight + waveY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lineTo(center.x + radius, center.y + radius)
|
||||||
|
lineTo(center.x - radius, center.y + radius)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPath(
|
||||||
|
path = waterPath,
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(0xFF64B5F6),
|
||||||
|
Color(0xFF2196F3)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Граница круга
|
||||||
|
drawCircle(
|
||||||
|
color = Color(0xFF1976D2),
|
||||||
|
radius = radius,
|
||||||
|
center = center,
|
||||||
|
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 4.dp.toPx())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickAddSection(
|
||||||
|
onAddWater: (Float) -> 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)
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
val quickAmounts = listOf(0.25f, 0.5f, 1.0f, 1.5f)
|
||||||
|
items(quickAmounts) { amount ->
|
||||||
|
QuickAddButton(
|
||||||
|
amount = amount,
|
||||||
|
onClick = { onAddWater(amount) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickAddButton(
|
||||||
|
amount: Float,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF2196F3),
|
||||||
|
contentColor = NeutralWhite
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.WaterDrop,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = if (amount < 1.0f) "${(amount * 1000).toInt()} мл" else "%.1f л".format(amount),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TodayProgressCard(
|
||||||
|
waterIntakes: List<WaterIntake>,
|
||||||
|
onRemoveIntake: (WaterIntake) -> 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 (waterIntakes.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Пока что ничего не выпито",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
waterIntakes.forEach { intake ->
|
||||||
|
WaterIntakeItem(
|
||||||
|
waterIntake = intake,
|
||||||
|
onRemove = { onRemoveIntake(intake) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WaterIntakeItem(
|
||||||
|
waterIntake: WaterIntake,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.WaterDrop,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF2196F3),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (waterIntake.amount < 1.0f) "${(waterIntake.amount * 1000).toInt()} мл" else "%.1f л".format(waterIntake.amount),
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = waterIntake.time.format(DateTimeFormatter.ofPattern("HH:mm")),
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onRemove
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = "Удалить",
|
||||||
|
tint = Color(0xFFFF5722),
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeeklyProgressCard(
|
||||||
|
weeklyData: Map<LocalDate, Float>,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Прогресс за неделю",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
weeklyData.entries.toList().takeLast(7).forEach { (date, amount) ->
|
||||||
|
WeeklyProgressBar(
|
||||||
|
date = date,
|
||||||
|
amount = amount,
|
||||||
|
goalAmount = 2.5f, // TODO: Получить из настроек
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeeklyProgressBar(
|
||||||
|
date: LocalDate,
|
||||||
|
amount: Float,
|
||||||
|
goalAmount: Float,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val progress = if (goalAmount > 0) (amount / goalAmount).coerceIn(0f, 1f) else 0f
|
||||||
|
val animatedProgress by animateFloatAsState(
|
||||||
|
targetValue = progress,
|
||||||
|
animationSpec = tween(durationMillis = 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date.dayOfWeek.name.take(3),
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(24.dp)
|
||||||
|
.height(80.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color(0xFFE3F2FD))
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(animatedProgress)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(0xFF64B5F6),
|
||||||
|
Color(0xFF2196F3)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "%.1f".format(amount),
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = TextPrimary,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.water
|
||||||
|
|
||||||
|
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 kr.smartsoltech.wellshe.data.repository.WellSheRepository
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.WaterIntake
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class WaterTrackingUiState(
|
||||||
|
val todayWaterIntake: Float = 0f,
|
||||||
|
val dailyGoal: Float = 2.5f,
|
||||||
|
val todayIntakes: List<WaterIntake> = emptyList(),
|
||||||
|
val weeklyData: Map<LocalDate, Float> = emptyMap(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class WaterTrackingViewModel @Inject constructor(
|
||||||
|
private val repository: WellSheRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(WaterTrackingUiState())
|
||||||
|
val uiState: StateFlow<WaterTrackingUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadWaterData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
|
||||||
|
// Загружаем данные о потреблении воды за сегодня
|
||||||
|
repository.getWaterIntakeForDate(today).collect { intakes ->
|
||||||
|
val totalAmount = intakes.sumOf { it.amount.toDouble() }.toFloat()
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
todayWaterIntake = totalAmount,
|
||||||
|
todayIntakes = intakes,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем недельные данные
|
||||||
|
loadWeeklyData()
|
||||||
|
|
||||||
|
// Загружаем цель пользователя
|
||||||
|
repository.getUserProfile().collect { user ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
dailyGoal = user.dailyWaterGoal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadWeeklyData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val weeklyData = mutableMapOf<LocalDate, Float>()
|
||||||
|
val today = LocalDate.now()
|
||||||
|
|
||||||
|
for (i in 0..6) {
|
||||||
|
val date = today.minusDays(i.toLong())
|
||||||
|
val intakes = repository.getWaterIntakeForDateSync(date)
|
||||||
|
val totalAmount = intakes.sumOf { it.amount.toDouble() }.toFloat()
|
||||||
|
weeklyData[date] = totalAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(weeklyData = weeklyData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addWaterIntake(amount: Float) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val waterIntake = WaterIntake(
|
||||||
|
id = 0,
|
||||||
|
date = LocalDate.now(),
|
||||||
|
time = LocalTime.now(),
|
||||||
|
amount = amount
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.addWaterIntake(waterIntake)
|
||||||
|
loadWaterData() // Перезагружаем данные
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeWaterIntake(waterIntake: WaterIntake) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.removeWaterIntake(waterIntake.id)
|
||||||
|
loadWaterData() // Перезагружаем данные
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDailyGoal(newGoal: Float) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.updateWaterGoal(newGoal)
|
||||||
|
_uiState.value = _uiState.value.copy(dailyGoal = newGoal)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.workouts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WorkoutsScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
SecondaryBlueLight.copy(alpha = 0.2f),
|
||||||
|
NeutralWhite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Тренировки",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowBack,
|
||||||
|
contentDescription = "Назад",
|
||||||
|
tint = TextPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = NeutralWhite.copy(alpha = 0.95f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = NeutralWhite),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FitnessCenter,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = SecondaryBlue,
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Тренировки",
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Здесь будет отображаться информация о ваших тренировках",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package kr.smartsoltech.wellshe.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kr.smartsoltech.wellshe.data.repo.WellSheRepository
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.*
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileWriter
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class DataExportManager @Inject constructor(
|
||||||
|
private val repository: WellSheRepository,
|
||||||
|
private val gson: Gson = GsonBuilder()
|
||||||
|
.registerTypeAdapter(LocalDate::class.java, LocalDateTypeAdapter())
|
||||||
|
.registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeTypeAdapter())
|
||||||
|
.setPrettyPrinting()
|
||||||
|
.create()
|
||||||
|
) {
|
||||||
|
|
||||||
|
data class ExportData(
|
||||||
|
val exportDate: String,
|
||||||
|
val appVersion: String,
|
||||||
|
val waterLogs: List<WaterLog>,
|
||||||
|
val sleepLogs: List<SleepLog>,
|
||||||
|
val cyclePeriods: List<CyclePeriod>,
|
||||||
|
val cycleSymptoms: List<CycleSymptom>,
|
||||||
|
val workoutSessions: List<WorkoutSession>,
|
||||||
|
val postureEvents: List<PostureEvent>
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun exportAllData(context: Context): Result<Uri> = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
// Собираем все данные
|
||||||
|
val waterLogs = repository.getWaterLogsFlow()
|
||||||
|
val sleepLogs = repository.getSleepLogsFlow()
|
||||||
|
val cyclePeriods = repository.getCyclePeriodsFlow()
|
||||||
|
|
||||||
|
val exportData = ExportData(
|
||||||
|
exportDate = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||||
|
appVersion = "1.0.0",
|
||||||
|
waterLogs = emptyList(), // TODO: получить реальные данные
|
||||||
|
sleepLogs = emptyList(),
|
||||||
|
cyclePeriods = emptyList(),
|
||||||
|
cycleSymptoms = emptyList(),
|
||||||
|
workoutSessions = emptyList(),
|
||||||
|
postureEvents = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Создаем JSON файл
|
||||||
|
val fileName = "wellshe_backup_${LocalDate.now()}.json"
|
||||||
|
val file = File(context.cacheDir, fileName)
|
||||||
|
|
||||||
|
FileWriter(file).use { writer ->
|
||||||
|
gson.toJson(exportData, writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем Uri для sharing
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
|
||||||
|
Result.success(uri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun exportToCsv(context: Context): Result<Uri> = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val fileName = "wellshe_data_${LocalDate.now()}.csv"
|
||||||
|
val file = File(context.cacheDir, fileName)
|
||||||
|
|
||||||
|
FileWriter(file).use { writer ->
|
||||||
|
// CSV заголовки
|
||||||
|
writer.appendLine("Date,Type,Value,Notes")
|
||||||
|
|
||||||
|
// TODO: Добавить реальные данные в CSV формате
|
||||||
|
writer.appendLine("${LocalDate.now()},Water,250ml,Morning intake")
|
||||||
|
writer.appendLine("${LocalDate.now()},Sleep,8h,Good quality")
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
|
||||||
|
Result.success(uri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareData(context: Context, uri: Uri) {
|
||||||
|
val shareIntent = Intent().apply {
|
||||||
|
action = Intent.ACTION_SEND
|
||||||
|
type = "application/json"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
val chooser = Intent.createChooser(shareIntent, "Поделиться данными WellShe")
|
||||||
|
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(chooser)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importData(context: Context, uri: Uri): Result<String> = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
|
val jsonString = inputStream?.bufferedReader()?.use { it.readText() }
|
||||||
|
|
||||||
|
if (jsonString != null) {
|
||||||
|
val importData = gson.fromJson(jsonString, ExportData::class.java)
|
||||||
|
|
||||||
|
// TODO: Импорт данных в базу
|
||||||
|
// repository.importWaterLogs(importData.waterLogs)
|
||||||
|
// repository.importSleepLogs(importData.sleepLogs)
|
||||||
|
// и т.д.
|
||||||
|
|
||||||
|
Result.success("Данные успешно импортированы: ${importData.waterLogs.size} записей о воде, ${importData.sleepLogs.size} записей о сне")
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Не удалось прочитать файл"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package kr.smartsoltech.wellshe.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.security.crypto.EncryptedFile
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import java.io.File
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.SecretKeyFactory
|
||||||
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import javax.crypto.CipherOutputStream
|
||||||
|
import javax.crypto.CipherInputStream
|
||||||
|
import com.google.gson.Gson
|
||||||
|
|
||||||
|
object ExportManager {
|
||||||
|
private const val ITERATIONS = 10000
|
||||||
|
private const val KEY_LENGTH = 256
|
||||||
|
private const val ALGORITHM = "AES"
|
||||||
|
|
||||||
|
fun exportData(context: Context, data: Any, pin: String, file: File) {
|
||||||
|
val key = deriveKey(pin)
|
||||||
|
val cipher = Cipher.getInstance("AES")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||||
|
val out = CipherOutputStream(file.outputStream(), cipher)
|
||||||
|
out.write(Gson().toJson(data).toByteArray())
|
||||||
|
out.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importData(context: Context, pin: String, file: File): String? {
|
||||||
|
val key = deriveKey(pin)
|
||||||
|
val cipher = Cipher.getInstance("AES")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key)
|
||||||
|
val input = CipherInputStream(file.inputStream(), cipher)
|
||||||
|
return input.readBytes().toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deriveKey(pin: String): SecretKeySpec {
|
||||||
|
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||||
|
val spec = PBEKeySpec(pin.toCharArray(), "WellSheSalt".toByteArray(), ITERATIONS, KEY_LENGTH)
|
||||||
|
val tmp = factory.generateSecret(spec)
|
||||||
|
return SecretKeySpec(tmp.encoded, ALGORITHM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package kr.smartsoltech.wellshe.util
|
||||||
|
|
||||||
|
import com.google.gson.*
|
||||||
|
import java.lang.reflect.Type
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
class LocalDateTypeAdapter : JsonSerializer<LocalDate>, JsonDeserializer<LocalDate> {
|
||||||
|
private val formatter = DateTimeFormatter.ISO_LOCAL_DATE
|
||||||
|
|
||||||
|
override fun serialize(src: LocalDate?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
|
||||||
|
return JsonPrimitive(src?.format(formatter))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): LocalDate {
|
||||||
|
return LocalDate.parse(json?.asString, formatter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalDateTimeTypeAdapter : JsonSerializer<LocalDateTime>, JsonDeserializer<LocalDateTime> {
|
||||||
|
private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||||
|
|
||||||
|
override fun serialize(src: LocalDateTime?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
|
||||||
|
return JsonPrimitive(src?.format(formatter))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): LocalDateTime {
|
||||||
|
return LocalDateTime.parse(json?.asString, formatter)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.smartsoltech.wellshe.util
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
object NotificationChannels {
|
||||||
|
const val REMINDERS_WATER = "REMINDERS_WATER"
|
||||||
|
const val ALARM_SLEEP = "ALARM_SLEEP"
|
||||||
|
const val POSTURE_TIPS = "POSTURE_TIPS"
|
||||||
|
const val CYCLE_COACH = "CYCLE_COACH"
|
||||||
|
|
||||||
|
fun createAll(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channels = listOf(
|
||||||
|
NotificationChannel(REMINDERS_WATER, "Напоминания о воде", NotificationManager.IMPORTANCE_DEFAULT),
|
||||||
|
NotificationChannel(ALARM_SLEEP, "Будильник сна", NotificationManager.IMPORTANCE_HIGH),
|
||||||
|
NotificationChannel(POSTURE_TIPS, "Осанка", NotificationManager.IMPORTANCE_DEFAULT),
|
||||||
|
NotificationChannel(CYCLE_COACH, "Цикл", NotificationManager.IMPORTANCE_DEFAULT)
|
||||||
|
)
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
channels.forEach { manager.createNotificationChannel(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package kr.smartsoltech.wellshe.util
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import kr.smartsoltech.wellshe.MainActivity
|
||||||
|
import kr.smartsoltech.wellshe.R
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class NotificationHelper @Inject constructor(
|
||||||
|
private val context: Context
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val WATER_REMINDER_CHANNEL = "water_reminder"
|
||||||
|
const val PERIOD_TRACKING_CHANNEL = "period_tracking"
|
||||||
|
const val WORKOUT_REMINDER_CHANNEL = "workout_reminder"
|
||||||
|
const val SLEEP_REMINDER_CHANNEL = "sleep_reminder"
|
||||||
|
|
||||||
|
const val WATER_NOTIFICATION_ID = 1
|
||||||
|
const val PERIOD_NOTIFICATION_ID = 2
|
||||||
|
const val WORKOUT_NOTIFICATION_ID = 3
|
||||||
|
const val SLEEP_NOTIFICATION_ID = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
createNotificationChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannels() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channels = listOf(
|
||||||
|
NotificationChannel(
|
||||||
|
WATER_REMINDER_CHANNEL,
|
||||||
|
"Напоминания о воде",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "Напоминания пить воду"
|
||||||
|
},
|
||||||
|
NotificationChannel(
|
||||||
|
PERIOD_TRACKING_CHANNEL,
|
||||||
|
"Отслеживание цикла",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "Уведомления о менструальном цикле"
|
||||||
|
},
|
||||||
|
NotificationChannel(
|
||||||
|
WORKOUT_REMINDER_CHANNEL,
|
||||||
|
"Напоминания о тренировках",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "Напоминания о физической активности"
|
||||||
|
},
|
||||||
|
NotificationChannel(
|
||||||
|
SLEEP_REMINDER_CHANNEL,
|
||||||
|
"Напоминания о сне",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "Напоминания о режиме сна"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
channels.forEach { channel ->
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showWaterReminder() {
|
||||||
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, WATER_REMINDER_CHANNEL)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle("💧 Время пить воду!")
|
||||||
|
.setContentText("Не забудьте выпить стакан воды для поддержания водного баланса")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle()
|
||||||
|
.bigText("Регулярное употребление воды важно для вашего здоровья. Выпейте стакан воды прямо сейчас!"))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(context).notify(WATER_NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Handle permission error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showPeriodReminder(daysUntilPeriod: Int) {
|
||||||
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val title = if (daysUntilPeriod <= 3) {
|
||||||
|
"🩸 Скоро начнется менструация"
|
||||||
|
} else {
|
||||||
|
"📅 Напоминание о цикле"
|
||||||
|
}
|
||||||
|
|
||||||
|
val text = if (daysUntilPeriod <= 3) {
|
||||||
|
"Менструация ожидается через $daysUntilPeriod дня. Подготовьтесь заранее!"
|
||||||
|
} else {
|
||||||
|
"Следующая менструация через $daysUntilPeriod дней"
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, PERIOD_TRACKING_CHANNEL)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(context).notify(PERIOD_NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Handle permission error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showWorkoutReminder() {
|
||||||
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, WORKOUT_REMINDER_CHANNEL)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle("💪 Время для тренировки!")
|
||||||
|
.setContentText("Не пропустите сегодняшнюю тренировку. Ваше тело скажет спасибо!")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(context).notify(WORKOUT_NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Handle permission error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showSleepReminder() {
|
||||||
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, SLEEP_REMINDER_CHANNEL)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle("😴 Время готовиться ко сну!")
|
||||||
|
.setContentText("Ложитесь спать в одно время для здорового режима сна")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(context).notify(SLEEP_NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Handle permission error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAllNotifications() {
|
||||||
|
NotificationManagerCompat.from(context).cancelAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelNotification(notificationId: Int) {
|
||||||
|
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package kr.smartsoltech.wellshe.util
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class PermissionManager @Inject constructor() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NOTIFICATION_PERMISSION_REQUEST = 1001
|
||||||
|
const val ACTIVITY_RECOGNITION_REQUEST = 1002
|
||||||
|
const val BODY_SENSORS_REQUEST = 1003
|
||||||
|
|
||||||
|
val NOTIFICATION_PERMISSIONS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
} else {
|
||||||
|
emptyArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
val ACTIVITY_PERMISSIONS = arrayOf(
|
||||||
|
Manifest.permission.ACTIVITY_RECOGNITION
|
||||||
|
)
|
||||||
|
|
||||||
|
val SENSOR_PERMISSIONS = arrayOf(
|
||||||
|
Manifest.permission.BODY_SENSORS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasNotificationPermission(context: Context): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
} else {
|
||||||
|
true // На старых версиях Android разрешение не требуется
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasActivityRecognitionPermission(context: Context): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.ACTIVITY_RECOGNITION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasBodySensorsPermission(context: Context): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.BODY_SENSORS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestNotificationPermission(activity: Activity) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
activity,
|
||||||
|
NOTIFICATION_PERMISSIONS,
|
||||||
|
NOTIFICATION_PERMISSION_REQUEST
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestActivityRecognitionPermission(activity: Activity) {
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
activity,
|
||||||
|
ACTIVITY_PERMISSIONS,
|
||||||
|
ACTIVITY_RECOGNITION_REQUEST
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestBodySensorsPermission(activity: Activity) {
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
activity,
|
||||||
|
SENSOR_PERMISSIONS,
|
||||||
|
BODY_SENSORS_REQUEST
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldShowNotificationRationale(activity: Activity): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
ActivityCompat.shouldShowRequestPermissionRationale(
|
||||||
|
activity,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldShowActivityRecognitionRationale(activity: Activity): Boolean {
|
||||||
|
return ActivityCompat.shouldShowRequestPermissionRationale(
|
||||||
|
activity,
|
||||||
|
Manifest.permission.ACTIVITY_RECOGNITION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun areAllPermissionsGranted(context: Context): Boolean {
|
||||||
|
return hasNotificationPermission(context) &&
|
||||||
|
hasActivityRecognitionPermission(context) &&
|
||||||
|
hasBodySensorsPermission(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
221
app/src/main/java/kr/smartsoltech/wellshe/util/Utils.kt.backup
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package kr.smartsoltech.wellshe.util
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.CyclePhase
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.CyclePeriod
|
||||||
|
|
||||||
|
object DateUtils {
|
||||||
|
|
||||||
|
fun formatDate(date: LocalDate): String {
|
||||||
|
return date.format(DateTimeFormatter.ofPattern("dd MMMM"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatDateTime(dateTime: LocalDateTime): String {
|
||||||
|
return dateTime.format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatTime(dateTime: LocalDateTime): String {
|
||||||
|
return dateTime.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun daysBetween(start: LocalDate, end: LocalDate): Int {
|
||||||
|
return ChronoUnit.DAYS.between(start, end).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hoursBetween(start: LocalDateTime, end: LocalDateTime): Long {
|
||||||
|
return ChronoUnit.HOURS.between(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun minutesBetween(start: LocalDateTime, end: LocalDateTime): Long {
|
||||||
|
return ChronoUnit.MINUTES.between(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStartOfDay(date: LocalDate = LocalDate.now()): LocalDateTime {
|
||||||
|
return date.atStartOfDay()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getEndOfDay(date: LocalDate = LocalDate.now()): LocalDateTime {
|
||||||
|
return date.atTime(23, 59, 59)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isToday(date: LocalDate): Boolean {
|
||||||
|
return date == LocalDate.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isYesterday(date: LocalDate): Boolean {
|
||||||
|
return date == LocalDate.now().minusDays(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getWeekDates(): List<LocalDate> {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val startOfWeek = today.minusDays(today.dayOfWeek.value - 1L)
|
||||||
|
return (0..6).map { startOfWeek.plusDays(it.toLong()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMonthDates(year: Int, month: Int): List<LocalDate> {
|
||||||
|
val firstDay = LocalDate.of(year, month, 1)
|
||||||
|
val lastDay = firstDay.withDayOfMonth(firstDay.lengthOfMonth())
|
||||||
|
|
||||||
|
val dates = mutableListOf<LocalDate>()
|
||||||
|
var current = firstDay
|
||||||
|
while (!current.isAfter(lastDay)) {
|
||||||
|
dates.add(current)
|
||||||
|
current = current.plusDays(1)
|
||||||
|
}
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object CycleUtils {
|
||||||
|
|
||||||
|
fun calculateCycleDay(lastPeriodStart: LocalDate, currentDate: LocalDate = LocalDate.now()): Int {
|
||||||
|
return DateUtils.daysBetween(lastPeriodStart, currentDate) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateCyclePhase(cycleDay: Int, cycleLength: Int = 28): CyclePhase {
|
||||||
|
return when (cycleDay) {
|
||||||
|
in 1..5 -> CyclePhase.MENSTRUAL
|
||||||
|
in 6..(cycleLength / 2 - 1) -> CyclePhase.FOLLICULAR
|
||||||
|
cycleLength / 2 -> CyclePhase.OVULATION
|
||||||
|
else -> CyclePhase.LUTEAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateNextPeriodDate(lastPeriodStart: LocalDate, cycleLength: Int = 28): LocalDate {
|
||||||
|
return lastPeriodStart.plusDays(cycleLength.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateDaysUntilNextPeriod(lastPeriodStart: LocalDate, cycleLength: Int = 28): Int {
|
||||||
|
val nextPeriod = calculateNextPeriodDate(lastPeriodStart, cycleLength)
|
||||||
|
return DateUtils.daysBetween(LocalDate.now(), nextPeriod)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateFertileWindow(lastPeriodStart: LocalDate, cycleLength: Int = 28): Pair<LocalDate, LocalDate> {
|
||||||
|
val ovulationDay = lastPeriodStart.plusDays((cycleLength / 2).toLong())
|
||||||
|
val fertileStart = ovulationDay.minusDays(5)
|
||||||
|
val fertileEnd = ovulationDay.plusDays(1)
|
||||||
|
return Pair(fertileStart, fertileEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isInFertileWindow(
|
||||||
|
lastPeriodStart: LocalDate,
|
||||||
|
cycleLength: Int = 28,
|
||||||
|
currentDate: LocalDate = LocalDate.now()
|
||||||
|
): Boolean {
|
||||||
|
val (fertileStart, fertileEnd) = calculateFertileWindow(lastPeriodStart, cycleLength)
|
||||||
|
return !currentDate.isBefore(fertileStart) && !currentDate.isAfter(fertileEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateAverageCycleLength(periods: List<CyclePeriod>): Int {
|
||||||
|
if (periods.size < 2) return 28
|
||||||
|
|
||||||
|
val cycleLengths = mutableListOf<Int>()
|
||||||
|
for (i in 1 until periods.size) {
|
||||||
|
val previousEnd = periods[i-1].startDate
|
||||||
|
val currentStart = periods[i].startDate
|
||||||
|
cycleLengths.add(DateUtils.daysBetween(previousEnd, currentStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (cycleLengths.isNotEmpty()) {
|
||||||
|
cycleLengths.average().toInt()
|
||||||
|
} else 28
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object HealthCalculator {
|
||||||
|
|
||||||
|
fun calculateWaterProgressPercentage(current: Int, goal: Int): Float {
|
||||||
|
return if (goal > 0) (current.toFloat() / goal).coerceAtMost(1f) else 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateSleepQualityScore(hours: Double): Int {
|
||||||
|
return when {
|
||||||
|
hours >= 8.0 -> 4 // Excellent
|
||||||
|
hours >= 7.0 -> 3 // Good
|
||||||
|
hours >= 6.0 -> 2 // Fair
|
||||||
|
else -> 1 // Poor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateHealthScore(
|
||||||
|
waterPercentage: Float,
|
||||||
|
sleepHours: Double,
|
||||||
|
workoutsThisWeek: Int,
|
||||||
|
targetWorkouts: Int = 5
|
||||||
|
): Int {
|
||||||
|
val waterScore = (waterPercentage * 25).toInt()
|
||||||
|
val sleepScore = when {
|
||||||
|
sleepHours >= 8.0 -> 25
|
||||||
|
sleepHours >= 7.0 -> 20
|
||||||
|
sleepHours >= 6.0 -> 15
|
||||||
|
else -> 10
|
||||||
|
}
|
||||||
|
val workoutScore = ((workoutsThisWeek.toFloat() / targetWorkouts) * 25).toInt().coerceAtMost(25)
|
||||||
|
val bonusScore = 25 // Base score for using the app
|
||||||
|
|
||||||
|
return (waterScore + sleepScore + workoutScore + bonusScore).coerceAtMost(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateBMI(weightKg: Double, heightCm: Double): Double {
|
||||||
|
val heightM = heightCm / 100
|
||||||
|
return weightKg / (heightM * heightM)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBMICategory(bmi: Double): String {
|
||||||
|
return when {
|
||||||
|
bmi < 18.5 -> "Недостаточный вес"
|
||||||
|
bmi < 25.0 -> "Нормальный вес"
|
||||||
|
bmi < 30.0 -> "Избыточный вес"
|
||||||
|
else -> "Ожирение"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateCaloriesBurned(activityType: String, durationMinutes: Int, weightKg: Double): Int {
|
||||||
|
val metValues = mapOf(
|
||||||
|
"walking" to 3.5,
|
||||||
|
"running" to 8.0,
|
||||||
|
"cycling" to 6.0,
|
||||||
|
"yoga" to 3.0,
|
||||||
|
"strength" to 4.5,
|
||||||
|
"dancing" to 5.0,
|
||||||
|
"swimming" to 7.0
|
||||||
|
)
|
||||||
|
|
||||||
|
val met = metValues[activityType] ?: 4.0
|
||||||
|
return ((met * weightKg * (durationMinutes / 60.0)) * 1.05).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object ValidationUtils {
|
||||||
|
|
||||||
|
fun isValidEmail(email: String): Boolean {
|
||||||
|
return android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidAge(age: Int): Boolean {
|
||||||
|
return age in 13..100
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidWeight(weight: Double): Boolean {
|
||||||
|
return weight in 30.0..300.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidHeight(height: Double): Boolean {
|
||||||
|
return height in 100.0..250.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidWaterAmount(amount: Int): Boolean {
|
||||||
|
return amount in 50..1000
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidCycleLength(length: Int): Boolean {
|
||||||
|
return length in 21..35
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidPeriodLength(length: Int): Boolean {
|
||||||
|
return length in 3..10
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package kr.smartsoltech.wellshe.workers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
|
||||||
|
class WaterReminderWorker(
|
||||||
|
context: Context,
|
||||||
|
workerParams: WorkerParameters
|
||||||
|
) : CoroutineWorker(context, workerParams) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
return try {
|
||||||
|
// TODO: Implement water reminder logic
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PeriodReminderWorker(
|
||||||
|
context: Context,
|
||||||
|
workerParams: WorkerParameters
|
||||||
|
) : CoroutineWorker(context, workerParams) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
return try {
|
||||||
|
// TODO: Implement period reminder logic
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SleepReminderWorker(
|
||||||
|
context: Context,
|
||||||
|
workerParams: WorkerParameters
|
||||||
|
) : CoroutineWorker(context, workerParams) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
return try {
|
||||||
|
// TODO: Implement sleep reminder logic
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WorkoutReminderWorker(
|
||||||
|
context: Context,
|
||||||
|
workerParams: WorkerParameters
|
||||||
|
) : CoroutineWorker(context, workerParams) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
return try {
|
||||||
|
// TODO: Implement workout reminder logic
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package kr.smartsoltech.wellshe.workers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.*
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class WorkerManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val workManager = WorkManager.getInstance(context)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WATER_REMINDER_WORK = "water_reminder_work"
|
||||||
|
private const val PERIOD_REMINDER_WORK = "period_reminder_work"
|
||||||
|
private const val WORKOUT_REMINDER_WORK = "workout_reminder_work"
|
||||||
|
private const val SLEEP_REMINDER_WORK = "sleep_reminder_work"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheduleWaterReminders() {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
|
||||||
|
.setRequiresBatteryNotLow(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val waterReminderRequest = PeriodicWorkRequestBuilder<WaterReminderWorker>(2, TimeUnit.HOURS)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setInitialDelay(1, TimeUnit.HOURS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
WATER_REMINDER_WORK,
|
||||||
|
ExistingPeriodicWorkPolicy.REPLACE,
|
||||||
|
waterReminderRequest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun schedulePeriodReminders() {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val periodReminderRequest = PeriodicWorkRequestBuilder<PeriodReminderWorker>(1, TimeUnit.DAYS)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setInitialDelay(12, TimeUnit.HOURS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
PERIOD_REMINDER_WORK,
|
||||||
|
ExistingPeriodicWorkPolicy.REPLACE,
|
||||||
|
periodReminderRequest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheduleWorkoutReminders() {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val workoutReminderRequest = PeriodicWorkRequestBuilder<WorkoutReminderWorker>(1, TimeUnit.DAYS)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setInitialDelay(18, TimeUnit.HOURS) // 18:00
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
WORKOUT_REMINDER_WORK,
|
||||||
|
ExistingPeriodicWorkPolicy.REPLACE,
|
||||||
|
workoutReminderRequest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheduleSleepReminders() {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val sleepReminderRequest = PeriodicWorkRequestBuilder<SleepReminderWorker>(1, TimeUnit.DAYS)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setInitialDelay(22, TimeUnit.HOURS) // 22:00
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
SLEEP_REMINDER_WORK,
|
||||||
|
ExistingPeriodicWorkPolicy.REPLACE,
|
||||||
|
sleepReminderRequest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheduleAllReminders() {
|
||||||
|
scheduleWaterReminders()
|
||||||
|
schedulePeriodReminders()
|
||||||
|
scheduleWorkoutReminders()
|
||||||
|
scheduleSleepReminders()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelWaterReminders() {
|
||||||
|
workManager.cancelUniqueWork(WATER_REMINDER_WORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelPeriodReminders() {
|
||||||
|
workManager.cancelUniqueWork(PERIOD_REMINDER_WORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelWorkoutReminders() {
|
||||||
|
workManager.cancelUniqueWork(WORKOUT_REMINDER_WORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelSleepReminders() {
|
||||||
|
workManager.cancelUniqueWork(SLEEP_REMINDER_WORK)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAllReminders() {
|
||||||
|
cancelWaterReminders()
|
||||||
|
cancelPeriodReminders()
|
||||||
|
cancelWorkoutReminders()
|
||||||
|
cancelSleepReminders()
|
||||||
|
}
|
||||||
|
}
|
||||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
10
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">WellShe</string>
|
||||||
|
</resources>
|
||||||
5
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Theme.WellShe" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older than API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
17
app/src/test/java/kr/smartsoltech/wellshe/ExampleUnitTest.kt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package kr.smartsoltech.wellshe
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.analytics
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleStatsEntity
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CycleAnalyticsTest {
|
||||||
|
@Test
|
||||||
|
fun testForecastHighConfidence() {
|
||||||
|
val periods = listOf(CyclePeriodEntity(id = 0, startTs = 1_700_000_000_000, endTs = 1_700_000_000_000 + 5 * 24 * 60 * 60 * 1000, notes = ""))
|
||||||
|
val stats = CycleStatsEntity(avgCycle = 28, variance = 1, lutealLen = 14)
|
||||||
|
val forecast = CycleAnalytics.forecast(periods, stats)
|
||||||
|
assertEquals("высокая", forecast.confidence)
|
||||||
|
assertNotNull(forecast.nextStart)
|
||||||
|
assertNotNull(forecast.fertileWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.analytics
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PostureAnalyticsTest {
|
||||||
|
@Test
|
||||||
|
fun testIsExceeded() {
|
||||||
|
assertTrue(PostureAnalytics.isExceeded(10f, 20f, 5f))
|
||||||
|
assertFalse(PostureAnalytics.isExceeded(10f, 12f, 5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||