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