init commit

This commit is contained in:
2025-10-12 18:35:13 +09:00
commit fcd195403e
110 changed files with 12286 additions and 0 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
# Пример переменных окружения для WellShe
WATER_GOAL_DEFAULT=2000
THEME=light
ONBOARDING_COMPLETE=false
NOTIFICATIONS_ENABLED=true

15
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

6
.idea/AndroidProjectSystem.xml generated Normal file
View 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
View 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
View 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
View 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
View 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>

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

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

@@ -0,0 +1 @@
/build

79
app/build.gradle.kts Normal file
View 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
View 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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,36 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.data.entity.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?
}

View File

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

View File

@@ -0,0 +1,22 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDate
@Entity(tableName = "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()
)

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 // средний пульс за день
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 -> "Плавание"
}
}

View File

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

View File

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

View File

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

View File

@@ -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(
"Головная боль", "Усталость", "Тошнота", "Головокружение",
"Боль в спине", "Боль в суставах", "Бессонница", "Стресс",
"Простуда", "Аллергия", "Боль в животе", "Другое"
)

View File

@@ -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 -> "Ожирение"
}
}

View File

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

View File

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

View File

@@ -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} часов/день")
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 // Возвращаем значение по умолчанию
}
}

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

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

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">WellShe</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WellShe" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

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

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

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

View File

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

View File

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

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