Compare commits
7 Commits
functions
...
47afd9848b
| Author | SHA1 | Date | |
|---|---|---|---|
| 47afd9848b | |||
| 18753b214d | |||
| f429d54e1b | |||
| f98a77e5a5 | |||
| 3fea080626 | |||
| 0e792d28e0 | |||
| 5128762d91 |
4
.idea/deploymentTargetSelector.xml
generated
@@ -4,10 +4,10 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2025-10-12T11:23:35.923427438Z">
|
<DropdownSelection timestamp="2025-10-16T05:53:10.409373833Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=R3CT80VPBQZ" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=LGMG600S9b4da66b" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ android {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildConfigField("String", "API_BASE_URL", "\"${project.findProperty("API_BASE_URL")}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -47,6 +49,8 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion = "1.5.14"
|
kotlinCompilerExtensionVersion = "1.5.14"
|
||||||
@@ -64,6 +68,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
|
implementation(libs.material)
|
||||||
kapt(libs.hilt.compiler)
|
kapt(libs.hilt.compiler)
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
kapt("androidx.room:room-compiler:2.6.1")
|
kapt("androidx.room:room-compiler:2.6.1")
|
||||||
@@ -83,6 +88,21 @@ dependencies {
|
|||||||
implementation("com.squareup.moshi:moshi-adapters:1.15.0")
|
implementation("com.squareup.moshi:moshi-adapters:1.15.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
|
||||||
|
|
||||||
|
// Retrofit зависимости
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
|
||||||
|
// Fragment dependencies
|
||||||
|
implementation("androidx.fragment:fragment-ktx:1.6.2")
|
||||||
|
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
|
||||||
|
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
|
||||||
|
|
||||||
|
// ViewBinding
|
||||||
|
implementation("androidx.databinding:databinding-runtime:8.2.2")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation("io.mockk:mockk:1.13.8")
|
testImplementation("io.mockk:mockk:1.13.8")
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
1552
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/13.json
Normal file
@@ -24,6 +24,7 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:theme="@style/Theme.WellShe">
|
android:theme="@style/Theme.WellShe">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
@@ -1,22 +1,69 @@
|
|||||||
package kr.smartsoltech.wellshe
|
package kr.smartsoltech.wellshe
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kr.smartsoltech.wellshe.ui.navigation.WellSheNavigation
|
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
|
||||||
|
import kr.smartsoltech.wellshe.ui.navigation.AppNavGraph
|
||||||
|
import kr.smartsoltech.wellshe.ui.navigation.BottomNavigation
|
||||||
|
import kr.smartsoltech.wellshe.ui.navigation.BottomNavItem
|
||||||
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
|
||||||
|
try {
|
||||||
setContent {
|
setContent {
|
||||||
WellSheTheme {
|
WellSheTheme {
|
||||||
WellSheNavigation()
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
// Получаем AuthViewModel для управления авторизацией
|
||||||
|
val authViewModel: AuthViewModel = viewModel()
|
||||||
|
|
||||||
|
// Получаем текущий маршрут для определения показа нижней навигации
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
|
// Определяем, нужно ли отображать нижнюю панель навигации
|
||||||
|
val showBottomNav = currentRoute in BottomNavItem.items.map { it.route }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
if (showBottomNav) {
|
||||||
|
BottomNavigation(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
// Навигационный граф приложения с передачей authViewModel
|
||||||
|
AppNavGraph(
|
||||||
|
navController = navController,
|
||||||
|
modifier = Modifier.padding(paddingValues),
|
||||||
|
authViewModel = authViewModel
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Log.d("MainActivity", "Activity started successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("MainActivity", "Error in onCreate: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.room.TypeConverter
|
|||||||
entities = [
|
entities = [
|
||||||
// Основные сущности
|
// Основные сущности
|
||||||
WaterLogEntity::class,
|
WaterLogEntity::class,
|
||||||
SleepLogEntity::class,
|
|
||||||
WorkoutEntity::class,
|
WorkoutEntity::class,
|
||||||
CalorieEntity::class,
|
CalorieEntity::class,
|
||||||
StepsEntity::class,
|
StepsEntity::class,
|
||||||
@@ -43,13 +42,12 @@ import androidx.room.TypeConverter
|
|||||||
ExerciseFormulaVar::class,
|
ExerciseFormulaVar::class,
|
||||||
CatalogVersion::class
|
CatalogVersion::class
|
||||||
],
|
],
|
||||||
version = 11,
|
version = 13, // Увеличиваем версию базы данных после удаления полей mood и stressLevel
|
||||||
exportSchema = true
|
exportSchema = true
|
||||||
)
|
)
|
||||||
@TypeConverters(LocalDateConverter::class, InstantConverter::class, StringListConverter::class)
|
@TypeConverters(LocalDateConverter::class, InstantConverter::class, StringListConverter::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun waterLogDao(): WaterLogDao
|
abstract fun waterLogDao(): WaterLogDao
|
||||||
abstract fun sleepLogDao(): SleepLogDao
|
|
||||||
abstract fun workoutDao(): WorkoutDao
|
abstract fun workoutDao(): WorkoutDao
|
||||||
abstract fun calorieDao(): CalorieDao
|
abstract fun calorieDao(): CalorieDao
|
||||||
abstract fun stepsDao(): StepsDao
|
abstract fun stepsDao(): StepsDao
|
||||||
@@ -63,6 +61,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun cycleForecastDao(): CycleForecastDao
|
abstract fun cycleForecastDao(): CycleForecastDao
|
||||||
|
|
||||||
// Дополнительные DAO для repo
|
// Дополнительные DAO для repo
|
||||||
|
abstract fun beverageDao(): BeverageDao
|
||||||
|
abstract fun beverageServingDao(): BeverageServingDao
|
||||||
abstract fun beverageLogDao(): BeverageLogDao
|
abstract fun beverageLogDao(): BeverageLogDao
|
||||||
abstract fun beverageLogNutrientDao(): BeverageLogNutrientDao
|
abstract fun beverageLogNutrientDao(): BeverageLogNutrientDao
|
||||||
abstract fun beverageServingNutrientDao(): BeverageServingNutrientDao
|
abstract fun beverageServingNutrientDao(): BeverageServingNutrientDao
|
||||||
@@ -71,8 +71,11 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun workoutSessionParamDao(): WorkoutSessionParamDao
|
abstract fun workoutSessionParamDao(): WorkoutSessionParamDao
|
||||||
abstract fun workoutEventDao(): WorkoutEventDao
|
abstract fun workoutEventDao(): WorkoutEventDao
|
||||||
abstract fun exerciseDao(): ExerciseDao
|
abstract fun exerciseDao(): ExerciseDao
|
||||||
|
abstract fun exerciseParamDao(): ExerciseParamDao
|
||||||
abstract fun exerciseFormulaDao(): ExerciseFormulaDao
|
abstract fun exerciseFormulaDao(): ExerciseFormulaDao
|
||||||
abstract fun exerciseFormulaVarDao(): ExerciseFormulaVarDao
|
abstract fun exerciseFormulaVarDao(): ExerciseFormulaVarDao
|
||||||
|
abstract fun nutrientDao(): NutrientDao
|
||||||
|
abstract fun catalogVersionDao(): CatalogVersionDao
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalDateConverter {
|
class LocalDateConverter {
|
||||||
|
|||||||
@@ -5,27 +5,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kr.smartsoltech.wellshe.data.entity.*
|
import kr.smartsoltech.wellshe.data.entity.*
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
@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
|
@Dao
|
||||||
interface WorkoutDao {
|
interface WorkoutDao {
|
||||||
@Query("SELECT * FROM workouts WHERE date = :date ORDER BY id DESC")
|
@Query("SELECT * FROM workouts WHERE date = :date ORDER BY id DESC")
|
||||||
|
|||||||
@@ -22,6 +22,5 @@ data class CycleHistoryEntity(
|
|||||||
// Добавляем поля для соответствия с CyclePeriodEntity
|
// Добавляем поля для соответствия с CyclePeriodEntity
|
||||||
val flow: String = "",
|
val flow: String = "",
|
||||||
val symptoms: List<String> = emptyList(),
|
val symptoms: List<String> = emptyList(),
|
||||||
val mood: String = "",
|
|
||||||
val cycleLength: Int? = null
|
val cycleLength: Int? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,5 @@ data class CyclePeriodEntity(
|
|||||||
val endDate: LocalDate?,
|
val endDate: LocalDate?,
|
||||||
val flow: String = "",
|
val flow: String = "",
|
||||||
val symptoms: List<String> = emptyList(),
|
val symptoms: List<String> = emptyList(),
|
||||||
val mood: String = "",
|
|
||||||
val cycleLength: Int? = null
|
val cycleLength: Int? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,18 +13,6 @@ data class WaterLogEntity(
|
|||||||
val timestamp: Long = System.currentTimeMillis()
|
val timestamp: Long = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
@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 = "workouts")
|
@Entity(tableName = "workouts")
|
||||||
data class WorkoutEntity(
|
data class WorkoutEntity(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
@@ -76,5 +64,10 @@ data class UserProfileEntity(
|
|||||||
val cycleLength: Int = 28,
|
val cycleLength: Int = 28,
|
||||||
val periodLength: Int = 5,
|
val periodLength: Int = 5,
|
||||||
val lastPeriodDate: LocalDate? = null,
|
val lastPeriodDate: LocalDate? = null,
|
||||||
val profileImagePath: String = ""
|
val profileImagePath: String = "",
|
||||||
|
val emergency_contact_1_name: String? = null,
|
||||||
|
val emergency_contact_1_phone: String? = null,
|
||||||
|
val emergency_contact_2_name: String? = null,
|
||||||
|
val emergency_contact_2_phone: String? = null,
|
||||||
|
val emergency_notifications_enabled: Boolean? = false
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ data class HealthRecordEntity(
|
|||||||
val bloodPressureS: Int?,
|
val bloodPressureS: Int?,
|
||||||
val bloodPressureD: Int?,
|
val bloodPressureD: Int?,
|
||||||
val temperature: Float?,
|
val temperature: Float?,
|
||||||
val mood: String?,
|
|
||||||
val energyLevel: Int?,
|
val energyLevel: Int?,
|
||||||
val stressLevel: Int?,
|
|
||||||
val symptoms: List<String>?,
|
val symptoms: List<String>?,
|
||||||
val notes: String?
|
val notes: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val Context.authDataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_preferences")
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AuthTokenRepository @Inject constructor(
|
||||||
|
private val context: Context
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private val AUTH_TOKEN = stringPreferencesKey("auth_token")
|
||||||
|
private val USER_EMAIL = stringPreferencesKey("user_email")
|
||||||
|
private val USER_PASSWORD = stringPreferencesKey("user_password") // Храним зашифрованный пароль
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение токена авторизации
|
||||||
|
val authToken: Flow<String?> = context.authDataStore.data
|
||||||
|
.map { preferences -> preferences[AUTH_TOKEN] }
|
||||||
|
|
||||||
|
// Получение сохраненного email
|
||||||
|
val savedEmail: Flow<String?> = context.authDataStore.data
|
||||||
|
.map { preferences -> preferences[USER_EMAIL] }
|
||||||
|
|
||||||
|
// Получение сохраненного пароля
|
||||||
|
val savedPassword: Flow<String?> = context.authDataStore.data
|
||||||
|
.map { preferences -> preferences[USER_PASSWORD] }
|
||||||
|
|
||||||
|
// Проверка, есть ли сохраненные данные для автологина
|
||||||
|
val hasAuthData: Flow<Boolean> = context.authDataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
val email = preferences[USER_EMAIL]
|
||||||
|
val password = preferences[USER_PASSWORD]
|
||||||
|
!email.isNullOrEmpty() && !password.isNullOrEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение токена авторизации
|
||||||
|
suspend fun saveAuthToken(token: String) {
|
||||||
|
context.authDataStore.edit { preferences ->
|
||||||
|
preferences[AUTH_TOKEN] = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение учетных данных для автологина
|
||||||
|
suspend fun saveAuthCredentials(email: String, password: String) {
|
||||||
|
context.authDataStore.edit { preferences ->
|
||||||
|
preferences[USER_EMAIL] = email
|
||||||
|
// TODO: здесь должно быть шифрование пароля перед сохранением
|
||||||
|
preferences[USER_PASSWORD] = password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка данных авторизации при выходе
|
||||||
|
suspend fun clearAuthData() {
|
||||||
|
context.authDataStore.edit { preferences ->
|
||||||
|
preferences.remove(AUTH_TOKEN)
|
||||||
|
preferences.remove(USER_EMAIL)
|
||||||
|
preferences.remove(USER_PASSWORD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.network
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для настройки и создания API-клиентов
|
||||||
|
*/
|
||||||
|
object ApiClient {
|
||||||
|
private const val BASE_URL = "http://192.168.219.108:8000/api/v1/"
|
||||||
|
private const val CONNECT_TIMEOUT = 15L
|
||||||
|
private const val READ_TIMEOUT = 15L
|
||||||
|
private const val WRITE_TIMEOUT = 15L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает экземпляр Retrofit с настройками для работы с API
|
||||||
|
*/
|
||||||
|
private fun createRetrofit(baseUrl: String = BASE_URL): Retrofit {
|
||||||
|
val gson: Gson = GsonBuilder()
|
||||||
|
.setLenient()
|
||||||
|
.create()
|
||||||
|
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.client(createOkHttpClient())
|
||||||
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает настроенный OkHttpClient с логированием и таймаутами
|
||||||
|
*/
|
||||||
|
private fun createOkHttpClient(): OkHttpClient {
|
||||||
|
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
}
|
||||||
|
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.addInterceptor(loggingInterceptor)
|
||||||
|
.connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает сервис для работы с авторизацией
|
||||||
|
*/
|
||||||
|
fun createAuthService(): AuthService {
|
||||||
|
return createRetrofit().create(AuthService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает сервис для работы с экстренными оповещениями
|
||||||
|
*/
|
||||||
|
fun createEmergencyService(): EmergencyService {
|
||||||
|
return createRetrofit().create(EmergencyService::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.network
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перехватчик, добавляющий токен авторизации в заголовки запросов
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AuthInterceptor @Inject constructor(
|
||||||
|
private val authTokenRepository: AuthTokenRepository
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
|
||||||
|
// Пробуем получить токен авторизации (в блокирующем режиме, т.к. Interceptor не поддерживает suspend функции)
|
||||||
|
val token = runBlocking { authTokenRepository.authToken.firstOrNull() }
|
||||||
|
|
||||||
|
// Если токен есть, добавляем его в заголовок запроса
|
||||||
|
val modifiedRequest = if (!token.isNullOrEmpty()) {
|
||||||
|
originalRequest.newBuilder()
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
originalRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain.proceed(modifiedRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.network
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.model.auth.*
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Header
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс для работы с API авторизации
|
||||||
|
*/
|
||||||
|
interface AuthService {
|
||||||
|
/**
|
||||||
|
* Регистрация нового пользователя
|
||||||
|
*/
|
||||||
|
@POST("auth/register")
|
||||||
|
suspend fun register(@Body request: RegisterRequest): Response<RegisterResponseWrapper>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вход в систему
|
||||||
|
*/
|
||||||
|
@POST("auth/login")
|
||||||
|
suspend fun login(@Body request: AuthRequest): Response<DirectAuthResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление токена
|
||||||
|
*/
|
||||||
|
@POST("auth/refresh")
|
||||||
|
suspend fun refreshToken(@Body request: TokenRefreshRequest): Response<TokenRefreshResponseWrapper>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выход из системы
|
||||||
|
*/
|
||||||
|
@POST("auth/logout")
|
||||||
|
suspend fun logout(@Header("Authorization") token: String): Response<BaseResponseWrapper>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение профиля текущего пользователя
|
||||||
|
*/
|
||||||
|
@GET("users/me")
|
||||||
|
suspend fun getProfile(@Header("Authorization") token: String): Response<UserProfileResponseWrapper>
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.network
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.model.auth.BaseResponseWrapper
|
||||||
|
import kr.smartsoltech.wellshe.model.emergency.*
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс для работы с API экстренных оповещений
|
||||||
|
*/
|
||||||
|
interface EmergencyService {
|
||||||
|
/**
|
||||||
|
* Создание нового экстренного оповещения
|
||||||
|
*/
|
||||||
|
@POST("emergency/alert")
|
||||||
|
suspend fun createAlert(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Body request: EmergencyAlertRequest
|
||||||
|
): Response<EmergencyAlertResponseWrapper>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение информации о статусе экстренного оповещения
|
||||||
|
*/
|
||||||
|
@GET("emergency/alert/{alert_id}")
|
||||||
|
suspend fun getAlertStatus(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: String
|
||||||
|
): Response<EmergencyAlertStatusWrapper>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление местоположения для активного оповещения
|
||||||
|
*/
|
||||||
|
@PUT("emergency/alert/{alert_id}/location")
|
||||||
|
suspend fun updateLocation(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: String,
|
||||||
|
@Body request: LocationUpdateRequest
|
||||||
|
): Response<LocationUpdateResponseWrapper>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отмена активного экстренного оповещения
|
||||||
|
*/
|
||||||
|
@POST("emergency/alert/{alert_id}/cancel")
|
||||||
|
suspend fun cancelAlert(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: String,
|
||||||
|
@Body request: AlertCancelRequest
|
||||||
|
): Response<AlertCancelResponseWrapper>
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repo
|
||||||
|
|
||||||
|
// Устаревший репозиторий, используйте kr.smartsoltech.wellshe.data.repository.AuthRepository вместо этого
|
||||||
|
@Deprecated("Используйте kr.smartsoltech.wellshe.data.repository.AuthRepository вместо этого")
|
||||||
|
typealias AuthRepository = kr.smartsoltech.wellshe.data.repository.AuthRepository
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repository
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.network.AuthService
|
||||||
|
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||||
|
import kr.smartsoltech.wellshe.model.auth.*
|
||||||
|
import kr.smartsoltech.wellshe.util.Result
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий для работы с авторизацией и профилем пользователя
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AuthRepository @Inject constructor(
|
||||||
|
private val authService: AuthService,
|
||||||
|
private val authTokenRepository: AuthTokenRepository,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вход в систему
|
||||||
|
*/
|
||||||
|
suspend fun login(identifier: String, password: String, isEmail: Boolean): Result<AuthTokenResponse> {
|
||||||
|
return try {
|
||||||
|
// Если имя пользователя - galya0815, преобразуем его в Galya0815 с большой буквы
|
||||||
|
val correctedIdentifier = if (!isEmail && identifier.equals("galya0815", ignoreCase = true)) {
|
||||||
|
"Galya0815"
|
||||||
|
} else {
|
||||||
|
identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
val authRequest = if (isEmail) {
|
||||||
|
AuthRequest(email = correctedIdentifier, password = password)
|
||||||
|
} else {
|
||||||
|
AuthRequest(username = correctedIdentifier, password = password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем реальный API-метод login
|
||||||
|
val response = authService.login(authRequest)
|
||||||
|
|
||||||
|
// Логирование для отладки
|
||||||
|
android.util.Log.d("AuthRepository", "Login response: ${response.code()}, isSuccessful: ${response.isSuccessful}")
|
||||||
|
if (response.body() != null) {
|
||||||
|
android.util.Log.d("AuthRepository", "Response body: ${response.body()}")
|
||||||
|
} else if (response.errorBody() != null) {
|
||||||
|
android.util.Log.d("AuthRepository", "Error body: ${response.errorBody()?.string()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val directAuthResponse = response.body()
|
||||||
|
|
||||||
|
// Если ответ успешен, но не содержит ожидаемых данных
|
||||||
|
if (directAuthResponse == null) {
|
||||||
|
return Result.Error(Exception("Получен пустой ответ от сервера"))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Создаем объект AuthTokenResponse из DirectAuthResponse
|
||||||
|
val authTokenResponse = AuthTokenResponse(
|
||||||
|
accessToken = directAuthResponse.accessToken,
|
||||||
|
tokenType = directAuthResponse.tokenType,
|
||||||
|
refreshToken = "", // Может отсутствовать в ответе сервера
|
||||||
|
expiresIn = 0 // Может отсутствовать в ответе сервера
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сохраняем токен в локальное хранилище
|
||||||
|
tokenManager.saveAccessToken(authTokenResponse.accessToken)
|
||||||
|
tokenManager.saveTokenType(authTokenResponse.tokenType)
|
||||||
|
|
||||||
|
android.util.Log.d("AuthRepository", "Login successful, token: ${authTokenResponse.accessToken.take(15)}...")
|
||||||
|
Result.Success(authTokenResponse)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("AuthRepository", "Error processing auth response: ${e.message}", e)
|
||||||
|
Result.Error(Exception("Ошибка обработки ответа авторизации: ${e.message}"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка авторизации"
|
||||||
|
android.util.Log.e("AuthRepository", "Login error: $errorMessage (code ${response.code()})")
|
||||||
|
Result.Error(Exception("Ошибка авторизации: $errorMessage (код ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("AuthRepository", "Exception during login: ${e.message}", e)
|
||||||
|
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регистрация нового пользователя
|
||||||
|
*/
|
||||||
|
suspend fun register(
|
||||||
|
email: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
phone: String
|
||||||
|
): Result<Boolean> {
|
||||||
|
return try {
|
||||||
|
val registerRequest = RegisterRequest(
|
||||||
|
email = email,
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
first_name = firstName,
|
||||||
|
last_name = lastName,
|
||||||
|
phone = phone
|
||||||
|
)
|
||||||
|
|
||||||
|
// Вызываем реальный API-метод register
|
||||||
|
val response = authService.register(registerRequest)
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
Result.Success(true)
|
||||||
|
} else {
|
||||||
|
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка регистрации"
|
||||||
|
Result.Error(Exception("Ошибка регистрации: $errorMessage (код ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выход из системы
|
||||||
|
*/
|
||||||
|
suspend fun logout(accessToken: String): Result<Boolean> {
|
||||||
|
return try {
|
||||||
|
// Формируем заголовок авторизации
|
||||||
|
val authHeader = "Bearer $accessToken"
|
||||||
|
|
||||||
|
// Вызываем реальный API-метод logout
|
||||||
|
val response = authService.logout(authHeader)
|
||||||
|
|
||||||
|
// Независимо от результата запроса очищаем локальные данные авторизации
|
||||||
|
authTokenRepository.clearAuthData()
|
||||||
|
tokenManager.clearTokens()
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
Result.Success(true)
|
||||||
|
} else {
|
||||||
|
// Даже при ошибке API считаем выход успешным, так как локальные данные очищены
|
||||||
|
Result.Success(true)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Даже при исключении считаем выход успешным, так как локальные данные очищены
|
||||||
|
Result.Success(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление токена доступа
|
||||||
|
*/
|
||||||
|
suspend fun refreshToken(refreshToken: String): Result<TokenResponse> {
|
||||||
|
return try {
|
||||||
|
// Создаем запрос на обновление токена
|
||||||
|
val tokenRefreshRequest = TokenRefreshRequest(refresh_token = refreshToken)
|
||||||
|
|
||||||
|
// Вызываем реальный API-метод refreshToken
|
||||||
|
val response = authService.refreshToken(tokenRefreshRequest)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val tokenResponse = response.body()?.data
|
||||||
|
if (tokenResponse != null) {
|
||||||
|
Result.Success(tokenResponse)
|
||||||
|
} else {
|
||||||
|
Result.Error(Exception("Ответ сервера не содержит данных обновления токена"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка обновления токена"
|
||||||
|
Result.Error(Exception("Ошибка обновления токена: $errorMessage (код ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение профиля пользователя
|
||||||
|
*/
|
||||||
|
suspend fun getUserProfile(accessToken: String): Result<UserProfile> {
|
||||||
|
return try {
|
||||||
|
// Формируем заголовок авторизации
|
||||||
|
val authHeader = "Bearer $accessToken"
|
||||||
|
|
||||||
|
// Вызываем реальный API-метод получения профиля
|
||||||
|
val response = authService.getProfile(authHeader)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val userProfile = response.body()?.data
|
||||||
|
if (userProfile != null) {
|
||||||
|
Result.Success(userProfile)
|
||||||
|
} else {
|
||||||
|
Result.Error(Exception("Ответ сервера не содержит данных профиля"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка получения профиля"
|
||||||
|
Result.Error(Exception("Ошибка получения профиля: $errorMessage (код ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -263,7 +263,6 @@ class CycleRepository @Inject constructor(
|
|||||||
endDate = historyEntity.periodEnd,
|
endDate = historyEntity.periodEnd,
|
||||||
flow = historyEntity.flow,
|
flow = historyEntity.flow,
|
||||||
symptoms = historyEntity.symptoms,
|
symptoms = historyEntity.symptoms,
|
||||||
mood = historyEntity.mood,
|
|
||||||
cycleLength = historyEntity.cycleLength
|
cycleLength = historyEntity.cycleLength
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -277,7 +276,6 @@ class CycleRepository @Inject constructor(
|
|||||||
periodEnd = period.endDate,
|
periodEnd = period.endDate,
|
||||||
flow = period.flow,
|
flow = period.flow,
|
||||||
symptoms = period.symptoms,
|
symptoms = period.symptoms,
|
||||||
mood = period.mood,
|
|
||||||
cycleLength = period.cycleLength,
|
cycleLength = period.cycleLength,
|
||||||
atypical = false // по умолчанию не отмечаем как нетипичный
|
atypical = false // по умолчанию не отмечаем как нетипичный
|
||||||
)
|
)
|
||||||
@@ -292,7 +290,6 @@ class CycleRepository @Inject constructor(
|
|||||||
periodEnd = period.endDate,
|
periodEnd = period.endDate,
|
||||||
flow = period.flow,
|
flow = period.flow,
|
||||||
symptoms = period.symptoms,
|
symptoms = period.symptoms,
|
||||||
mood = period.mood,
|
|
||||||
cycleLength = period.cycleLength,
|
cycleLength = period.cycleLength,
|
||||||
atypical = false // сохраняем существующее значение, если возможно
|
atypical = false // сохраняем существующее значение, если возможно
|
||||||
)
|
)
|
||||||
@@ -306,7 +303,6 @@ class CycleRepository @Inject constructor(
|
|||||||
periodEnd = period.endDate,
|
periodEnd = period.endDate,
|
||||||
flow = period.flow,
|
flow = period.flow,
|
||||||
symptoms = period.symptoms,
|
symptoms = period.symptoms,
|
||||||
mood = period.mood,
|
|
||||||
cycleLength = period.cycleLength,
|
cycleLength = period.cycleLength,
|
||||||
atypical = false
|
atypical = false
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kr.smartsoltech.wellshe.data.network.EmergencyService
|
||||||
|
import kr.smartsoltech.wellshe.model.emergency.*
|
||||||
|
import kr.smartsoltech.wellshe.util.Result
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий для работы с экстренными оповещениями
|
||||||
|
*/
|
||||||
|
class EmergencyRepository(private val emergencyService: EmergencyService) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создание нового экстренного оповещения
|
||||||
|
*/
|
||||||
|
suspend fun createAlert(
|
||||||
|
token: String,
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
message: String? = null,
|
||||||
|
batteryLevel: Int? = null,
|
||||||
|
contactIds: List<String>? = null
|
||||||
|
): Result<EmergencyAlertResponse> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bearerToken = "Bearer $token"
|
||||||
|
val locationData = LocationData(latitude, longitude)
|
||||||
|
val request = EmergencyAlertRequest(locationData, message, batteryLevel, contactIds)
|
||||||
|
|
||||||
|
val response = emergencyService.createAlert(bearerToken, request)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
Result.Success(response.body()!!.data)
|
||||||
|
} else {
|
||||||
|
Result.Error(Exception("Ошибка создания оповещения: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение статуса экстренного оповещения
|
||||||
|
*/
|
||||||
|
suspend fun getAlertStatus(token: String, alertId: String): Result<EmergencyAlertStatus> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bearerToken = "Bearer $token"
|
||||||
|
val response = emergencyService.getAlertStatus(bearerToken, alertId)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
Result.Success(response.body()!!.data)
|
||||||
|
} else {
|
||||||
|
Result.Error(Exception("Ошибка получения статуса оповещения: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление местоположения в активном оповещении
|
||||||
|
*/
|
||||||
|
suspend fun updateLocation(
|
||||||
|
token: String,
|
||||||
|
alertId: String,
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
accuracy: Float? = null,
|
||||||
|
batteryLevel: Int? = null
|
||||||
|
): Result<LocationUpdateResponse> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bearerToken = "Bearer $token"
|
||||||
|
val request = LocationUpdateRequest(latitude, longitude, accuracy, batteryLevel)
|
||||||
|
|
||||||
|
val response = emergencyService.updateLocation(bearerToken, alertId, request)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
Result.Success(response.body()!!.data)
|
||||||
|
} else {
|
||||||
|
Result.Error(Exception("Ошибка обновления местоположения: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отмена экстренного оповещения
|
||||||
|
*/
|
||||||
|
suspend fun cancelAlert(
|
||||||
|
token: String,
|
||||||
|
alertId: String,
|
||||||
|
reason: String? = null,
|
||||||
|
details: String? = null
|
||||||
|
): Result<AlertCancelResponse> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bearerToken = "Bearer $token"
|
||||||
|
val request = AlertCancelRequest(reason, details)
|
||||||
|
|
||||||
|
val response = emergencyService.cancelAlert(bearerToken, alertId, request)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
Result.Success(response.body()!!.data)
|
||||||
|
} else {
|
||||||
|
Result.Error(Exception("Ошибка отмены оповещения: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import kr.smartsoltech.wellshe.domain.model.User
|
|||||||
import kr.smartsoltech.wellshe.domain.model.WaterIntake
|
import kr.smartsoltech.wellshe.domain.model.WaterIntake
|
||||||
import kr.smartsoltech.wellshe.domain.model.WorkoutSession
|
import kr.smartsoltech.wellshe.domain.model.WorkoutSession
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -21,7 +20,6 @@ import javax.inject.Singleton
|
|||||||
class WellSheRepository @Inject constructor(
|
class WellSheRepository @Inject constructor(
|
||||||
private val waterLogDao: WaterLogDao,
|
private val waterLogDao: WaterLogDao,
|
||||||
private val cyclePeriodDao: CyclePeriodDao,
|
private val cyclePeriodDao: CyclePeriodDao,
|
||||||
private val sleepLogDao: SleepLogDao,
|
|
||||||
private val healthRecordDao: HealthRecordDao,
|
private val healthRecordDao: HealthRecordDao,
|
||||||
private val workoutDao: WorkoutDao,
|
private val workoutDao: WorkoutDao,
|
||||||
private val calorieDao: CalorieDao,
|
private val calorieDao: CalorieDao,
|
||||||
@@ -45,8 +43,7 @@ class WellSheRepository @Inject constructor(
|
|||||||
weight = 60f,
|
weight = 60f,
|
||||||
dailyWaterGoal = 2.5f,
|
dailyWaterGoal = 2.5f,
|
||||||
dailyStepsGoal = 10000,
|
dailyStepsGoal = 10000,
|
||||||
dailyCaloriesGoal = 2000,
|
dailyCaloriesGoal = 2000
|
||||||
dailySleepGoal = 8.0f
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -157,231 +154,89 @@ class WellSheRepository @Inject constructor(
|
|||||||
// TODO: Реализовать окончание тренировки
|
// 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) {
|
suspend fun addPeriod(startDate: LocalDate, endDate: LocalDate?, flow: String, symptoms: List<String>) {
|
||||||
val period = CyclePeriodEntity(
|
val period = CyclePeriodEntity(
|
||||||
startDate = startDate,
|
startDate = startDate,
|
||||||
endDate = endDate,
|
endDate = endDate,
|
||||||
flow = flow,
|
flow = flow,
|
||||||
symptoms = symptoms,
|
symptoms = symptoms
|
||||||
mood = mood
|
|
||||||
)
|
)
|
||||||
cyclePeriodDao.insert(period)
|
// Используем CycleRepository для работы с периодами
|
||||||
|
// cyclePeriodDao.insertPeriod(period)
|
||||||
|
// TODO: Добавить интеграцию с CycleRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updatePeriod(periodId: Long, endDate: LocalDate?, flow: String, symptoms: List<String>, mood: String) {
|
suspend fun updatePeriod(periodId: Long, endDate: LocalDate?, flow: String, symptoms: List<String>) {
|
||||||
val periods = cyclePeriodDao.getAll()
|
// TODO: Реализовать через CycleRepository
|
||||||
val existingPeriod = periods.firstOrNull { it.id == periodId }
|
// val existingPeriod = cyclePeriodDao.getPeriodById(periodId)
|
||||||
if (existingPeriod != null) {
|
// existingPeriod?.let {
|
||||||
val updatedPeriod = existingPeriod.copy(
|
// val updatedPeriod = it.copy(
|
||||||
endDate = endDate,
|
// endDate = endDate,
|
||||||
flow = flow,
|
// flow = flow,
|
||||||
symptoms = symptoms,
|
// symptoms = symptoms
|
||||||
mood = mood
|
// )
|
||||||
)
|
// cyclePeriodDao.updatePeriod(updatedPeriod)
|
||||||
cyclePeriodDao.update(updatedPeriod)
|
// }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getRecentPeriods(): List<CyclePeriodEntity> {
|
fun getPeriods(): Flow<List<CyclePeriodEntity>> {
|
||||||
return cyclePeriodDao.getAll().take(6)
|
// TODO: Реализовать через CycleRepository
|
||||||
|
return flowOf(emptyList())
|
||||||
|
// return cyclePeriodDao.getAllPeriods()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deletePeriod(periodId: Long) {
|
||||||
|
// TODO: Реализовать через CycleRepository
|
||||||
|
// cyclePeriodDao.deletePeriodById(periodId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// НАСТРОЙКИ
|
// НАСТРОЙКИ
|
||||||
// =================
|
// =================
|
||||||
|
|
||||||
fun getSettings(): Flow<AppSettings> {
|
fun getAppSettings(): Flow<AppSettings> {
|
||||||
// TODO: Реализовать получение настроек из БД
|
// TODO: Реализовать получение настроек из БД
|
||||||
return flowOf(
|
return flowOf(
|
||||||
AppSettings(
|
AppSettings(
|
||||||
isWaterReminderEnabled = true,
|
notificationsEnabled = true,
|
||||||
isCycleReminderEnabled = true,
|
darkModeEnabled = false
|
||||||
isSleepReminderEnabled = true,
|
|
||||||
cycleLength = 28,
|
|
||||||
periodLength = 5,
|
|
||||||
waterGoal = 2.5f,
|
|
||||||
stepsGoal = 10000,
|
|
||||||
sleepGoal = 8.0f,
|
|
||||||
isDarkTheme = false
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateWaterReminderSetting(enabled: Boolean) {
|
suspend fun updateAppSettings(settings: AppSettings) {
|
||||||
// TODO: Реализовать обновление настройки напоминаний о воде
|
// 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() {
|
fun getDashboardData(date: LocalDate): Flow<DashboardData> {
|
||||||
// TODO: Реализовать экспорт данных пользователя
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun importUserData() {
|
|
||||||
// TODO: Реализовать импорт данных пользователя
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun clearAllUserData() {
|
|
||||||
// TODO: Реализовать очистку всех данных пользователя
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================
|
|
||||||
// ЗДОРОВЬЕ
|
|
||||||
// =================
|
|
||||||
|
|
||||||
fun getTodayHealthData(): kotlinx.coroutines.flow.Flow<HealthRecordEntity?> {
|
|
||||||
val today = LocalDate.now()
|
|
||||||
return healthRecordDao.getByDateFlow(today)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAllHealthRecords(): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>> {
|
|
||||||
return healthRecordDao.getAllFlow()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getRecentHealthRecords(limit: Int = 10): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>> {
|
|
||||||
return healthRecordDao.getAllFlow().map { records: List<HealthRecordEntity> ->
|
|
||||||
records.sortedByDescending { r -> r.date }.take(limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun saveHealthRecord(record: HealthRecordEntity) {
|
|
||||||
if (record.id != 0L) {
|
|
||||||
healthRecordDao.update(record)
|
|
||||||
} else {
|
|
||||||
healthRecordDao.insert(record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun deleteHealthRecord(recordId: Long) {
|
|
||||||
val record = healthRecordDao.getAll().firstOrNull { it.id == recordId }
|
|
||||||
if (record != null) {
|
|
||||||
healthRecordDao.delete(record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================
|
|
||||||
// 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 flow {
|
return flow {
|
||||||
emit(waterLogDao.getWaterLogsForDate(date))
|
emit(
|
||||||
|
DashboardData(
|
||||||
|
date = date,
|
||||||
|
waterIntake = 1.2f,
|
||||||
|
steps = 6500,
|
||||||
|
calories = 1850,
|
||||||
|
workouts = 1,
|
||||||
|
cycleDay = null
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вспомогательные data классы
|
|
||||||
data class DashboardData(
|
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 date: LocalDate,
|
||||||
val bloodPressureSystolic: Int = 0,
|
val waterIntake: Float,
|
||||||
val bloodPressureDiastolic: Int = 0,
|
val steps: Int,
|
||||||
val heartRate: Int = 0,
|
val calories: Int,
|
||||||
val weight: Float = 0f,
|
val workouts: Int,
|
||||||
val mood: String = "neutral", // Добавляем поле настроения
|
val cycleDay: Int?
|
||||||
val energyLevel: Int = 5, // Добавляем уровень энергии (1-10)
|
|
||||||
val stressLevel: Int = 5, // Добавляем уровень стресса (1-10)
|
|
||||||
val notes: String = ""
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.storage
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для управления токенами авторизации
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class TokenManager @Inject constructor() {
|
||||||
|
|
||||||
|
// Токен авторизации
|
||||||
|
private var accessToken: String? = null
|
||||||
|
|
||||||
|
// Токен обновления
|
||||||
|
private var refreshToken: String? = null
|
||||||
|
|
||||||
|
// Время истечения токена
|
||||||
|
private var expiresAt: Long = 0
|
||||||
|
|
||||||
|
// Тип токена (например, "Bearer")
|
||||||
|
private var tokenType: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить токены авторизации
|
||||||
|
*/
|
||||||
|
fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Int) {
|
||||||
|
this.accessToken = accessToken
|
||||||
|
this.refreshToken = refreshToken
|
||||||
|
this.expiresAt = Date().time + (expiresIn * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить только токен доступа
|
||||||
|
*/
|
||||||
|
fun updateAccessToken(accessToken: String, expiresIn: Int) {
|
||||||
|
this.accessToken = accessToken
|
||||||
|
this.expiresAt = Date().time + (expiresIn * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить токен доступа
|
||||||
|
*/
|
||||||
|
fun saveAccessToken(accessToken: String) {
|
||||||
|
this.accessToken = accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить тип токена
|
||||||
|
*/
|
||||||
|
fun saveTokenType(tokenType: String) {
|
||||||
|
this.tokenType = tokenType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить тип токена
|
||||||
|
*/
|
||||||
|
fun getTokenType(): String? {
|
||||||
|
return tokenType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очистить все токены
|
||||||
|
*/
|
||||||
|
fun clearTokens() {
|
||||||
|
accessToken = null
|
||||||
|
refreshToken = null
|
||||||
|
tokenType = null
|
||||||
|
expiresAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить токен авторизации
|
||||||
|
*/
|
||||||
|
fun getAccessToken(): String? {
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить токен обновления
|
||||||
|
*/
|
||||||
|
fun getRefreshToken(): String? {
|
||||||
|
return refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверить, истек ли токен авторизации
|
||||||
|
*/
|
||||||
|
fun isAccessTokenExpired(): Boolean {
|
||||||
|
return Date().time > expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить токен авторизации (для обратной совместимости)
|
||||||
|
*/
|
||||||
|
fun saveAuthToken(token: String) {
|
||||||
|
accessToken = token
|
||||||
|
expiresAt = Date().time + (3600 * 1000) // По умолчанию 1 час
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,21 +8,8 @@ import dagger.hilt.InstallIn
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import kr.smartsoltech.wellshe.data.AppDatabase
|
import kr.smartsoltech.wellshe.data.AppDatabase
|
||||||
import kr.smartsoltech.wellshe.data.datastore.DataStoreManager
|
|
||||||
import kr.smartsoltech.wellshe.data.dao.*
|
import kr.smartsoltech.wellshe.data.dao.*
|
||||||
import kr.smartsoltech.wellshe.data.repo.DrinkLogger
|
import kr.smartsoltech.wellshe.data.repo.*
|
||||||
import kr.smartsoltech.wellshe.data.repo.WeightRepository
|
|
||||||
import kr.smartsoltech.wellshe.data.repo.WorkoutService
|
|
||||||
import kr.smartsoltech.wellshe.data.MIGRATION_1_2
|
|
||||||
import kr.smartsoltech.wellshe.data.MIGRATION_2_3
|
|
||||||
import kr.smartsoltech.wellshe.data.MIGRATION_3_4
|
|
||||||
import kr.smartsoltech.wellshe.data.MIGRATION_4_5
|
|
||||||
import kr.smartsoltech.wellshe.data.MIGRATION_5_6
|
|
||||||
import kr.smartsoltech.wellshe.data.MIGRATION_6_7
|
|
||||||
import kr.smartsoltech.wellshe.data.MIGRATION_7_8
|
|
||||||
import kr.smartsoltech.wellshe.data.MIGRATION_8_9
|
|
||||||
import kr.smartsoltech.wellshe.data.MIGRATION_9_10
|
|
||||||
import kr.smartsoltech.wellshe.data.MIGRATION_10_11
|
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -31,34 +18,18 @@ object AppModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideDataStoreManager(@ApplicationContext context: Context): DataStoreManager =
|
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
|
||||||
DataStoreManager(context)
|
return Room.databaseBuilder(
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
|
||||||
Room.databaseBuilder(
|
|
||||||
context,
|
context,
|
||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"well_she_db"
|
"wellshe_database"
|
||||||
)
|
).build()
|
||||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11)
|
}
|
||||||
.fallbackToDestructiveMigration()
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// DAO providers
|
// DAO Providers
|
||||||
@Provides
|
@Provides
|
||||||
fun provideWaterLogDao(database: AppDatabase): WaterLogDao = database.waterLogDao()
|
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
|
@Provides
|
||||||
fun provideWorkoutDao(database: AppDatabase): WorkoutDao = database.workoutDao()
|
fun provideWorkoutDao(database: AppDatabase): WorkoutDao = database.workoutDao()
|
||||||
|
|
||||||
@@ -71,7 +42,12 @@ object AppModule {
|
|||||||
@Provides
|
@Provides
|
||||||
fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao()
|
fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao()
|
||||||
|
|
||||||
// DAO для BodyRepo
|
@Provides
|
||||||
|
fun provideCyclePeriodDao(database: AppDatabase): CyclePeriodDao = database.cyclePeriodDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideHealthRecordDao(database: AppDatabase): HealthRecordDao = database.healthRecordDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideBeverageLogDao(database: AppDatabase): BeverageLogDao = database.beverageLogDao()
|
fun provideBeverageLogDao(database: AppDatabase): BeverageLogDao = database.beverageLogDao()
|
||||||
|
|
||||||
@@ -102,7 +78,28 @@ object AppModule {
|
|||||||
@Provides
|
@Provides
|
||||||
fun provideExerciseFormulaVarDao(database: AppDatabase): ExerciseFormulaVarDao = database.exerciseFormulaVarDao()
|
fun provideExerciseFormulaVarDao(database: AppDatabase): ExerciseFormulaVarDao = database.exerciseFormulaVarDao()
|
||||||
|
|
||||||
// Repo providers
|
@Provides
|
||||||
|
fun provideBeverageDao(database: AppDatabase): BeverageDao = database.beverageDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideBeverageServingDao(database: AppDatabase): BeverageServingDao = database.beverageServingDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideExerciseParamDao(database: AppDatabase): ExerciseParamDao = database.exerciseParamDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideNutrientDao(database: AppDatabase): NutrientDao = database.nutrientDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideCatalogVersionDao(database: AppDatabase): CatalogVersionDao = database.catalogVersionDao()
|
||||||
|
|
||||||
|
// Repository/Service Providers
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideWeightRepository(weightLogDao: WeightLogDao): WeightRepository {
|
||||||
|
return WeightRepository(weightLogDao)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideDrinkLogger(
|
fun provideDrinkLogger(
|
||||||
@@ -110,12 +107,9 @@ object AppModule {
|
|||||||
beverageLogDao: BeverageLogDao,
|
beverageLogDao: BeverageLogDao,
|
||||||
beverageLogNutrientDao: BeverageLogNutrientDao,
|
beverageLogNutrientDao: BeverageLogNutrientDao,
|
||||||
servingNutrientDao: BeverageServingNutrientDao
|
servingNutrientDao: BeverageServingNutrientDao
|
||||||
): DrinkLogger = DrinkLogger(waterLogDao, beverageLogDao, beverageLogNutrientDao, servingNutrientDao)
|
): DrinkLogger {
|
||||||
|
return DrinkLogger(waterLogDao, beverageLogDao, beverageLogNutrientDao, servingNutrientDao)
|
||||||
@Provides
|
}
|
||||||
@Singleton
|
|
||||||
fun provideWeightRepository(weightLogDao: WeightLogDao): WeightRepository =
|
|
||||||
WeightRepository(weightLogDao)
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -127,23 +121,27 @@ object AppModule {
|
|||||||
formulaDao: ExerciseFormulaDao,
|
formulaDao: ExerciseFormulaDao,
|
||||||
formulaVarDao: ExerciseFormulaVarDao,
|
formulaVarDao: ExerciseFormulaVarDao,
|
||||||
exerciseDao: ExerciseDao
|
exerciseDao: ExerciseDao
|
||||||
): WorkoutService = WorkoutService(sessionDao, paramDao, eventDao, weightRepo, formulaDao, formulaVarDao, exerciseDao)
|
): WorkoutService {
|
||||||
|
return WorkoutService(sessionDao, paramDao, eventDao, weightRepo, formulaDao, formulaVarDao, exerciseDao)
|
||||||
|
}
|
||||||
|
|
||||||
// Repository
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideWellSheRepository(
|
fun provideBeverageCatalogRepository(
|
||||||
waterLogDao: WaterLogDao,
|
beverageDao: BeverageDao,
|
||||||
cyclePeriodDao: CyclePeriodDao,
|
servingDao: BeverageServingDao,
|
||||||
sleepLogDao: SleepLogDao,
|
servingNutrientDao: BeverageServingNutrientDao
|
||||||
healthRecordDao: HealthRecordDao,
|
): BeverageCatalogRepository {
|
||||||
workoutDao: WorkoutDao,
|
return BeverageCatalogRepository(beverageDao, servingDao, servingNutrientDao)
|
||||||
calorieDao: CalorieDao,
|
}
|
||||||
stepsDao: StepsDao,
|
|
||||||
userProfileDao: UserProfileDao
|
@Provides
|
||||||
): kr.smartsoltech.wellshe.data.repository.WellSheRepository =
|
@Singleton
|
||||||
kr.smartsoltech.wellshe.data.repository.WellSheRepository(
|
fun provideExerciseCatalogRepository(
|
||||||
waterLogDao, cyclePeriodDao, sleepLogDao, healthRecordDao,
|
exerciseDao: ExerciseDao,
|
||||||
workoutDao, calorieDao, stepsDao, userProfileDao
|
paramDao: ExerciseParamDao,
|
||||||
)
|
formulaDao: ExerciseFormulaDao
|
||||||
|
): ExerciseCatalogRepository {
|
||||||
|
return ExerciseCatalogRepository(exerciseDao, paramDao, formulaDao)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
app/src/main/java/kr/smartsoltech/wellshe/di/AuthModule.kt
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package kr.smartsoltech.wellshe.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.network.AuthService
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.AuthRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.GetUserProfileUseCase
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.LoginUseCase
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.LogoutUseCase
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.RegisterUseCase
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.RefreshTokenUseCase
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AuthModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthTokenRepository(@ApplicationContext context: Context): AuthTokenRepository {
|
||||||
|
return AuthTokenRepository(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideTokenManager(): TokenManager {
|
||||||
|
return TokenManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthService(retrofit: Retrofit): AuthService {
|
||||||
|
return retrofit.create(AuthService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthRepository(
|
||||||
|
authService: AuthService,
|
||||||
|
authTokenRepository: AuthTokenRepository,
|
||||||
|
tokenManager: TokenManager
|
||||||
|
): AuthRepository {
|
||||||
|
return AuthRepository(authService, authTokenRepository, tokenManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideLoginUseCase(authRepository: AuthRepository, tokenManager: TokenManager): LoginUseCase {
|
||||||
|
return LoginUseCase(authRepository, tokenManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideRegisterUseCase(authRepository: AuthRepository): RegisterUseCase {
|
||||||
|
return RegisterUseCase(authRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideLogoutUseCase(authRepository: AuthRepository, tokenManager: TokenManager): LogoutUseCase {
|
||||||
|
return LogoutUseCase(authRepository, tokenManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideGetUserProfileUseCase(authRepository: AuthRepository, tokenManager: TokenManager): GetUserProfileUseCase {
|
||||||
|
return GetUserProfileUseCase(authRepository, tokenManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideRefreshTokenUseCase(authRepository: AuthRepository, tokenManager: TokenManager): RefreshTokenUseCase {
|
||||||
|
return RefreshTokenUseCase(authRepository, tokenManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package kr.smartsoltech.wellshe.di
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kr.smartsoltech.wellshe.BuildConfig
|
||||||
|
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.network.AuthInterceptor
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object NetworkModule {
|
||||||
|
private const val CONNECT_TIMEOUT = 15L
|
||||||
|
private const val READ_TIMEOUT = 15L
|
||||||
|
private const val WRITE_TIMEOUT = 15L
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideGson(): Gson {
|
||||||
|
return GsonBuilder()
|
||||||
|
.setLenient()
|
||||||
|
.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthInterceptor(authTokenRepository: AuthTokenRepository): AuthInterceptor {
|
||||||
|
return AuthInterceptor(authTokenRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRetrofit(gson: Gson, authInterceptor: AuthInterceptor): Retrofit {
|
||||||
|
val client = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
})
|
||||||
|
.connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl(BuildConfig.API_BASE_URL)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
|
.client(client)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.smartsoltech.wellshe.di
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Provider
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фабрика для создания ViewModel с внедрением зависимостей через Hilt
|
||||||
|
*/
|
||||||
|
class ViewModelFactory @Inject constructor(
|
||||||
|
private val creators: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
val creator = creators[modelClass] ?: creators.entries.firstOrNull {
|
||||||
|
modelClass.isAssignableFrom(it.key)
|
||||||
|
}?.value ?: throw IllegalArgumentException("unknown model class $modelClass")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
creator.get() as T
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw RuntimeException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package kr.smartsoltech.wellshe.domain.analytics
|
|
||||||
|
|
||||||
import kr.smartsoltech.wellshe.data.entity.SleepLogEntity
|
|
||||||
|
|
||||||
object SleepAnalytics {
|
|
||||||
/**
|
|
||||||
* Расчёт долга сна и недельного тренда
|
|
||||||
*/
|
|
||||||
fun sleepDebt(logs: List<SleepLogEntity>, targetHours: Int = 8): Int {
|
|
||||||
val total = logs.sumOf { it.duration.toDouble() }
|
|
||||||
val expected = logs.size * targetHours
|
|
||||||
return (expected - total).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.auth
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.AuthRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||||
|
import kr.smartsoltech.wellshe.model.auth.UserProfile
|
||||||
|
import kr.smartsoltech.wellshe.util.Result
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для регистрации нового пользователя
|
||||||
|
*/
|
||||||
|
class RegisterUseCase(private val authRepository: AuthRepository) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
email: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
phone: String
|
||||||
|
): Result<Boolean> {
|
||||||
|
val result = authRepository.register(email, username, password, firstName, lastName, phone)
|
||||||
|
return when (result) {
|
||||||
|
is Result.Success -> Result.Success(true)
|
||||||
|
is Result.Error -> Result.Error(result.exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для авторизации пользователя
|
||||||
|
*/
|
||||||
|
class LoginUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
|
||||||
|
suspend operator fun invoke(identifier: String, password: String, isEmail: Boolean): Result<Boolean> {
|
||||||
|
val result = authRepository.login(identifier, password, isEmail)
|
||||||
|
return when (result) {
|
||||||
|
is Result.Success -> {
|
||||||
|
val response = result.data
|
||||||
|
tokenManager.saveTokens(response.accessToken, response.refreshToken, response.expiresIn)
|
||||||
|
Result.Success(true)
|
||||||
|
}
|
||||||
|
is Result.Error -> Result.Error(result.exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для выхода пользователя из системы
|
||||||
|
*/
|
||||||
|
class LogoutUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
|
||||||
|
suspend operator fun invoke(): Result<Boolean> {
|
||||||
|
val accessToken = tokenManager.getAccessToken()
|
||||||
|
if (accessToken == null) {
|
||||||
|
tokenManager.clearTokens()
|
||||||
|
return Result.Success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = authRepository.logout(accessToken)
|
||||||
|
tokenManager.clearTokens() // Очищаем токены даже при ошибке запроса
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для получения профиля пользователя
|
||||||
|
*/
|
||||||
|
class GetUserProfileUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
|
||||||
|
suspend operator fun invoke(): Result<UserProfile> {
|
||||||
|
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||||
|
|
||||||
|
return if (tokenManager.isAccessTokenExpired()) {
|
||||||
|
// Если токен истек, пытаемся обновить его
|
||||||
|
val refreshToken = tokenManager.getRefreshToken() ?: return Result.Error(Exception("Токен обновления недоступен"))
|
||||||
|
when (val refreshResult = authRepository.refreshToken(refreshToken)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
tokenManager.updateAccessToken(refreshResult.data.accessToken, refreshResult.data.expiresIn)
|
||||||
|
// Получаем профиль с обновленным токеном
|
||||||
|
authRepository.getUserProfile(refreshResult.data.accessToken)
|
||||||
|
}
|
||||||
|
is Result.Error -> Result.Error(refreshResult.exception)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Получаем профиль с текущим токеном
|
||||||
|
authRepository.getUserProfile(accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для обновления токена доступа
|
||||||
|
*/
|
||||||
|
class RefreshTokenUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
|
||||||
|
suspend operator fun invoke(): Result<Boolean> {
|
||||||
|
val refreshToken = tokenManager.getRefreshToken() ?: return Result.Error(Exception("Токен обновления недоступен"))
|
||||||
|
|
||||||
|
return when (val result = authRepository.refreshToken(refreshToken)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
tokenManager.updateAccessToken(result.data.accessToken, result.data.expiresIn)
|
||||||
|
Result.Success(true)
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
// Если ошибка обновления, то считаем, что пользователь не авторизован
|
||||||
|
tokenManager.clearTokens()
|
||||||
|
Result.Error(result.exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.emergency
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.EmergencyRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||||
|
import kr.smartsoltech.wellshe.model.emergency.*
|
||||||
|
import kr.smartsoltech.wellshe.util.Result
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для создания экстренного оповещения
|
||||||
|
*/
|
||||||
|
class CreateEmergencyAlertUseCase(
|
||||||
|
private val emergencyRepository: EmergencyRepository,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
message: String? = null,
|
||||||
|
batteryLevel: Int? = null,
|
||||||
|
contactIds: List<String>? = null
|
||||||
|
): Result<EmergencyAlertResponse> {
|
||||||
|
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||||
|
|
||||||
|
return emergencyRepository.createAlert(
|
||||||
|
accessToken, latitude, longitude, message, batteryLevel, contactIds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для получения статуса экстренного оповещения
|
||||||
|
*/
|
||||||
|
class GetAlertStatusUseCase(
|
||||||
|
private val emergencyRepository: EmergencyRepository,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(alertId: String): Result<EmergencyAlertStatus> {
|
||||||
|
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||||
|
|
||||||
|
return emergencyRepository.getAlertStatus(accessToken, alertId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для обновления местоположения при активном оповещении
|
||||||
|
*/
|
||||||
|
class UpdateLocationUseCase(
|
||||||
|
private val emergencyRepository: EmergencyRepository,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
alertId: String,
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
accuracy: Float? = null,
|
||||||
|
batteryLevel: Int? = null
|
||||||
|
): Result<LocationUpdateResponse> {
|
||||||
|
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||||
|
|
||||||
|
return emergencyRepository.updateLocation(
|
||||||
|
accessToken, alertId, latitude, longitude, accuracy, batteryLevel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для отмены экстренного оповещения
|
||||||
|
*/
|
||||||
|
class CancelAlertUseCase(
|
||||||
|
private val emergencyRepository: EmergencyRepository,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
alertId: String,
|
||||||
|
reason: String? = null,
|
||||||
|
details: String? = null
|
||||||
|
): Result<AlertCancelResponse> {
|
||||||
|
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||||
|
|
||||||
|
return emergencyRepository.cancelAlert(accessToken, alertId, reason, details)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,6 @@
|
|||||||
package kr.smartsoltech.wellshe.domain.model
|
package kr.smartsoltech.wellshe.domain.model
|
||||||
|
|
||||||
data class AppSettings(
|
data class AppSettings(
|
||||||
val id: Long = 0,
|
val notificationsEnabled: Boolean = true,
|
||||||
val isWaterReminderEnabled: Boolean = true,
|
val darkModeEnabled: Boolean = false
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ data class User(
|
|||||||
val dailyWaterGoal: Float = 2.5f, // в литрах
|
val dailyWaterGoal: Float = 2.5f, // в литрах
|
||||||
val dailyStepsGoal: Int = 10000,
|
val dailyStepsGoal: Int = 10000,
|
||||||
val dailyCaloriesGoal: Int = 2000,
|
val dailyCaloriesGoal: Int = 2000,
|
||||||
val dailySleepGoal: Float = 8.0f, // в часах
|
|
||||||
val cycleLength: Int = 28, // дней
|
val cycleLength: Int = 28, // дней
|
||||||
val periodLength: Int = 5, // дней
|
val periodLength: Int = 5, // дней
|
||||||
val lastPeriodStart: LocalDate? = null,
|
val lastPeriodStart: LocalDate? = null,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model.auth
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для запроса авторизации
|
||||||
|
*/
|
||||||
|
data class AuthRequest(
|
||||||
|
val username: String? = null,
|
||||||
|
val email: String? = null,
|
||||||
|
val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// Создаем типоним для совместимости
|
||||||
|
typealias LoginRequest = AuthRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для запроса регистрации
|
||||||
|
*/
|
||||||
|
data class RegisterRequest(
|
||||||
|
val email: String,
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
val first_name: String,
|
||||||
|
val last_name: String,
|
||||||
|
val phone: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для запроса обновления токена
|
||||||
|
*/
|
||||||
|
data class TokenRefreshRequest(
|
||||||
|
val refresh_token: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для запроса выхода из системы
|
||||||
|
*/
|
||||||
|
data class LogoutRequest(
|
||||||
|
val refresh_token: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model.auth
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовая модель ответа от API
|
||||||
|
*/
|
||||||
|
sealed class ApiResponse<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Успешный ответ от API
|
||||||
|
*/
|
||||||
|
data class SuccessResponse<T>(
|
||||||
|
val status: String,
|
||||||
|
val data: T,
|
||||||
|
val message: String? = null
|
||||||
|
) : ApiResponse<T>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ответ с ошибкой от API
|
||||||
|
*/
|
||||||
|
data class ErrorResponse<T>(
|
||||||
|
val status: String,
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
val details: Map<String, List<String>>? = null
|
||||||
|
) : ApiResponse<T>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель ответа при успешной авторизации
|
||||||
|
*/
|
||||||
|
data class AuthTokenResponse(
|
||||||
|
@SerializedName("access_token") val accessToken: String,
|
||||||
|
@SerializedName("refresh_token") val refreshToken: String,
|
||||||
|
@SerializedName("token_type") val tokenType: String,
|
||||||
|
@SerializedName("expires_in") val expiresIn: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель ответа при обновлении токена
|
||||||
|
*/
|
||||||
|
data class TokenRefreshResponse(
|
||||||
|
@SerializedName("access_token") val accessToken: String,
|
||||||
|
@SerializedName("token_type") val tokenType: String,
|
||||||
|
@SerializedName("expires_in") val expiresIn: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель ответа при регистрации
|
||||||
|
*/
|
||||||
|
data class RegisterResponse(
|
||||||
|
@SerializedName("user_id") val userId: String,
|
||||||
|
val username: String,
|
||||||
|
val email: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типонимы для обеспечения совместимости с кодом
|
||||||
|
*/
|
||||||
|
typealias AuthResponse = AuthTokenResponse
|
||||||
|
typealias TokenResponse = TokenRefreshResponse
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model.auth
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель прямого ответа авторизации от сервера, без обертки data
|
||||||
|
*/
|
||||||
|
data class DirectAuthResponse(
|
||||||
|
@SerializedName("access_token") val accessToken: String,
|
||||||
|
@SerializedName("token_type") val tokenType: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model.auth
|
||||||
|
|
||||||
|
// Файл больше не используется
|
||||||
|
// Типонимы перенесены в AuthResponse.kt
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model.auth
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовая обертка для ответа API
|
||||||
|
*/
|
||||||
|
open class BaseResponseWrapper(
|
||||||
|
val status: String = "success",
|
||||||
|
val message: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обертка для ответа регистрации
|
||||||
|
*/
|
||||||
|
class RegisterResponseWrapper(
|
||||||
|
@SerializedName("data") val data: RegisterResponse
|
||||||
|
) : BaseResponseWrapper()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обертка для ответа авторизации
|
||||||
|
*/
|
||||||
|
class AuthTokenResponseWrapper(
|
||||||
|
@SerializedName("data") val data: AuthTokenResponse
|
||||||
|
) : BaseResponseWrapper()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обертка для ответа обновления токена
|
||||||
|
*/
|
||||||
|
class TokenRefreshResponseWrapper(
|
||||||
|
@SerializedName("data") val data: TokenRefreshResponse
|
||||||
|
) : BaseResponseWrapper()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обертка для ответа с профилем пользователя
|
||||||
|
*/
|
||||||
|
class UserProfileResponseWrapper(
|
||||||
|
@SerializedName("data") val data: UserProfile
|
||||||
|
) : BaseResponseWrapper()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model.auth
|
||||||
|
|
||||||
|
// Файл больше не используется
|
||||||
|
// Все классы и типонимы перенесены в AuthResponse.kt
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model.auth
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель данных профиля пользователя
|
||||||
|
*/
|
||||||
|
data class UserProfile(
|
||||||
|
val id: Long = 0,
|
||||||
|
val username: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
@SerializedName("first_name") val firstName: String = "",
|
||||||
|
@SerializedName("last_name") val lastName: String = "",
|
||||||
|
val phone: String = "",
|
||||||
|
@SerializedName("user_id") val userId: String = "",
|
||||||
|
@SerializedName("created_at") val createdAt: String = "",
|
||||||
|
@SerializedName("is_verified") val isVerified: Boolean = false,
|
||||||
|
|
||||||
|
// Дополнительные поля для экрана редактирования профиля
|
||||||
|
val bio: String? = null,
|
||||||
|
@SerializedName("date_of_birth") val date_of_birth: String? = null,
|
||||||
|
|
||||||
|
// Экстренные контакты
|
||||||
|
@SerializedName("emergency_contact_1_name") val emergency_contact_1_name: String? = null,
|
||||||
|
@SerializedName("emergency_contact_1_phone") val emergency_contact_1_phone: String? = null,
|
||||||
|
@SerializedName("emergency_contact_2_name") val emergency_contact_2_name: String? = null,
|
||||||
|
@SerializedName("emergency_contact_2_phone") val emergency_contact_2_phone: String? = null,
|
||||||
|
|
||||||
|
// Настройки уведомлений и доступа
|
||||||
|
@SerializedName("emergency_notifications_enabled") val emergency_notifications_enabled: Boolean? = false,
|
||||||
|
@SerializedName("location_sharing_enabled") val location_sharing_enabled: Boolean? = false,
|
||||||
|
@SerializedName("push_notifications_enabled") val push_notifications_enabled: Boolean? = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model.emergency
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для запроса создания экстренного оповещения
|
||||||
|
*/
|
||||||
|
data class EmergencyAlertRequest(
|
||||||
|
val location: LocationData,
|
||||||
|
val message: String? = null,
|
||||||
|
@SerializedName("battery_level") val batteryLevel: Int? = null,
|
||||||
|
@SerializedName("contact_ids") val contactIds: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель данных о местоположении
|
||||||
|
*/
|
||||||
|
data class LocationData(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val accuracy: Float? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для запроса обновления местоположения в активном оповещении
|
||||||
|
*/
|
||||||
|
data class LocationUpdateRequest(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val accuracy: Float? = null,
|
||||||
|
@SerializedName("battery_level") val batteryLevel: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для запроса отмены экстренного оповещения
|
||||||
|
*/
|
||||||
|
data class AlertCancelRequest(
|
||||||
|
val reason: String? = null,
|
||||||
|
val details: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model.emergency
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель ответа при создании экстренного оповещения
|
||||||
|
*/
|
||||||
|
data class EmergencyAlertResponse(
|
||||||
|
@SerializedName("alert_id") val alertId: String,
|
||||||
|
@SerializedName("created_at") val createdAt: String,
|
||||||
|
val status: String,
|
||||||
|
val message: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель контакта с информацией о статусе уведомления
|
||||||
|
*/
|
||||||
|
data class NotifiedContact(
|
||||||
|
@SerializedName("contact_id") val contactId: String,
|
||||||
|
val status: String,
|
||||||
|
@SerializedName("notified_at") val notifiedAt: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель информации о местоположении с датой обновления
|
||||||
|
*/
|
||||||
|
data class LocationStatus(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
@SerializedName("updated_at") val updatedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель детального статуса экстренного оповещения
|
||||||
|
*/
|
||||||
|
data class EmergencyAlertStatus(
|
||||||
|
@SerializedName("alert_id") val alertId: String,
|
||||||
|
@SerializedName("created_at") val createdAt: String,
|
||||||
|
val status: String,
|
||||||
|
val location: LocationStatus,
|
||||||
|
@SerializedName("notified_contacts") val notifiedContacts: List<NotifiedContact>,
|
||||||
|
@SerializedName("emergency_services_notified") val emergencyServicesNotified: Boolean,
|
||||||
|
@SerializedName("emergency_services_notified_at") val emergencyServicesNotifiedAt: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель ответа при обновлении местоположения
|
||||||
|
*/
|
||||||
|
data class LocationUpdateResponse(
|
||||||
|
@SerializedName("updated_at") val updatedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель ответа при отмене экстренного оповещения
|
||||||
|
*/
|
||||||
|
data class AlertCancelResponse(
|
||||||
|
@SerializedName("alert_id") val alertId: String,
|
||||||
|
@SerializedName("cancelled_at") val cancelledAt: String,
|
||||||
|
val status: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package kr.smartsoltech.wellshe.model.emergency
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import kr.smartsoltech.wellshe.model.auth.BaseResponseWrapper
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обертка для ответа при создании экстренного оповещения
|
||||||
|
*/
|
||||||
|
class EmergencyAlertResponseWrapper(
|
||||||
|
@SerializedName("data") val data: EmergencyAlertResponse
|
||||||
|
) : BaseResponseWrapper()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обертка для ответа при получении статуса экстренного оповещения
|
||||||
|
*/
|
||||||
|
class EmergencyAlertStatusWrapper(
|
||||||
|
@SerializedName("data") val data: EmergencyAlertStatus
|
||||||
|
) : BaseResponseWrapper()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обертка для ответа при обновлении местоположения
|
||||||
|
*/
|
||||||
|
class LocationUpdateResponseWrapper(
|
||||||
|
@SerializedName("data") val data: LocationUpdateResponse
|
||||||
|
) : BaseResponseWrapper()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обертка для ответа при отмене экстренного оповещения
|
||||||
|
*/
|
||||||
|
class AlertCancelResponseWrapper(
|
||||||
|
@SerializedName("data") val data: AlertCancelResponse
|
||||||
|
) : BaseResponseWrapper()
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.auth
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.GetUserProfileUseCase
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.LoginUseCase
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.LogoutUseCase
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.RegisterUseCase
|
||||||
|
import kr.smartsoltech.wellshe.model.auth.AuthTokenResponseWrapper
|
||||||
|
import kr.smartsoltech.wellshe.model.auth.UserProfile
|
||||||
|
import kr.smartsoltech.wellshe.util.Result
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel для управления авторизацией и профилем пользователя
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class AuthViewModel @Inject constructor(
|
||||||
|
private val loginUseCase: LoginUseCase,
|
||||||
|
private val registerUseCase: RegisterUseCase,
|
||||||
|
private val logoutUseCase: LogoutUseCase,
|
||||||
|
private val getUserProfileUseCase: GetUserProfileUseCase,
|
||||||
|
private val authTokenRepository: AuthTokenRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _authState = MutableLiveData<AuthState>()
|
||||||
|
val authState: LiveData<AuthState> = _authState
|
||||||
|
|
||||||
|
private val _userProfile = MutableLiveData<UserProfile?>()
|
||||||
|
val userProfile: LiveData<UserProfile?> = _userProfile
|
||||||
|
|
||||||
|
private val _isLoading = MutableLiveData(false)
|
||||||
|
val isLoading: LiveData<Boolean> = _isLoading
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Проверяем наличие сохраненных данных для автоматического входа
|
||||||
|
checkSavedCredentials()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка и использование сохраненных учетных данных для автоматического входа
|
||||||
|
*/
|
||||||
|
private fun checkSavedCredentials() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val hasAuthData = authTokenRepository.hasAuthData.first()
|
||||||
|
if (hasAuthData) {
|
||||||
|
val email = authTokenRepository.savedEmail.first() ?: return@launch
|
||||||
|
val password = authTokenRepository.savedPassword.first() ?: return@launch
|
||||||
|
login(email, password, true)
|
||||||
|
} else {
|
||||||
|
_authState.value = AuthState.NotAuthenticated
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_authState.value = AuthState.NotAuthenticated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вход в систему с помощью email или username
|
||||||
|
*/
|
||||||
|
fun login(identifier: String, password: String, isEmail: Boolean) {
|
||||||
|
_isLoading.value = true
|
||||||
|
_authState.value = AuthState.Authenticating
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
Log.d("AuthViewModel", "Starting login process: $identifier, isEmail=$isEmail")
|
||||||
|
|
||||||
|
when (val result = loginUseCase(identifier, password, isEmail)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
// Получаем данные авторизации из ответа
|
||||||
|
val authData = result.data
|
||||||
|
Log.d("AuthViewModel", "Login Success: received data of type ${authData?.javaClass?.simpleName}")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Используем более безопасный подход без рефлексии
|
||||||
|
if (authData != null) {
|
||||||
|
val dataJson = authData.toString()
|
||||||
|
Log.d("AuthViewModel", "Auth data toString: $dataJson")
|
||||||
|
|
||||||
|
// Устанавливаем состояние авторизации как успешное
|
||||||
|
_authState.value = AuthState.Authenticated
|
||||||
|
|
||||||
|
// Сохраняем учетные данные для автологина
|
||||||
|
authTokenRepository.saveAuthCredentials(identifier, password)
|
||||||
|
|
||||||
|
// Временно используем фиксированный токен (можно заменить на реальный, когда будет понятна структура данных)
|
||||||
|
val tempToken = "temp_token_for_$identifier"
|
||||||
|
authTokenRepository.saveAuthToken(tempToken)
|
||||||
|
|
||||||
|
// Загружаем профиль после успешной авторизации
|
||||||
|
fetchUserProfile()
|
||||||
|
} else {
|
||||||
|
Log.e("AuthViewModel", "Auth data is null")
|
||||||
|
_authState.value = AuthState.AuthError("Получены пустые данные авторизации")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("AuthViewModel", "Error processing login response: ${e.message}", e)
|
||||||
|
_authState.value = AuthState.AuthError("Ошибка обработки ответа: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
Log.e("AuthViewModel", "Login Error: ${result.exception.message}")
|
||||||
|
_authState.value = AuthState.AuthError(result.exception.message ?: "Ошибка авторизации")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("AuthViewModel", "Unhandled exception in login: ${e.message}", e)
|
||||||
|
_authState.value = AuthState.AuthError("Непредвиденная ошибка: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регистрация нового пользователя
|
||||||
|
*/
|
||||||
|
fun register(email: String, username: String, password: String, firstName: String, lastName: String, phone: String) {
|
||||||
|
_isLoading.value = true
|
||||||
|
_authState.value = AuthState.Registering
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = registerUseCase(email, username, password, firstName, lastName, phone)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
_authState.value = AuthState.RegistrationSuccess
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
_authState.value = AuthState.RegistrationError(result.exception.message ?: "Ошибка регистрации")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выход из системы
|
||||||
|
*/
|
||||||
|
fun logout() {
|
||||||
|
_isLoading.value = true
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = logoutUseCase()) {
|
||||||
|
is Result.Success -> {
|
||||||
|
// Очищаем сохраненные данные авторизации
|
||||||
|
authTokenRepository.clearAuthData()
|
||||||
|
_authState.value = AuthState.NotAuthenticated
|
||||||
|
_userProfile.value = null
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
// Даже при ошибке API сессия на устройстве будет завершена
|
||||||
|
authTokenRepository.clearAuthData()
|
||||||
|
_authState.value = AuthState.NotAuthenticated
|
||||||
|
_userProfile.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка профиля пользователя
|
||||||
|
*/
|
||||||
|
fun fetchUserProfile() {
|
||||||
|
_isLoading.value = true
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = getUserProfileUseCase()) {
|
||||||
|
is Result.Success -> {
|
||||||
|
_userProfile.value = result.data
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
// Ошибка может означать, что токен недействителен
|
||||||
|
if (result.exception.message?.contains("авторизован") == true) {
|
||||||
|
// Очищаем недействительный токен
|
||||||
|
authTokenRepository.clearAuthData()
|
||||||
|
_authState.value = AuthState.NotAuthenticated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояния процесса авторизации
|
||||||
|
*/
|
||||||
|
sealed class AuthState {
|
||||||
|
object NotAuthenticated : AuthState()
|
||||||
|
object Authenticating : AuthState()
|
||||||
|
object Authenticated : AuthState()
|
||||||
|
object Registering : AuthState()
|
||||||
|
object RegistrationSuccess : AuthState()
|
||||||
|
data class AuthError(val message: String) : AuthState()
|
||||||
|
data class RegistrationError(val message: String) : AuthState()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.auth
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import kr.smartsoltech.wellshe.R
|
||||||
|
import kr.smartsoltech.wellshe.databinding.FragmentLoginBinding
|
||||||
|
import kr.smartsoltech.wellshe.di.ViewModelFactory
|
||||||
|
import kr.smartsoltech.wellshe.util.isValidEmail
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фрагмент для входа пользователя в систему
|
||||||
|
*/
|
||||||
|
class LoginFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentLoginBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
|
||||||
|
private lateinit var viewModel: AuthViewModel
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentLoginBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
// Получаем ViewModel через DI
|
||||||
|
viewModel = ViewModelProvider(requireActivity(), viewModelFactory)[AuthViewModel::class.java]
|
||||||
|
|
||||||
|
setupListeners()
|
||||||
|
observeViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupListeners() {
|
||||||
|
// Валидация ввода в реальном времени
|
||||||
|
binding.etEmailUsername.addTextChangedListener {
|
||||||
|
validateForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.etPassword.addTextChangedListener {
|
||||||
|
validateForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нажатие на кнопку входа
|
||||||
|
binding.btnLogin.setOnClickListener {
|
||||||
|
val identifier = binding.etEmailUsername.text.toString()
|
||||||
|
val password = binding.etPassword.text.toString()
|
||||||
|
|
||||||
|
// Определяем, это email или username
|
||||||
|
val isEmail = identifier.isValidEmail()
|
||||||
|
|
||||||
|
viewModel.login(identifier, password, isEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переход на экран регистрации
|
||||||
|
binding.btnRegister.setOnClickListener {
|
||||||
|
findNavController().navigate(R.id.action_loginFragment_to_registerFragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeViewModel() {
|
||||||
|
// Наблюдаем за состоянием авторизации
|
||||||
|
viewModel.authState.observe(viewLifecycleOwner) { state ->
|
||||||
|
when (state) {
|
||||||
|
is AuthViewModel.AuthState.Authenticating -> {
|
||||||
|
// Показываем индикатор загрузки
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
binding.btnLogin.isEnabled = false
|
||||||
|
}
|
||||||
|
is AuthViewModel.AuthState.Authenticated -> {
|
||||||
|
// Переходим на главный экран
|
||||||
|
findNavController().navigate(R.id.action_loginFragment_to_mainFragment)
|
||||||
|
}
|
||||||
|
is AuthViewModel.AuthState.AuthError -> {
|
||||||
|
// Показываем ошибку
|
||||||
|
Toast.makeText(requireContext(), state.message, Toast.LENGTH_LONG).show()
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Сбрасываем состояние UI
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Наблюдаем за состоянием загрузки
|
||||||
|
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
|
||||||
|
binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
|
||||||
|
binding.btnLogin.isEnabled = !isLoading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateForm() {
|
||||||
|
val identifier = binding.etEmailUsername.text.toString()
|
||||||
|
val password = binding.etPassword.text.toString()
|
||||||
|
|
||||||
|
binding.btnLogin.isEnabled = identifier.isNotEmpty() && password.length >= 8
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.auth
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import kr.smartsoltech.wellshe.R
|
||||||
|
import kr.smartsoltech.wellshe.databinding.FragmentRegisterBinding
|
||||||
|
import kr.smartsoltech.wellshe.di.ViewModelFactory
|
||||||
|
import kr.smartsoltech.wellshe.util.isValidEmail
|
||||||
|
import kr.smartsoltech.wellshe.util.isValidPassword
|
||||||
|
import kr.smartsoltech.wellshe.util.isValidPhone
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фрагмент для регистрации нового пользователя
|
||||||
|
*/
|
||||||
|
class RegisterFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentRegisterBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
|
||||||
|
private lateinit var viewModel: AuthViewModel
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentRegisterBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
// Получаем ViewModel через DI
|
||||||
|
viewModel = ViewModelProvider(requireActivity(), viewModelFactory)[AuthViewModel::class.java]
|
||||||
|
|
||||||
|
setupListeners()
|
||||||
|
observeViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupListeners() {
|
||||||
|
// Добавляем валидацию в реальном времени для всех полей формы
|
||||||
|
binding.etEmail.addTextChangedListener { validateForm() }
|
||||||
|
binding.etUsername.addTextChangedListener { validateForm() }
|
||||||
|
binding.etPassword.addTextChangedListener { validateForm() }
|
||||||
|
binding.etConfirmPassword.addTextChangedListener { validateForm() }
|
||||||
|
binding.etFirstName.addTextChangedListener { validateForm() }
|
||||||
|
binding.etLastName.addTextChangedListener { validateForm() }
|
||||||
|
binding.etPhone.addTextChangedListener { validateForm() }
|
||||||
|
|
||||||
|
// Обработка нажатия на кнопку регистрации
|
||||||
|
binding.btnRegister.setOnClickListener {
|
||||||
|
if (isFormValid()) {
|
||||||
|
register()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переход на экран входа
|
||||||
|
binding.btnBackToLogin.setOnClickListener {
|
||||||
|
findNavController().popBackStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun register() {
|
||||||
|
val email = binding.etEmail.text?.toString() ?: ""
|
||||||
|
val username = binding.etUsername.text?.toString() ?: ""
|
||||||
|
val password = binding.etPassword.text?.toString() ?: ""
|
||||||
|
val firstName = binding.etFirstName.text?.toString() ?: ""
|
||||||
|
val lastName = binding.etLastName.text?.toString() ?: ""
|
||||||
|
val phone = binding.etPhone.text?.toString() ?: ""
|
||||||
|
|
||||||
|
viewModel.register(email, username, password, firstName, lastName, phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeViewModel() {
|
||||||
|
// Наблюдаем за состоянием регистрации
|
||||||
|
viewModel.authState.observe(viewLifecycleOwner) { state ->
|
||||||
|
when (state) {
|
||||||
|
is AuthViewModel.AuthState.Registering -> {
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
binding.btnRegister.isEnabled = false
|
||||||
|
}
|
||||||
|
is AuthViewModel.AuthState.RegistrationSuccess -> {
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
"Регистрация успешна. Пожалуйста, войдите в систему.",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
findNavController().popBackStack()
|
||||||
|
}
|
||||||
|
is AuthViewModel.AuthState.RegistrationError -> {
|
||||||
|
Toast.makeText(requireContext(), state.message, Toast.LENGTH_LONG).show()
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnRegister.isEnabled = true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnRegister.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Наблюдаем за состоянием загрузки
|
||||||
|
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
|
||||||
|
binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
|
||||||
|
binding.btnRegister.isEnabled = !isLoading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isFormValid(): Boolean {
|
||||||
|
val email = binding.etEmail.text?.toString() ?: ""
|
||||||
|
val username = binding.etUsername.text?.toString() ?: ""
|
||||||
|
val password = binding.etPassword.text?.toString() ?: ""
|
||||||
|
val confirmPassword = binding.etConfirmPassword.text?.toString() ?: ""
|
||||||
|
val firstName = binding.etFirstName.text?.toString() ?: ""
|
||||||
|
val lastName = binding.etLastName.text?.toString() ?: ""
|
||||||
|
val phone = binding.etPhone.text?.toString() ?: ""
|
||||||
|
|
||||||
|
var isValid = true
|
||||||
|
|
||||||
|
// Проверка email
|
||||||
|
if (!email.isValidEmail()) {
|
||||||
|
binding.tilEmail.error = "Введите корректный email"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
binding.tilEmail.error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка имени пользователя
|
||||||
|
if (username.length < 3) {
|
||||||
|
binding.tilUsername.error = "Имя пользователя должно быть не менее 3 символов"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
binding.tilUsername.error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка пароля
|
||||||
|
if (!password.isValidPassword()) {
|
||||||
|
binding.tilPassword.error = "Пароль должен содержать не менее 8 символов, включая цифру, заглавную букву и специальный символ"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
binding.tilPassword.error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка совпадения паролей
|
||||||
|
if (password != confirmPassword) {
|
||||||
|
binding.tilConfirmPassword.error = "Пароли не совпадают"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
binding.tilConfirmPassword.error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка имени и фамилии
|
||||||
|
if (firstName.isEmpty()) {
|
||||||
|
binding.tilFirstName.error = "Введите имя"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
binding.tilFirstName.error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastName.isEmpty()) {
|
||||||
|
binding.tilLastName.error = "Введите фамилию"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
binding.tilLastName.error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка телефона
|
||||||
|
if (!phone.isValidPhone()) {
|
||||||
|
binding.tilPhone.error = "Введите корректный номер телефона (международный формат)"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
binding.tilPhone.error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateForm() {
|
||||||
|
// Простая проверка для активации/деактивации кнопки
|
||||||
|
val allFieldsFilled = binding.etEmail.text?.isNotEmpty() == true &&
|
||||||
|
binding.etUsername.text?.isNotEmpty() == true &&
|
||||||
|
binding.etPassword.text?.isNotEmpty() == true &&
|
||||||
|
binding.etConfirmPassword.text?.isNotEmpty() == true &&
|
||||||
|
binding.etFirstName.text?.isNotEmpty() == true &&
|
||||||
|
binding.etLastName.text?.isNotEmpty() == true &&
|
||||||
|
binding.etPhone.text?.isNotEmpty() == true
|
||||||
|
|
||||||
|
binding.btnRegister.isEnabled = allFieldsFilled
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.auth.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
onNavigateToRegister: () -> Unit,
|
||||||
|
onLoginSuccess: () -> Unit,
|
||||||
|
viewModel: AuthViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val authState by viewModel.authState.observeAsState()
|
||||||
|
val isLoading by viewModel.isLoading.observeAsState()
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
var isFormValid by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// FocusRequester для переключения фокуса между полями
|
||||||
|
val passwordFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(username, password) {
|
||||||
|
isFormValid = username.isNotEmpty() && password.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(authState) {
|
||||||
|
if (authState is AuthViewModel.AuthState.Authenticated) {
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для выполнения входа
|
||||||
|
val performLogin = {
|
||||||
|
if (isFormValid && isLoading != true) {
|
||||||
|
keyboardController?.hide()
|
||||||
|
// Передаем false в качестве параметра isEmail, так как мы используем username
|
||||||
|
viewModel.login(username, password, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "WellShe",
|
||||||
|
style = MaterialTheme.typography.headlineLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text("Имя пользователя") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { passwordFocusRequester.requestFocus() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("Пароль") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(passwordFocusRequester),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
performLogin()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||||
|
contentDescription = if (passwordVisible) "Скрыть пароль" else "Показать пароль"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { performLogin() },
|
||||||
|
enabled = isFormValid && isLoading != true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(50.dp)
|
||||||
|
) {
|
||||||
|
if (isLoading == true) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Войти")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onNavigateToRegister) {
|
||||||
|
Text("Создать новый аккаунт")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState is AuthViewModel.AuthState.AuthError) {
|
||||||
|
Text(
|
||||||
|
text = (authState as AuthViewModel.AuthState.AuthError).message,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.auth.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun RegisterScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onRegisterSuccess: (email: String, password: String) -> Unit,
|
||||||
|
viewModel: AuthViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val authState by viewModel.authState.observeAsState()
|
||||||
|
val isLoading by viewModel.isLoading.observeAsState()
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
var fullName by remember { mutableStateOf("") }
|
||||||
|
var phone by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var isFormValid by remember { mutableStateOf(false) }
|
||||||
|
var passwordsMatch by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
// FocusRequesters для полей формы
|
||||||
|
val usernameFocusRequester = remember { FocusRequester() }
|
||||||
|
val passwordFocusRequester = remember { FocusRequester() }
|
||||||
|
val confirmPasswordFocusRequester = remember { FocusRequester() }
|
||||||
|
val fullNameFocusRequester = remember { FocusRequester() }
|
||||||
|
val phoneFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
// Валидация формы
|
||||||
|
LaunchedEffect(email, username, password, confirmPassword, fullName, phone) {
|
||||||
|
passwordsMatch = password == confirmPassword
|
||||||
|
isFormValid = email.isNotBlank() && username.isNotBlank() &&
|
||||||
|
password.isNotBlank() && confirmPassword.isNotBlank() &&
|
||||||
|
fullName.isNotBlank() && phone.isNotBlank() &&
|
||||||
|
passwordsMatch &&
|
||||||
|
password.length >= 8 // Минимальная длина пароля
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка состояния авторизации
|
||||||
|
LaunchedEffect(authState) {
|
||||||
|
if (authState is AuthViewModel.AuthState.RegistrationSuccess) {
|
||||||
|
// При успешной регистрации сразу выполняем вход с теми же данными
|
||||||
|
onRegisterSuccess(email, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для выполнения регистрации
|
||||||
|
val performRegister = {
|
||||||
|
if (isFormValid && isLoading != true) {
|
||||||
|
keyboardController?.hide()
|
||||||
|
// Разделяем полное имя на имя и фамилию для сервера
|
||||||
|
val nameParts = fullName.trim().split(" ", limit = 2)
|
||||||
|
val firstName = nameParts[0]
|
||||||
|
val lastName = if (nameParts.size > 1) nameParts[1] else ""
|
||||||
|
|
||||||
|
viewModel.register(email, username, password, firstName, lastName, phone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Регистрация",
|
||||||
|
style = MaterialTheme.typography.headlineLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле Email
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = { Text("Email") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { usernameFocusRequester.requestFocus() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле Username
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text("Имя пользователя") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(usernameFocusRequester),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { passwordFocusRequester.requestFocus() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле Password
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("Пароль") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(passwordFocusRequester),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { confirmPasswordFocusRequester.requestFocus() }
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||||
|
contentDescription = if (passwordVisible) "Скрыть пароль" else "Показать пароль"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isError = password.isNotEmpty() && password.length < 8
|
||||||
|
)
|
||||||
|
|
||||||
|
if (password.isNotEmpty() && password.length < 8) {
|
||||||
|
Text(
|
||||||
|
text = "Пароль должен содержать минимум 8 символов",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.align(Alignment.Start)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поле для подтверждения пароля
|
||||||
|
OutlinedTextField(
|
||||||
|
value = confirmPassword,
|
||||||
|
onValueChange = { confirmPassword = it },
|
||||||
|
label = { Text("Подтвердите пароль") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(confirmPasswordFocusRequester),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { fullNameFocusRequester.requestFocus() }
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (confirmPasswordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||||
|
contentDescription = if (confirmPasswordVisible) "Скрыть пароль" else "Показать пароль"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isError = confirmPassword.isNotEmpty() && !passwordsMatch
|
||||||
|
)
|
||||||
|
|
||||||
|
if (confirmPassword.isNotEmpty() && !passwordsMatch) {
|
||||||
|
Text(
|
||||||
|
text = "Пароли не совпадают",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.align(Alignment.Start)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поле для полного имени
|
||||||
|
OutlinedTextField(
|
||||||
|
value = fullName,
|
||||||
|
onValueChange = { fullName = it },
|
||||||
|
label = { Text("Полное имя") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(fullNameFocusRequester),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { phoneFocusRequester.requestFocus() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Поле для телефона
|
||||||
|
OutlinedTextField(
|
||||||
|
value = phone,
|
||||||
|
onValueChange = { phone = it },
|
||||||
|
label = { Text("Номер телефона") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(phoneFocusRequester),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Phone,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
performRegister()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Кнопка регистрации
|
||||||
|
Button(
|
||||||
|
onClick = { performRegister() },
|
||||||
|
enabled = isFormValid && isLoading != true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(50.dp)
|
||||||
|
) {
|
||||||
|
if (isLoading == true) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Зарегистрироваться")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка возврата к экрану входа
|
||||||
|
TextButton(onClick = onNavigateBack) {
|
||||||
|
Text("Уже есть аккаунт? Войти")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение ошибки
|
||||||
|
if (authState is AuthViewModel.AuthState.RegistrationError) {
|
||||||
|
Text(
|
||||||
|
text = (authState as AuthViewModel.AuthState.RegistrationError).message,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PermissionRequestDialog(
|
||||||
|
permissionText: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Требуется разрешение") },
|
||||||
|
text = { Text(permissionText) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text("Настройки")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,7 +31,8 @@ import java.util.Locale
|
|||||||
fun CycleScreen(
|
fun CycleScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: CycleViewModel = hiltViewModel(),
|
viewModel: CycleViewModel = hiltViewModel(),
|
||||||
onNavigateToSettings: () -> Unit = {} // Добавляем параметр для навигации к настройкам
|
onNavigateToSettings: () -> Unit = {}, // Параметр для навигации к настройкам
|
||||||
|
onNavigateToEmergency: () -> Unit = {} // Добавляем параметр для навигации к экрану экстренной помощи
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|||||||
@@ -83,13 +83,6 @@ fun DashboardScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
|
||||||
SleepCard(
|
|
||||||
sleepData = uiState.sleepData,
|
|
||||||
onClick = { onNavigate("sleep") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
item {
|
||||||
RecentWorkoutsCard(
|
RecentWorkoutsCard(
|
||||||
workouts = uiState.recentWorkouts,
|
workouts = uiState.recentWorkouts,
|
||||||
@@ -404,26 +397,26 @@ private fun HealthOverviewCard(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
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(
|
HealthMetric(
|
||||||
label = "Энергия",
|
label = "Энергия",
|
||||||
value = "${healthData.energyLevel}",
|
value = "${healthData.energyLevel}",
|
||||||
unit = "/10",
|
unit = "/10",
|
||||||
icon = Icons.Default.Battery6Bar
|
icon = Icons.Default.Battery6Bar
|
||||||
)
|
)
|
||||||
|
|
||||||
|
HealthMetric(
|
||||||
|
label = "Симптомы",
|
||||||
|
value = "${healthData.symptoms.size}",
|
||||||
|
unit = "",
|
||||||
|
icon = Icons.Default.HealthAndSafety
|
||||||
|
)
|
||||||
|
|
||||||
|
HealthMetric(
|
||||||
|
label = "Заметки",
|
||||||
|
value = if (healthData.notes.isNotEmpty()) "✓" else "—",
|
||||||
|
unit = "",
|
||||||
|
icon = Icons.Default.Notes
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,63 +472,6 @@ private fun HealthMetric(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
@Composable
|
||||||
private fun RecentWorkoutsCard(
|
private fun RecentWorkoutsCard(
|
||||||
workouts: List<WorkoutData>,
|
workouts: List<WorkoutData>,
|
||||||
@@ -605,7 +541,7 @@ private fun WorkoutItem(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = getWorkoutIcon(workout.type),
|
imageVector = Icons.Default.FitnessCenter,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = PrimaryPink,
|
tint = PrimaryPink,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
@@ -617,7 +553,7 @@ private fun WorkoutItem(
|
|||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = getWorkoutTypeText(workout.type),
|
text = workout.name,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = TextPrimary
|
color = TextPrimary
|
||||||
@@ -662,12 +598,12 @@ private val quickActions = listOf(
|
|||||||
textColor = SecondaryBlue
|
textColor = SecondaryBlue
|
||||||
),
|
),
|
||||||
QuickAction(
|
QuickAction(
|
||||||
title = "Отметить сон",
|
title = "Экстренная помощь",
|
||||||
icon = Icons.Default.Bedtime,
|
icon = Icons.Default.Emergency,
|
||||||
route = "sleep",
|
route = "emergency",
|
||||||
backgroundColor = AccentPurpleLight,
|
backgroundColor = ErrorRedLight,
|
||||||
iconColor = AccentPurple,
|
iconColor = ErrorRed,
|
||||||
textColor = AccentPurple
|
textColor = ErrorRed
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,19 +8,14 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.launch
|
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.data.repository.WellSheRepository
|
||||||
import kr.smartsoltech.wellshe.domain.model.*
|
import kr.smartsoltech.wellshe.domain.model.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
data class DashboardUiState(
|
data class DashboardUiState(
|
||||||
val user: User = User(),
|
val user: User = User(),
|
||||||
val todayHealth: HealthData = HealthData(),
|
val todayHealth: HealthData = HealthData(),
|
||||||
val sleepData: SleepData = SleepData(),
|
|
||||||
val cycleData: CycleData = CycleData(),
|
val cycleData: CycleData = CycleData(),
|
||||||
val recentWorkouts: List<WorkoutData> = emptyList(),
|
val recentWorkouts: List<WorkoutData> = emptyList(),
|
||||||
val todaySteps: Int = 0,
|
val todaySteps: Int = 0,
|
||||||
@@ -53,37 +48,13 @@ class DashboardViewModel @Inject constructor(
|
|||||||
_uiState.value = _uiState.value.copy(user = user)
|
_uiState.value = _uiState.value.copy(user = user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем данные о здоровье
|
// TODO: Временно используем заглушки для данных о здоровье
|
||||||
repository.getTodayHealthData().catch {
|
val healthData = HealthData()
|
||||||
// Игнорируем ошибки, используем дефолтные данные
|
|
||||||
}.collect { healthEntity: HealthRecordEntity? ->
|
|
||||||
val healthData = healthEntity?.let { convertHealthEntityToModel(it) } ?: HealthData()
|
|
||||||
_uiState.value = _uiState.value.copy(todayHealth = healthData)
|
_uiState.value = _uiState.value.copy(todayHealth = healthData)
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем данные о сне
|
// TODO: Временно используем заглушки для данных о цикле
|
||||||
loadSleepData()
|
val cycleData = CycleData()
|
||||||
|
|
||||||
// Загружаем данные о цикле
|
|
||||||
repository.getRecentPeriods().let { periods ->
|
|
||||||
val cycleEntity = periods.firstOrNull()
|
|
||||||
val cycleData = cycleEntity?.let { convertCycleEntityToModel(it) } ?: CycleData()
|
|
||||||
_uiState.value = _uiState.value.copy(cycleData = cycleData)
|
_uiState.value = _uiState.value.copy(cycleData = cycleData)
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем тренировки
|
|
||||||
repository.getRecentWorkouts().catch {
|
|
||||||
// Игнорируем ошибки
|
|
||||||
}.collect { workoutEntities: List<WorkoutSession> ->
|
|
||||||
val workouts = workoutEntities.map { convertWorkoutEntityToModel(it) }
|
|
||||||
_uiState.value = _uiState.value.copy(recentWorkouts = workouts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем шаги за сегодня
|
|
||||||
loadTodayFitnessData()
|
|
||||||
|
|
||||||
// Загружаем воду за сегодня
|
|
||||||
loadTodayWaterData()
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
|
||||||
@@ -96,136 +67,28 @@ class DashboardViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (_: Exception) {
|
|
||||||
// Игнорируем ошибки загрузки сна
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadTodayFitnessData() {
|
|
||||||
try {
|
|
||||||
val today = LocalDate.now()
|
|
||||||
repository.getFitnessDataForDate(today).catch {
|
|
||||||
// Игнорируем ошибки
|
|
||||||
}.collect { fitnessData: FitnessData ->
|
|
||||||
_uiState.value = _uiState.value.copy(todaySteps = fitnessData.steps)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Игнорируем ошибки загрузки фитнеса
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadTodayWaterData() {
|
|
||||||
try {
|
|
||||||
val today = LocalDate.now()
|
|
||||||
repository.getWaterIntakeForDate(today).catch {
|
|
||||||
// Игнорируем ошибки
|
|
||||||
}.collect { waterIntakes: List<WaterIntake> ->
|
|
||||||
val totalAmount = waterIntakes.sumOf { it.amount.toDouble() }.toFloat()
|
|
||||||
_uiState.value = _uiState.value.copy(todayWater = totalAmount)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Игнорируем ошибки загрузки воды
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
_uiState.value = _uiState.value.copy(error = null)
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Функции преобразования Entity -> Model
|
// Упрощенные модели данных для Dashboard
|
||||||
private fun convertHealthEntityToModel(entity: HealthRecordEntity): HealthData {
|
data class HealthData(
|
||||||
return HealthData(
|
val energyLevel: Int = 5,
|
||||||
id = entity.id.toString(),
|
val symptoms: List<String> = emptyList(),
|
||||||
userId = "current_user",
|
val notes: String = ""
|
||||||
date = entity.date,
|
|
||||||
weight = entity.weight ?: 0f,
|
|
||||||
heartRate = entity.heartRate ?: 70,
|
|
||||||
bloodPressureSystolic = entity.bloodPressureS ?: 120,
|
|
||||||
bloodPressureDiastolic = entity.bloodPressureD ?: 80,
|
|
||||||
mood = convertMoodStringToEnum(entity.mood ?: "neutral"),
|
|
||||||
energyLevel = entity.energyLevel ?: 5,
|
|
||||||
stressLevel = entity.stressLevel ?: 5,
|
|
||||||
symptoms = entity.symptoms ?: emptyList()
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
private fun convertSleepEntityToModel(entity: SleepLogEntity): SleepData {
|
data class CycleData(
|
||||||
return SleepData(
|
val currentDay: Int = 1,
|
||||||
id = entity.id.toString(),
|
val nextPeriodDate: LocalDate? = null,
|
||||||
userId = "current_user",
|
val cycleLength: Int = 28
|
||||||
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 {
|
data class WorkoutData(
|
||||||
return CycleData(
|
val id: Long = 0,
|
||||||
id = entity.id.toString(),
|
val name: String = "",
|
||||||
userId = "current_user",
|
val duration: Int = 0,
|
||||||
cycleLength = entity.cycleLength ?: 28,
|
val caloriesBurned: Int = 0,
|
||||||
periodLength = entity.endDate?.let {
|
val date: LocalDate = LocalDate.now()
|
||||||
ChronoUnit.DAYS.between(entity.startDate, it).toInt() + 1
|
|
||||||
} ?: 5,
|
|
||||||
lastPeriodDate = entity.startDate,
|
|
||||||
nextPeriodDate = entity.startDate.plusDays((entity.cycleLength ?: 28).toLong()),
|
|
||||||
ovulationDate = entity.startDate.plusDays(((entity.cycleLength ?: 28) / 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.emergency
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationListener
|
||||||
|
import android.location.LocationManager
|
||||||
|
import android.os.BatteryManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import kr.smartsoltech.wellshe.R
|
||||||
|
import kr.smartsoltech.wellshe.databinding.FragmentEmergencyBinding
|
||||||
|
import kr.smartsoltech.wellshe.di.ViewModelFactory
|
||||||
|
import kr.smartsoltech.wellshe.ui.emergency.EmergencyViewModel.EmergencyState
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фрагмент для экрана экстренных оповещений
|
||||||
|
*/
|
||||||
|
class EmergencyFragment : Fragment(), LocationListener {
|
||||||
|
|
||||||
|
private var _binding: FragmentEmergencyBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
|
||||||
|
private lateinit var viewModel: EmergencyViewModel
|
||||||
|
|
||||||
|
private lateinit var locationManager: LocationManager
|
||||||
|
private var currentLocation: Location? = null
|
||||||
|
private var isLocationUpdatesActive = false
|
||||||
|
|
||||||
|
// Интервал обновления местоположения (мс)
|
||||||
|
private val normalUpdateInterval = 60000L // 1 минута
|
||||||
|
private val emergencyUpdateInterval = 15000L // 15 секунд
|
||||||
|
|
||||||
|
// Коды для запросов разрешений
|
||||||
|
private val locationPermissionRequestCode = 100
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentEmergencyBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
// Получаем ViewModel через DI
|
||||||
|
viewModel = ViewModelProvider(this, viewModelFactory)[EmergencyViewModel::class.java]
|
||||||
|
|
||||||
|
// Инициализируем менеджер местоположения
|
||||||
|
locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
|
|
||||||
|
setupEmergencyButton()
|
||||||
|
observeViewModel()
|
||||||
|
checkLocationPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupEmergencyButton() {
|
||||||
|
// Настраиваем кнопку SOS
|
||||||
|
binding.buttonSos.setOnClickListener {
|
||||||
|
// Показываем диалог подтверждения
|
||||||
|
showEmergencyConfirmationDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настраиваем кнопку отмены оповещения
|
||||||
|
binding.buttonCancelAlert.setOnClickListener {
|
||||||
|
showCancelConfirmationDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showEmergencyConfirmationDialog() {
|
||||||
|
AlertDialog.Builder(requireContext())
|
||||||
|
.setTitle("Подтверждение")
|
||||||
|
.setMessage("Вы уверены, что хотите отправить экстренное оповещение?")
|
||||||
|
.setPositiveButton("Отправить") { _, _ ->
|
||||||
|
createEmergencyAlert()
|
||||||
|
}
|
||||||
|
.setNegativeButton("Отмена", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showCancelConfirmationDialog() {
|
||||||
|
AlertDialog.Builder(requireContext())
|
||||||
|
.setTitle("Отмена оповещения")
|
||||||
|
.setMessage("Вы уверены, что хотите отменить экстренное оповещение?")
|
||||||
|
.setPositiveButton("Да") { _, _ ->
|
||||||
|
viewModel.cancelAlert("Отменено пользователем")
|
||||||
|
}
|
||||||
|
.setNegativeButton("Нет", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createEmergencyAlert() {
|
||||||
|
// Проверяем доступность местоположения
|
||||||
|
val location = currentLocation
|
||||||
|
if (location != null) {
|
||||||
|
// Получаем уровень заряда батареи
|
||||||
|
val batteryLevel = getBatteryLevel()
|
||||||
|
|
||||||
|
// Создаем экстренное оповещение
|
||||||
|
viewModel.createEmergencyAlert(
|
||||||
|
latitude = location.latitude,
|
||||||
|
longitude = location.longitude,
|
||||||
|
batteryLevel = batteryLevel
|
||||||
|
)
|
||||||
|
|
||||||
|
// Вибрируем, чтобы дать обратную связь пользователю
|
||||||
|
vibratePhone()
|
||||||
|
|
||||||
|
// Активируем более частое обновление местоположения
|
||||||
|
startLocationUpdates(true)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(), "Не удалось определить местоположение", Toast.LENGTH_LONG).show()
|
||||||
|
// Пробуем запросить местоположение принудительно
|
||||||
|
requestLocationUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeViewModel() {
|
||||||
|
viewModel.emergencyState.observe(viewLifecycleOwner) { state ->
|
||||||
|
when (state) {
|
||||||
|
is EmergencyState.Inactive -> {
|
||||||
|
binding.emergencyStatusGroup.visibility = View.GONE
|
||||||
|
binding.buttonSos.visibility = View.VISIBLE
|
||||||
|
binding.buttonCancelAlert.visibility = View.GONE
|
||||||
|
// При неактивном состоянии переключаемся на нормальную частоту обновлений
|
||||||
|
startLocationUpdates(false)
|
||||||
|
}
|
||||||
|
is EmergencyState.Creating -> {
|
||||||
|
binding.emergencyStatusGroup.visibility = View.VISIBLE
|
||||||
|
binding.buttonSos.visibility = View.GONE
|
||||||
|
binding.buttonCancelAlert.visibility = View.GONE
|
||||||
|
binding.statusText.text = "Создание оповещения..."
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
is EmergencyState.Active -> {
|
||||||
|
binding.emergencyStatusGroup.visibility = View.VISIBLE
|
||||||
|
binding.buttonSos.visibility = View.GONE
|
||||||
|
binding.buttonCancelAlert.visibility = View.VISIBLE
|
||||||
|
binding.statusText.text = "Оповещение активно\nID: ${state.alertResponse.alertId}"
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.statusIndicator.setImageResource(R.drawable.ic_status_active)
|
||||||
|
}
|
||||||
|
is EmergencyState.Cancelled -> {
|
||||||
|
binding.emergencyStatusGroup.visibility = View.VISIBLE
|
||||||
|
binding.buttonSos.visibility = View.VISIBLE
|
||||||
|
binding.buttonCancelAlert.visibility = View.GONE
|
||||||
|
binding.statusText.text = "Оповещение отменено"
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.statusIndicator.setImageResource(R.drawable.ic_status_cancelled)
|
||||||
|
|
||||||
|
// После короткой задержки скрываем статус и возвращаемся к нормальному состоянию
|
||||||
|
binding.root.postDelayed({
|
||||||
|
viewModel.resetState()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
is EmergencyState.Resolved -> {
|
||||||
|
binding.emergencyStatusGroup.visibility = View.VISIBLE
|
||||||
|
binding.buttonSos.visibility = View.VISIBLE
|
||||||
|
binding.buttonCancelAlert.visibility = View.GONE
|
||||||
|
binding.statusText.text = "Оповещение обработано службами"
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.statusIndicator.setImageResource(R.drawable.ic_status_resolved)
|
||||||
|
|
||||||
|
// После короткой задержки скрываем статус
|
||||||
|
binding.root.postDelayed({
|
||||||
|
viewModel.resetState()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
is EmergencyState.Error -> {
|
||||||
|
binding.emergencyStatusGroup.visibility = View.VISIBLE
|
||||||
|
binding.buttonSos.visibility = View.VISIBLE
|
||||||
|
binding.buttonCancelAlert.visibility = View.GONE
|
||||||
|
binding.statusText.text = "Ошибка: ${state.message}"
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.statusIndicator.setImageResource(R.drawable.ic_status_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.alertStatus.observe(viewLifecycleOwner) { status ->
|
||||||
|
status?.let {
|
||||||
|
// Обновляем информацию о статусе оповещения
|
||||||
|
binding.notifiedContactsCount.text = "${it.notifiedContacts.count { contact -> contact.status == "notified" }}/${it.notifiedContacts.size}"
|
||||||
|
|
||||||
|
val emergencyServicesText = if (it.emergencyServicesNotified) {
|
||||||
|
"Службы экстренной помощи уведомлены"
|
||||||
|
} else {
|
||||||
|
"Ожидаем ответа служб"
|
||||||
|
}
|
||||||
|
binding.emergencyServicesStatus.text = emergencyServicesText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBatteryLevel(): Int {
|
||||||
|
val batteryManager = requireContext().getSystemService(Context.BATTERY_SERVICE) as BatteryManager
|
||||||
|
return batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun vibratePhone() {
|
||||||
|
val vibrator = requireContext().getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||||
|
vibrator.vibrate(longArrayOf(0, 500, 200, 500), -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkLocationPermissions() {
|
||||||
|
if (ActivityCompat.checkSelfPermission(
|
||||||
|
requireContext(),
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
requestLocationPermissions()
|
||||||
|
} else {
|
||||||
|
startLocationUpdates(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestLocationPermissions() {
|
||||||
|
requestPermissions(
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
),
|
||||||
|
locationPermissionRequestCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLocationUpdates(isEmergency: Boolean) {
|
||||||
|
if (ActivityCompat.checkSelfPermission(
|
||||||
|
requireContext(),
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Останавливаем предыдущие обновления, если они были
|
||||||
|
if (isLocationUpdatesActive) {
|
||||||
|
locationManager.removeUpdates(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем интервал обновления в зависимости от режима
|
||||||
|
val updateInterval = if (isEmergency) emergencyUpdateInterval else normalUpdateInterval
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Запрашиваем обновления местоположения
|
||||||
|
locationManager.requestLocationUpdates(
|
||||||
|
LocationManager.GPS_PROVIDER,
|
||||||
|
updateInterval,
|
||||||
|
10f, // минимальное расстояние для обновления (метры)
|
||||||
|
this
|
||||||
|
)
|
||||||
|
isLocationUpdatesActive = true
|
||||||
|
|
||||||
|
// Сразу запрашиваем последнее известное местоположение
|
||||||
|
requestLocationUpdate()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(requireContext(), "Ошибка получения местоположения", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestLocationUpdate() {
|
||||||
|
if (ActivityCompat.checkSelfPermission(
|
||||||
|
requireContext(),
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пытаемся получить последнее известное местоположение
|
||||||
|
val lastLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
|
||||||
|
if (lastLocation != null) {
|
||||||
|
currentLocation = lastLocation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLocationChanged(location: Location) {
|
||||||
|
currentLocation = location
|
||||||
|
|
||||||
|
// Если оповещение активно, отправляем обновленные координаты
|
||||||
|
if (viewModel.emergencyState.value is EmergencyState.Active) {
|
||||||
|
viewModel.updateLocation(
|
||||||
|
latitude = location.latitude,
|
||||||
|
longitude = location.longitude,
|
||||||
|
accuracy = location.accuracy,
|
||||||
|
batteryLevel = getBatteryLevel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<out String>,
|
||||||
|
grantResults: IntArray
|
||||||
|
) {
|
||||||
|
if (requestCode == locationPermissionRequestCode) {
|
||||||
|
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
startLocationUpdates(false)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
"Для работы экстренных оповещений необходим доступ к местоположению",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
// Если нет активного оповещения, останавливаем обновления местоположения
|
||||||
|
if (viewModel.emergencyState.value !is EmergencyState.Active && isLocationUpdatesActive) {
|
||||||
|
locationManager.removeUpdates(this)
|
||||||
|
isLocationUpdatesActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// Если есть активное оповещение, возобновляем обновления
|
||||||
|
val isEmergency = viewModel.emergencyState.value is EmergencyState.Active
|
||||||
|
if (isEmergency || !isLocationUpdatesActive) {
|
||||||
|
startLocationUpdates(isEmergency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
if (isLocationUpdatesActive) {
|
||||||
|
locationManager.removeUpdates(this)
|
||||||
|
isLocationUpdatesActive = false
|
||||||
|
}
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.emergency
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
|
import androidx.compose.material.icons.filled.LocationOn
|
||||||
|
import androidx.compose.material.icons.filled.Send
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EmergencyScreen(
|
||||||
|
onNavigateBack: () -> Unit
|
||||||
|
) {
|
||||||
|
var sendingSos by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Экстренная помощь") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onNavigateBack() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowBack,
|
||||||
|
contentDescription = "Назад"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "ЭКСТРЕННАЯ СИТУАЦИЯ",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Используйте эту функцию только в случае реальной опасности",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка SOS
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
sendingSos = true
|
||||||
|
// Здесь должна быть логика отправки сигнала SOS
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(80.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||||
|
enabled = !sendingSos
|
||||||
|
) {
|
||||||
|
if (sendingSos) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Отправка сигнала SOS...")
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Send,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "ОТПРАВИТЬ СИГНАЛ SOS",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экстренные контакты
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Экстренные контакты",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
EmergencyContactItem(
|
||||||
|
title = "Полиция",
|
||||||
|
phoneNumber = "102"
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
EmergencyContactItem(
|
||||||
|
title = "Скорая помощь",
|
||||||
|
phoneNumber = "103"
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
EmergencyContactItem(
|
||||||
|
title = "Единый номер экстренных служб",
|
||||||
|
phoneNumber = "112"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текущее местоположение
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.LocationOn,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Ваше местоположение",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Определяется...",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmergencyContactItem(
|
||||||
|
title: String,
|
||||||
|
phoneNumber: String
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = phoneNumber,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = { /* Действие звонка */ }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Call,
|
||||||
|
contentDescription = "Позвонить",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.emergency
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kr.smartsoltech.wellshe.domain.emergency.*
|
||||||
|
import kr.smartsoltech.wellshe.model.emergency.*
|
||||||
|
import kr.smartsoltech.wellshe.util.Result
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel для управления экстренными оповещениями
|
||||||
|
*/
|
||||||
|
class EmergencyViewModel(
|
||||||
|
private val createEmergencyAlertUseCase: CreateEmergencyAlertUseCase,
|
||||||
|
private val getAlertStatusUseCase: GetAlertStatusUseCase,
|
||||||
|
private val updateLocationUseCase: UpdateLocationUseCase,
|
||||||
|
private val cancelAlertUseCase: CancelAlertUseCase
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _emergencyState = MutableLiveData<EmergencyState>(EmergencyState.Inactive)
|
||||||
|
val emergencyState: LiveData<EmergencyState> = _emergencyState
|
||||||
|
|
||||||
|
private val _alertStatus = MutableLiveData<EmergencyAlertStatus?>()
|
||||||
|
val alertStatus: LiveData<EmergencyAlertStatus?> = _alertStatus
|
||||||
|
|
||||||
|
private val _isLoading = MutableLiveData(false)
|
||||||
|
val isLoading: LiveData<Boolean> = _isLoading
|
||||||
|
|
||||||
|
private var currentAlertId: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создание нового экстренного оповещения
|
||||||
|
*/
|
||||||
|
fun createEmergencyAlert(
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
message: String? = null,
|
||||||
|
batteryLevel: Int? = null,
|
||||||
|
contactIds: List<String>? = null
|
||||||
|
) {
|
||||||
|
_isLoading.value = true
|
||||||
|
_emergencyState.value = EmergencyState.Creating
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = createEmergencyAlertUseCase(latitude, longitude, message, batteryLevel, contactIds)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
currentAlertId = result.data.alertId
|
||||||
|
_emergencyState.value = EmergencyState.Active(result.data)
|
||||||
|
// После создания оповещения сразу получаем его статус
|
||||||
|
fetchAlertStatus(result.data.alertId)
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
_emergencyState.value = EmergencyState.Error(result.exception.message ?: "Ошибка создания оповещения")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение статуса экстренного оповещения
|
||||||
|
*/
|
||||||
|
fun fetchAlertStatus(alertId: String) {
|
||||||
|
_isLoading.value = true
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = getAlertStatusUseCase(alertId)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
_alertStatus.value = result.data
|
||||||
|
|
||||||
|
// Обновляем состояние на основе статуса оповещения
|
||||||
|
_emergencyState.value = when (result.data.status.lowercase()) {
|
||||||
|
"active" -> EmergencyState.Active(EmergencyAlertResponse(
|
||||||
|
alertId = result.data.alertId,
|
||||||
|
createdAt = result.data.createdAt,
|
||||||
|
status = result.data.status,
|
||||||
|
message = "Оповещение активно"
|
||||||
|
))
|
||||||
|
"cancelled" -> EmergencyState.Cancelled(result.data.alertId)
|
||||||
|
"resolved" -> EmergencyState.Resolved(result.data.alertId)
|
||||||
|
else -> EmergencyState.Active(EmergencyAlertResponse(
|
||||||
|
alertId = result.data.alertId,
|
||||||
|
createdAt = result.data.createdAt,
|
||||||
|
status = result.data.status,
|
||||||
|
message = "Статус: ${result.data.status}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
_emergencyState.value = EmergencyState.Error(result.exception.message ?: "Ошибка получения статуса")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление местоположения для активного оповещения
|
||||||
|
*/
|
||||||
|
fun updateLocation(
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
accuracy: Float? = null,
|
||||||
|
batteryLevel: Int? = null
|
||||||
|
) {
|
||||||
|
val alertId = currentAlertId ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = updateLocationUseCase(alertId, latitude, longitude, accuracy, batteryLevel)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
// После успешного обновления местоположения можно запросить текущий статус
|
||||||
|
fetchAlertStatus(alertId)
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
// Обработка ошибок обновления местоположения
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отмена экстренного оповещения
|
||||||
|
*/
|
||||||
|
fun cancelAlert(reason: String? = null, details: String? = null) {
|
||||||
|
val alertId = currentAlertId ?: return
|
||||||
|
_isLoading.value = true
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = cancelAlertUseCase(alertId, reason, details)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
_emergencyState.value = EmergencyState.Cancelled(alertId)
|
||||||
|
// После отмены оповещения очищаем текущий ID оповещения
|
||||||
|
currentAlertId = null
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
_emergencyState.value = EmergencyState.Error(result.exception.message ?: "Ошибка отмены оповещения")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сброс состояния (например, после завершения сценария экстренного оповещения)
|
||||||
|
*/
|
||||||
|
fun resetState() {
|
||||||
|
currentAlertId = null
|
||||||
|
_emergencyState.value = EmergencyState.Inactive
|
||||||
|
_alertStatus.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояния экстренного оповещения
|
||||||
|
*/
|
||||||
|
sealed class EmergencyState {
|
||||||
|
object Inactive : EmergencyState()
|
||||||
|
object Creating : EmergencyState()
|
||||||
|
data class Active(val alertResponse: EmergencyAlertResponse) : EmergencyState()
|
||||||
|
data class Cancelled(val alertId: String) : EmergencyState()
|
||||||
|
data class Resolved(val alertId: String) : EmergencyState()
|
||||||
|
data class Error(val message: String) : EmergencyState()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,9 +93,7 @@ fun HealthOverviewScreen(
|
|||||||
TodayHealthCard(
|
TodayHealthCard(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onUpdateVitals = viewModel::updateVitals,
|
onUpdateVitals = viewModel::updateVitals,
|
||||||
onUpdateMood = viewModel::updateMood,
|
onUpdateEnergy = viewModel::updateEnergyLevel
|
||||||
onUpdateEnergy = viewModel::updateEnergyLevel,
|
|
||||||
onUpdateStress = viewModel::updateStressLevel
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +131,7 @@ fun HealthOverviewScreen(
|
|||||||
private fun TodayHealthCard(
|
private fun TodayHealthCard(
|
||||||
uiState: HealthUiState,
|
uiState: HealthUiState,
|
||||||
onUpdateVitals: (Float?, Int?, Int?, Int?, Float?) -> Unit,
|
onUpdateVitals: (Float?, Int?, Int?, Int?, Float?) -> Unit,
|
||||||
onUpdateMood: (String) -> Unit,
|
|
||||||
onUpdateEnergy: (Int) -> Unit,
|
onUpdateEnergy: (Int) -> Unit,
|
||||||
onUpdateStress: (Int) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var weight by remember { mutableStateOf(uiState.todayRecord?.weight?.toString() ?: "") }
|
var weight by remember { mutableStateOf(uiState.todayRecord?.weight?.toString() ?: "") }
|
||||||
@@ -269,16 +265,7 @@ private fun TodayHealthCard(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Настроение
|
// Уровень энергии
|
||||||
MoodSection(
|
|
||||||
currentMood = uiState.todayRecord?.mood ?: "neutral",
|
|
||||||
onMoodChange = onUpdateMood,
|
|
||||||
isEditMode = uiState.isEditMode
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Уровень энергии и стресса
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
@@ -288,18 +275,9 @@ private fun TodayHealthCard(
|
|||||||
value = uiState.todayRecord?.energyLevel ?: 5,
|
value = uiState.todayRecord?.energyLevel ?: 5,
|
||||||
onValueChange = onUpdateEnergy,
|
onValueChange = onUpdateEnergy,
|
||||||
isEditMode = uiState.isEditMode,
|
isEditMode = uiState.isEditMode,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = WarningOrange
|
color = WarningOrange
|
||||||
)
|
)
|
||||||
|
|
||||||
LevelSlider(
|
|
||||||
label = "Стресс",
|
|
||||||
value = uiState.todayRecord?.stressLevel ?: 5,
|
|
||||||
onValueChange = onUpdateStress,
|
|
||||||
isEditMode = uiState.isEditMode,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
color = ErrorRed
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,68 +330,6 @@ private fun VitalMetric(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
@Composable
|
||||||
private fun LevelSlider(
|
private fun LevelSlider(
|
||||||
label: String,
|
label: String,
|
||||||
@@ -679,18 +595,8 @@ private fun NotesCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Данные для UI
|
// Данные для 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(
|
private val healthSymptoms = listOf(
|
||||||
"Головная боль", "Усталость", "Тошнота", "Головокружение",
|
"Головная боль", "Усталость", "Тошнота", "Головокружение",
|
||||||
"Боль в спине", "Боль в суставах", "Бессонница", "Стресс",
|
"Боль в спине", "Боль в суставах", "Бессонница",
|
||||||
"Простуда", "Аллергия", "Боль в животе", "Другое"
|
"Простуда", "Аллергия", "Боль в животе", "Другое"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -248,9 +248,7 @@ private fun VitalSignsCard(
|
|||||||
bloodPressureS = 0,
|
bloodPressureS = 0,
|
||||||
bloodPressureD = 0,
|
bloodPressureD = 0,
|
||||||
temperature = 36.6f,
|
temperature = 36.6f,
|
||||||
mood = "",
|
|
||||||
energyLevel = 5,
|
energyLevel = 5,
|
||||||
stressLevel = 5,
|
|
||||||
symptoms = emptyList(),
|
symptoms = emptyList(),
|
||||||
notes = ""
|
notes = ""
|
||||||
)
|
)
|
||||||
@@ -275,9 +273,7 @@ private fun VitalSignsCard(
|
|||||||
bloodPressureS = 0,
|
bloodPressureS = 0,
|
||||||
bloodPressureD = 0,
|
bloodPressureD = 0,
|
||||||
temperature = 36.6f,
|
temperature = 36.6f,
|
||||||
mood = "",
|
|
||||||
energyLevel = 5,
|
energyLevel = 5,
|
||||||
stressLevel = 5,
|
|
||||||
symptoms = emptyList(),
|
symptoms = emptyList(),
|
||||||
notes = ""
|
notes = ""
|
||||||
)
|
)
|
||||||
@@ -305,9 +301,7 @@ private fun VitalSignsCard(
|
|||||||
bloodPressureS = 0,
|
bloodPressureS = 0,
|
||||||
bloodPressureD = 0,
|
bloodPressureD = 0,
|
||||||
temperature = 36.6f,
|
temperature = 36.6f,
|
||||||
mood = "",
|
|
||||||
energyLevel = 5,
|
energyLevel = 5,
|
||||||
stressLevel = 5,
|
|
||||||
symptoms = emptyList(),
|
symptoms = emptyList(),
|
||||||
notes = ""
|
notes = ""
|
||||||
)
|
)
|
||||||
@@ -333,9 +327,7 @@ private fun VitalSignsCard(
|
|||||||
bloodPressureS = 0,
|
bloodPressureS = 0,
|
||||||
bloodPressureD = 0,
|
bloodPressureD = 0,
|
||||||
temperature = 36.6f,
|
temperature = 36.6f,
|
||||||
mood = "",
|
|
||||||
energyLevel = 5,
|
energyLevel = 5,
|
||||||
stressLevel = 5,
|
|
||||||
symptoms = emptyList(),
|
symptoms = emptyList(),
|
||||||
notes = ""
|
notes = ""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,30 +37,41 @@ class HealthViewModel @Inject constructor(
|
|||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Загружаем данные о здоровье за сегодня
|
// TODO: Временно используем заглушки, пока не добавим методы в repository
|
||||||
repository.getTodayHealthData().collect { todayRecord: HealthRecordEntity? ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
todayRecord = todayRecord,
|
todayRecord = null,
|
||||||
lastUpdateDate = todayRecord?.date,
|
lastUpdateDate = null,
|
||||||
todaySymptoms = todayRecord?.symptoms ?: emptyList(),
|
todaySymptoms = emptyList(),
|
||||||
todayNotes = todayRecord?.notes ?: "",
|
todayNotes = "",
|
||||||
|
recentRecords = emptyList(),
|
||||||
|
weeklyWeights = emptyMap(),
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
// Загружаем данные о здоровье за сегодня
|
||||||
|
// repository.getTodayHealthData().collect { todayRecord: HealthRecordEntity? ->
|
||||||
|
// _uiState.value = _uiState.value.copy(
|
||||||
|
// todayRecord = todayRecord,
|
||||||
|
// lastUpdateDate = todayRecord?.date,
|
||||||
|
// todaySymptoms = todayRecord?.symptoms ?: emptyList(),
|
||||||
|
// todayNotes = todayRecord?.notes ?: "",
|
||||||
|
// isLoading = false
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
// Загружаем недельные данные веса
|
// Загружаем недельные данные веса
|
||||||
repository.getAllHealthRecords().collect { records: List<HealthRecordEntity> ->
|
// repository.getAllHealthRecords().collect { records: List<HealthRecordEntity> ->
|
||||||
val weightsMap = records
|
// val weightsMap = records
|
||||||
.filter { it.weight != null && it.weight > 0f }
|
// .filter { it.weight != null && it.weight > 0f }
|
||||||
.groupBy { it.date }
|
// .groupBy { it.date }
|
||||||
.mapValues { entry -> entry.value.last().weight ?: 0f }
|
// .mapValues { entry -> entry.value.last().weight ?: 0f }
|
||||||
_uiState.value = _uiState.value.copy(weeklyWeights = weightsMap)
|
// _uiState.value = _uiState.value.copy(weeklyWeights = weightsMap)
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Загружаем последние записи
|
// Загружаем последние записи
|
||||||
repository.getRecentHealthRecords().collect { records: List<HealthRecordEntity> ->
|
// repository.getRecentHealthRecords().collect { records: List<HealthRecordEntity> ->
|
||||||
_uiState.value = _uiState.value.copy(recentRecords = records)
|
// _uiState.value = _uiState.value.copy(recentRecords = records)
|
||||||
}
|
// }
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
@@ -91,42 +102,13 @@ class HealthViewModel @Inject constructor(
|
|||||||
bloodPressureS = bpSystolic,
|
bloodPressureS = bpSystolic,
|
||||||
bloodPressureD = bpDiastolic,
|
bloodPressureD = bpDiastolic,
|
||||||
temperature = temperature,
|
temperature = temperature,
|
||||||
mood = "",
|
|
||||||
energyLevel = 5,
|
energyLevel = 5,
|
||||||
stressLevel = 5,
|
|
||||||
symptoms = emptyList(),
|
symptoms = emptyList(),
|
||||||
notes = ""
|
notes = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
repository.saveHealthRecord(updatedRecord)
|
// TODO: Добавить метод saveHealthRecord в repository
|
||||||
} catch (e: Exception) {
|
// repository.saveHealthRecord(updatedRecord)
|
||||||
_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(),
|
|
||||||
weight = 0f,
|
|
||||||
heartRate = 0,
|
|
||||||
bloodPressureS = 0,
|
|
||||||
bloodPressureD = 0,
|
|
||||||
temperature = 36.6f,
|
|
||||||
mood = mood,
|
|
||||||
energyLevel = 5,
|
|
||||||
stressLevel = 5,
|
|
||||||
symptoms = emptyList(),
|
|
||||||
notes = ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
repository.saveHealthRecord(updatedRecord)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
@@ -142,47 +124,18 @@ class HealthViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
HealthRecordEntity(
|
HealthRecordEntity(
|
||||||
date = LocalDate.now(),
|
date = LocalDate.now(),
|
||||||
weight = 0f,
|
weight = null,
|
||||||
heartRate = 0,
|
heartRate = null,
|
||||||
bloodPressureS = 0,
|
bloodPressureS = null,
|
||||||
bloodPressureD = 0,
|
bloodPressureD = null,
|
||||||
temperature = 36.6f,
|
temperature = null,
|
||||||
mood = "",
|
|
||||||
energyLevel = energy,
|
energyLevel = energy,
|
||||||
stressLevel = 5,
|
|
||||||
symptoms = emptyList(),
|
symptoms = emptyList(),
|
||||||
notes = ""
|
notes = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
repository.saveHealthRecord(updatedRecord)
|
// TODO: Добавить метод saveHealthRecord в repository
|
||||||
} catch (e: Exception) {
|
// repository.saveHealthRecord(updatedRecord)
|
||||||
_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(),
|
|
||||||
weight = 0f,
|
|
||||||
heartRate = 0,
|
|
||||||
bloodPressureS = 0,
|
|
||||||
bloodPressureD = 0,
|
|
||||||
temperature = 36.6f,
|
|
||||||
mood = "",
|
|
||||||
energyLevel = 5,
|
|
||||||
stressLevel = stress,
|
|
||||||
symptoms = emptyList(),
|
|
||||||
notes = ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
repository.saveHealthRecord(updatedRecord)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
@@ -199,19 +152,18 @@ class HealthViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
HealthRecordEntity(
|
HealthRecordEntity(
|
||||||
date = LocalDate.now(),
|
date = LocalDate.now(),
|
||||||
weight = 0f,
|
weight = null,
|
||||||
heartRate = 0,
|
heartRate = null,
|
||||||
bloodPressureS = 0,
|
bloodPressureS = null,
|
||||||
bloodPressureD = 0,
|
bloodPressureD = null,
|
||||||
temperature = 36.6f,
|
temperature = null,
|
||||||
mood = "",
|
|
||||||
energyLevel = 5,
|
energyLevel = 5,
|
||||||
stressLevel = 5,
|
|
||||||
symptoms = symptoms,
|
symptoms = symptoms,
|
||||||
notes = ""
|
notes = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
repository.saveHealthRecord(updatedRecord)
|
// TODO: Добавить метод saveHealthRecord в repository
|
||||||
|
// repository.saveHealthRecord(updatedRecord)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
@@ -228,19 +180,18 @@ class HealthViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
HealthRecordEntity(
|
HealthRecordEntity(
|
||||||
date = LocalDate.now(),
|
date = LocalDate.now(),
|
||||||
weight = 0f,
|
weight = null,
|
||||||
heartRate = 0,
|
heartRate = null,
|
||||||
bloodPressureS = 0,
|
bloodPressureS = null,
|
||||||
bloodPressureD = 0,
|
bloodPressureD = null,
|
||||||
temperature = 36.6f,
|
temperature = null,
|
||||||
mood = "",
|
|
||||||
energyLevel = 5,
|
energyLevel = 5,
|
||||||
stressLevel = 5,
|
|
||||||
symptoms = emptyList(),
|
symptoms = emptyList(),
|
||||||
notes = notes
|
notes = notes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
repository.saveHealthRecord(updatedRecord)
|
// TODO: Добавить метод saveHealthRecord в repository
|
||||||
|
// repository.saveHealthRecord(updatedRecord)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
@@ -250,7 +201,8 @@ class HealthViewModel @Inject constructor(
|
|||||||
fun deleteHealthRecord(record: HealthRecordEntity) {
|
fun deleteHealthRecord(record: HealthRecordEntity) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.deleteHealthRecord(record.id)
|
// TODO: Добавить метод deleteHealthRecord в repository
|
||||||
|
// repository.deleteHealthRecord(record.id)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.main
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import kr.smartsoltech.wellshe.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Главный экран приложения
|
||||||
|
*/
|
||||||
|
class MainFragment : Fragment() {
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
return inflater.inflate(R.layout.fragment_main, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
// Инициализация UI и обработчиков событий
|
||||||
|
}
|
||||||
|
}
|
||||||
114
app/src/main/java/kr/smartsoltech/wellshe/ui/main/MainScreen.kt
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.main
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ExitToApp
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kr.smartsoltech.wellshe.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
onNavigateToEmergency: () -> Unit,
|
||||||
|
onLogout: () -> Unit
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("WellShe") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { onLogout() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ExitToApp,
|
||||||
|
contentDescription = "Выйти"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
BottomAppBar {
|
||||||
|
// Навигация внизу экрана, если необходимо
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Главный экран приложения WellShe",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onNavigateToEmergency() },
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||||
|
modifier = Modifier.fillMaxWidth(0.8f)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Warning,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Text("ЭКСТРЕННАЯ ПОМОЩЬ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Добавление основных функциональных кнопок
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Функции приложения",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Кнопки для основных функций
|
||||||
|
Button(
|
||||||
|
onClick = { /* Действие */ },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Мой профиль")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { /* Действие */ },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Настройки")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
package kr.smartsoltech.wellshe.ui.mood
|
|
||||||
|
|
||||||
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.Favorite
|
|
||||||
import androidx.compose.material.icons.filled.ModeNight
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kr.smartsoltech.wellshe.ui.components.InfoCard
|
|
||||||
import kr.smartsoltech.wellshe.ui.components.StatCard
|
|
||||||
import kr.smartsoltech.wellshe.ui.theme.MoodTabColor
|
|
||||||
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Экран "Настроение" для отслеживания сна и эмоционального состояния
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun MoodScreen(
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp)
|
|
||||||
.verticalScroll(scrollState),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
// Статистические карточки
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
StatCard(
|
|
||||||
title = "Сон",
|
|
||||||
value = "7.2 ч",
|
|
||||||
tone = Color(0xFF673AB7), // Фиолетовый для сна
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
|
|
||||||
StatCard(
|
|
||||||
title = "Стресс",
|
|
||||||
value = "3/10",
|
|
||||||
tone = Color(0xFFE91E63), // Розовый для стресса
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Карточка дневника
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MoodTabColor.copy(alpha = 0.3f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
// Заголовок
|
|
||||||
Text(
|
|
||||||
text = "Дневник",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
|
|
||||||
// Содержимое дневника
|
|
||||||
Text(
|
|
||||||
text = "Сегодня было продуктивно, немного тревоги перед встречей. Выполнила все запланированные задачи, чувствую удовлетворение от проделанной работы.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
// Кнопки действий
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End
|
|
||||||
) {
|
|
||||||
TextButton(onClick = { /* TODO */ }) {
|
|
||||||
Text("Редактировать")
|
|
||||||
}
|
|
||||||
|
|
||||||
TextButton(onClick = { /* TODO */ }) {
|
|
||||||
Text("Добавить запись")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Карточка сна
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
// Заголовок с иконкой
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ModeNight,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color(0xFF673AB7)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Качество сна",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Оценка сна
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text("Продолжительность")
|
|
||||||
Text("7.2 часа", fontWeight = FontWeight.SemiBold)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text("Качество")
|
|
||||||
Text("Хорошее", fontWeight = FontWeight.SemiBold)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text("Пробуждения")
|
|
||||||
Text("1 раз", fontWeight = FontWeight.SemiBold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопка добавления записи
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { /* TODO */ },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text("Записать сон")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Карточка эмоций
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
// Заголовок с иконкой
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Favorite,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color(0xFFE91E63)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Эмоциональное состояние",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Текущее настроение
|
|
||||||
Text(
|
|
||||||
text = "Текущее настроение: Спокойствие, удовлетворение",
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
|
|
||||||
// Кнопки эмоций
|
|
||||||
EmojiButtonsRow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Карточка рекомендаций
|
|
||||||
InfoCard(
|
|
||||||
title = "Рекомендации",
|
|
||||||
content = "Стабильный сон и низкий уровень стресса положительно влияют на ваш цикл. Рекомендуется поддерживать текущий режим для гормонального баланса."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Строка кнопок с эмодзи для выбора эмоций
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun EmojiButtonsRow() {
|
|
||||||
val emojis = listOf("😊", "😌", "🙂", "😐", "😔", "😢", "😡")
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
emojis.forEach { emoji ->
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { /* TODO */ },
|
|
||||||
contentPadding = PaddingValues(12.dp),
|
|
||||||
modifier = Modifier.size(44.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = emoji,
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun MoodScreenPreview() {
|
|
||||||
WellSheTheme {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
color = MaterialTheme.colorScheme.background
|
|
||||||
) {
|
|
||||||
MoodScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package kr.smartsoltech.wellshe.ui.mood
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class MoodViewModel @Inject constructor() : ViewModel() {
|
|
||||||
// Данные для экрана настроения
|
|
||||||
private val _sleepHours = MutableStateFlow(7.2f)
|
|
||||||
val sleepHours: StateFlow<Float> = _sleepHours.asStateFlow()
|
|
||||||
|
|
||||||
private val _stressLevel = MutableStateFlow(3)
|
|
||||||
val stressLevel: StateFlow<Int> = _stressLevel.asStateFlow()
|
|
||||||
|
|
||||||
private val _journalEntry = MutableStateFlow("Сегодня было продуктивно, немного тревоги перед встречей.")
|
|
||||||
val journalEntry: StateFlow<String> = _journalEntry.asStateFlow()
|
|
||||||
|
|
||||||
fun updateSleepHours(hours: Float) {
|
|
||||||
_sleepHours.value = hours
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateStressLevel(level: Int) {
|
|
||||||
_stressLevel.value = level
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateJournalEntry(entry: String) {
|
|
||||||
_journalEntry.value = entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,69 @@
|
|||||||
package kr.smartsoltech.wellshe.ui.navigation
|
package kr.smartsoltech.wellshe.ui.navigation
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import kr.smartsoltech.wellshe.ui.analytics.AnalyticsScreen
|
import kr.smartsoltech.wellshe.ui.analytics.AnalyticsScreen
|
||||||
import kr.smartsoltech.wellshe.ui.body.BodyScreen
|
import kr.smartsoltech.wellshe.ui.body.BodyScreen
|
||||||
import kr.smartsoltech.wellshe.ui.cycle.CycleScreen
|
import kr.smartsoltech.wellshe.ui.cycle.CycleScreen
|
||||||
import kr.smartsoltech.wellshe.ui.mood.MoodScreen
|
import kr.smartsoltech.wellshe.ui.emergency.EmergencyScreen
|
||||||
import kr.smartsoltech.wellshe.ui.profile.ProfileScreen
|
import kr.smartsoltech.wellshe.ui.profile.ProfileScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
|
||||||
|
import kr.smartsoltech.wellshe.ui.auth.compose.LoginScreen
|
||||||
|
import kr.smartsoltech.wellshe.ui.auth.compose.RegisterScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavGraph(
|
fun AppNavGraph(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
startDestination: String = BottomNavItem.Cycle.route
|
startDestination: String = "login", // Меняем стартовый экран на авторизацию
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
authViewModel: AuthViewModel // Добавляем параметр для доступа к AuthViewModel
|
||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination
|
startDestination = startDestination,
|
||||||
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
|
// Авторизация и регистрация
|
||||||
|
composable("login") {
|
||||||
|
LoginScreen(
|
||||||
|
onNavigateToRegister = {
|
||||||
|
navController.navigate("register")
|
||||||
|
},
|
||||||
|
onLoginSuccess = {
|
||||||
|
navController.navigate(BottomNavItem.Cycle.route) {
|
||||||
|
popUpTo("login") { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable("register") {
|
||||||
|
RegisterScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onRegisterSuccess = { email, password ->
|
||||||
|
// После успешной регистрации автоматически входим в систему с новыми учетными данными
|
||||||
|
authViewModel.login(email, password, true)
|
||||||
|
// Переходим на главный экран
|
||||||
|
navController.navigate(BottomNavItem.Cycle.route) {
|
||||||
|
popUpTo("login") { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Существующие экраны
|
||||||
composable(BottomNavItem.Cycle.route) {
|
composable(BottomNavItem.Cycle.route) {
|
||||||
CycleScreen(
|
CycleScreen(
|
||||||
onNavigateToSettings = {
|
onNavigateToSettings = {
|
||||||
navController.navigate("cycle_settings")
|
navController.navigate("cycle_settings")
|
||||||
|
},
|
||||||
|
onNavigateToEmergency = {
|
||||||
|
navController.navigate("emergency")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -40,8 +81,12 @@ fun AppNavGraph(
|
|||||||
BodyScreen()
|
BodyScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(BottomNavItem.Mood.route) {
|
composable(BottomNavItem.Emergency.route) {
|
||||||
MoodScreen()
|
EmergencyScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(BottomNavItem.Analytics.route) {
|
composable(BottomNavItem.Analytics.route) {
|
||||||
@@ -49,7 +94,17 @@ fun AppNavGraph(
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable(BottomNavItem.Profile.route) {
|
composable(BottomNavItem.Profile.route) {
|
||||||
ProfileScreen()
|
ProfileScreen(
|
||||||
|
onLogout = {
|
||||||
|
// Вызываем метод выхода из AuthViewModel, который очищает токены
|
||||||
|
authViewModel.logout()
|
||||||
|
// Переходим на экран авторизации после выхода
|
||||||
|
navController.navigate("login") {
|
||||||
|
// Очищаем весь стек навигации
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ package kr.smartsoltech.wellshe.ui.navigation
|
|||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.BarChart
|
import androidx.compose.material.icons.filled.BarChart
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Emergency
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.WaterDrop
|
import androidx.compose.material.icons.filled.WaterDrop
|
||||||
import androidx.compose.material.icons.filled.WbSunny
|
import androidx.compose.material.icons.filled.WbSunny
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Модель навигационного элемента для нижней панели навигац<EFBFBD><EFBFBD>и
|
* Модель навигационного элемента для нижней панели навигации
|
||||||
*/
|
*/
|
||||||
sealed class BottomNavItem(
|
sealed class BottomNavItem(
|
||||||
val route: String,
|
val route: String,
|
||||||
@@ -28,10 +28,10 @@ sealed class BottomNavItem(
|
|||||||
icon = Icons.Default.WaterDrop
|
icon = Icons.Default.WaterDrop
|
||||||
)
|
)
|
||||||
|
|
||||||
data object Mood : BottomNavItem(
|
data object Emergency : BottomNavItem(
|
||||||
route = "mood",
|
route = "emergency",
|
||||||
title = "Настроение",
|
title = "Экстренное",
|
||||||
icon = Icons.Default.Favorite
|
icon = Icons.Default.Emergency
|
||||||
)
|
)
|
||||||
|
|
||||||
data object Analytics : BottomNavItem(
|
data object Analytics : BottomNavItem(
|
||||||
@@ -47,6 +47,6 @@ sealed class BottomNavItem(
|
|||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val items = listOf(Cycle, Body, Mood, Analytics, Profile)
|
val items = listOf(Cycle, Body, Emergency, Analytics, Profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ fun BottomNavigation(
|
|||||||
val backgroundColor = when (item) {
|
val backgroundColor = when (item) {
|
||||||
BottomNavItem.Cycle -> CycleTabColor
|
BottomNavItem.Cycle -> CycleTabColor
|
||||||
BottomNavItem.Body -> BodyTabColor
|
BottomNavItem.Body -> BodyTabColor
|
||||||
BottomNavItem.Mood -> MoodTabColor
|
BottomNavItem.Emergency -> ErrorRed
|
||||||
BottomNavItem.Analytics -> AnalyticsTabColor
|
BottomNavItem.Analytics -> AnalyticsTabColor
|
||||||
BottomNavItem.Profile -> ProfileTabColor
|
BottomNavItem.Profile -> ProfileTabColor
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
|
||||||
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||||
|
|
||||||
// Добавляем явный импорт для BottomNavigation из того же пакета
|
// Добавляем явный импорт для BottomNavigation из того же пакета
|
||||||
@@ -20,6 +22,8 @@ import kr.smartsoltech.wellshe.ui.navigation.BottomNavigation
|
|||||||
@Composable
|
@Composable
|
||||||
fun WellSheNavigation() {
|
fun WellSheNavigation() {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
// Получаем AuthViewModel с помощью viewModel() делегата
|
||||||
|
val authViewModel: AuthViewModel = viewModel()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
@@ -31,7 +35,11 @@ fun WellSheNavigation() {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
AppNavGraph(navController = navController)
|
// Передаем authViewModel в AppNavGraph
|
||||||
|
AppNavGraph(
|
||||||
|
navController = navController,
|
||||||
|
authViewModel = authViewModel
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.profile
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
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.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kr.smartsoltech.wellshe.model.auth.UserProfile
|
||||||
|
import kr.smartsoltech.wellshe.ui.components.PermissionRequestDialog
|
||||||
|
import kr.smartsoltech.wellshe.ui.profile.viewmodel.ProfileViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ProfileEditScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: ProfileViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
// Состояние для профиля пользователя
|
||||||
|
var userProfile by remember { mutableStateOf(viewModel.userProfile.value ?: UserProfile()) }
|
||||||
|
|
||||||
|
// Состояния для показа диалогов выбора даты и разрешений
|
||||||
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
var showContactPermissionDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Состояние для обработки запроса разрешений на контакты
|
||||||
|
var contactType by remember { mutableStateOf(ContactType.NONE) }
|
||||||
|
|
||||||
|
// Launcher для выбора контакта
|
||||||
|
val contactPickerLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
|
result.data?.data?.let { contactUri ->
|
||||||
|
// Обрабатываем выбранный контакт
|
||||||
|
val projection = arrayOf(
|
||||||
|
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
|
||||||
|
ContactsContract.CommonDataKinds.Phone.NUMBER
|
||||||
|
)
|
||||||
|
|
||||||
|
context.contentResolver.query(contactUri, projection, null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
|
||||||
|
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||||
|
|
||||||
|
val name = cursor.getString(nameIndex)
|
||||||
|
val phone = cursor.getString(numberIndex)
|
||||||
|
|
||||||
|
// Обновляем профиль в зависимости от типа контакта
|
||||||
|
when (contactType) {
|
||||||
|
ContactType.EMERGENCY_1 -> {
|
||||||
|
userProfile = userProfile.copy(
|
||||||
|
emergency_contact_1_name = name,
|
||||||
|
emergency_contact_1_phone = phone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ContactType.EMERGENCY_2 -> {
|
||||||
|
userProfile = userProfile.copy(
|
||||||
|
emergency_contact_2_name = name,
|
||||||
|
emergency_contact_2_phone = phone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрос разрешения на доступ к контактам
|
||||||
|
val contactPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { isGranted ->
|
||||||
|
if (isGranted) {
|
||||||
|
// Если разрешение получено, запускаем выбор контакта
|
||||||
|
val intent = Intent(Intent.ACTION_PICK).apply {
|
||||||
|
setType(ContactsContract.CommonDataKinds.Phone.CONTENT_TYPE)
|
||||||
|
}
|
||||||
|
contactPickerLauncher.launch(intent)
|
||||||
|
} else {
|
||||||
|
// Если разрешение не получено, показываем диалог
|
||||||
|
showContactPermissionDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка и запрос разрешения на доступ к контактам
|
||||||
|
fun checkAndRequestContactPermission(type: ContactType) {
|
||||||
|
contactType = type
|
||||||
|
|
||||||
|
when (PackageManager.PERMISSION_GRANTED) {
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.READ_CONTACTS
|
||||||
|
) -> {
|
||||||
|
// Разрешение уже есть, запускаем выбор контакта
|
||||||
|
val intent = Intent(Intent.ACTION_PICK).apply {
|
||||||
|
setType(ContactsContract.CommonDataKinds.Phone.CONTENT_TYPE)
|
||||||
|
}
|
||||||
|
contactPickerLauncher.launch(intent)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Запрашиваем разрешение
|
||||||
|
contactPermissionLauncher.launch(Manifest.permission.READ_CONTACTS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог выбора даты рождения
|
||||||
|
if (showDatePicker) {
|
||||||
|
val currentDate = userProfile.date_of_birth?.let {
|
||||||
|
try {
|
||||||
|
LocalDate.parse(it)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LocalDate.now().minusYears(20)
|
||||||
|
}
|
||||||
|
} ?: LocalDate.now().minusYears(20)
|
||||||
|
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { showDatePicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val selectedDate = LocalDate.of(
|
||||||
|
viewModel.datePickerState.selectedDateMillis?.let { millis ->
|
||||||
|
java.time.Instant.ofEpochMilli(millis).atZone(java.time.ZoneId.systemDefault()).toLocalDate().year
|
||||||
|
} ?: currentDate.year,
|
||||||
|
viewModel.datePickerState.selectedDateMillis?.let { millis ->
|
||||||
|
java.time.Instant.ofEpochMilli(millis).atZone(java.time.ZoneId.systemDefault()).toLocalDate().monthValue
|
||||||
|
} ?: currentDate.monthValue,
|
||||||
|
viewModel.datePickerState.selectedDateMillis?.let { millis ->
|
||||||
|
java.time.Instant.ofEpochMilli(millis).atZone(java.time.ZoneId.systemDefault()).toLocalDate().dayOfMonth
|
||||||
|
} ?: currentDate.dayOfMonth
|
||||||
|
)
|
||||||
|
|
||||||
|
userProfile = userProfile.copy(
|
||||||
|
date_of_birth = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||||
|
)
|
||||||
|
showDatePicker = false
|
||||||
|
}) {
|
||||||
|
Text("OK")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDatePicker = false }) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
DatePicker(state = viewModel.datePickerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог запроса разрешения на контакты
|
||||||
|
if (showContactPermissionDialog) {
|
||||||
|
PermissionRequestDialog(
|
||||||
|
permissionText = "Для выбора экстренного контакта необходимо разрешение на доступ к контактам.",
|
||||||
|
onDismiss = { showContactPermissionDialog = false },
|
||||||
|
onConfirm = {
|
||||||
|
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", context.packageName, null)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
showContactPermissionDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Редактирование профиля") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.Filled.ArrowBack, contentDescription = "Назад")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
viewModel.updateUserProfile(userProfile)
|
||||||
|
onNavigateBack()
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Filled.Save, contentDescription = "Сохранить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Раздел с основной информацией
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Личная информация",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = userProfile.bio ?: "",
|
||||||
|
onValueChange = { userProfile = userProfile.copy(bio = it) },
|
||||||
|
label = { Text("О себе") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 3,
|
||||||
|
maxLines = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// Дата рождения
|
||||||
|
OutlinedTextField(
|
||||||
|
value = userProfile.date_of_birth ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
label = { Text("Дата рождения") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
readOnly = true,
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showDatePicker = true }) {
|
||||||
|
Icon(Icons.Filled.DateRange, contentDescription = "Выбрать дату")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Раздел с экстренными контактами
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Экстренные контакты",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Первый экстренный контакт
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Первый контакт",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (userProfile.emergency_contact_1_name.isNullOrBlank()) "Не выбрано"
|
||||||
|
else "${userProfile.emergency_contact_1_name}\n${userProfile.emergency_contact_1_phone}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { checkAndRequestContactPermission(ContactType.EMERGENCY_1) },
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Contacts, contentDescription = "Выбрать контакт")
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Выбрать")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Второй экстренный контакт
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Второй контакт",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (userProfile.emergency_contact_2_name.isNullOrBlank()) "Не выбрано"
|
||||||
|
else "${userProfile.emergency_contact_2_name}\n${userProfile.emergency_contact_2_phone}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { checkAndRequestContactPermission(ContactType.EMERGENCY_2) },
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Contacts, contentDescription = "Выбрать контакт")
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Выбрать")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Раздел с настройками уведомлений и доступа
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Настройки и разрешения",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Включение/выключение экстренных уведомлений
|
||||||
|
SwitchRow(
|
||||||
|
title = "Экстренные уведомления",
|
||||||
|
checked = userProfile.emergency_notifications_enabled ?: false,
|
||||||
|
onCheckedChange = {
|
||||||
|
userProfile = userProfile.copy(emergency_notifications_enabled = it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Включение/выключение обмена местоположением
|
||||||
|
SwitchRow(
|
||||||
|
title = "Обмен местоположением",
|
||||||
|
checked = userProfile.location_sharing_enabled ?: false,
|
||||||
|
onCheckedChange = {
|
||||||
|
userProfile = userProfile.copy(location_sharing_enabled = it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Включение/выключение push-уведомлений
|
||||||
|
SwitchRow(
|
||||||
|
title = "Push-уведомления",
|
||||||
|
checked = userProfile.push_notifications_enabled ?: false,
|
||||||
|
onCheckedChange = {
|
||||||
|
userProfile = userProfile.copy(push_notifications_enabled = it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwitchRow(
|
||||||
|
title: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onCheckedChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перечисление типов контактов
|
||||||
|
enum class ContactType {
|
||||||
|
NONE,
|
||||||
|
EMERGENCY_1,
|
||||||
|
EMERGENCY_2
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(
|
fun ProfileScreen(
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
onLogout: () -> Unit = {} // Добавляем параметр для выхода из аккаунта
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
@@ -59,9 +60,9 @@ fun ProfileScreen(
|
|||||||
var integrations by remember {
|
var integrations by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
listOf(
|
listOf(
|
||||||
Integration("Google Fit", false),
|
Integration("Google Fit", true),
|
||||||
Integration("FatSecret Proxy", true),
|
Integration("FatSecret Proxy", false),
|
||||||
Integration("Wear OS", false)
|
Integration("Wear OS", true)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -105,7 +106,7 @@ fun ProfileScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Цель воды (мл)",
|
text = "Цель воды в сутки (мл)",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -295,6 +296,19 @@ fun ProfileScreen(
|
|||||||
Text("Экспорт настроек")
|
Text("Экспорт настроек")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Кнопка выхода из аккаунта
|
||||||
|
Button(
|
||||||
|
onClick = { onLogout() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Выход из аккаунта")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package kr.smartsoltech.wellshe.ui.profile.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.material3.DatePickerState
|
||||||
|
import androidx.compose.material3.DisplayMode
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
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.model.auth.UserProfile
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ProfileViewModel @Inject constructor() : ViewModel() {
|
||||||
|
|
||||||
|
private val _userProfile = MutableStateFlow<UserProfile?>(null)
|
||||||
|
val userProfile: StateFlow<UserProfile?> = _userProfile.asStateFlow()
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
|
val error: StateFlow<String?> = _error.asStateFlow()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
val datePickerState = DatePickerState(
|
||||||
|
initialSelectedDateMillis = LocalDate.now().minusYears(20).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
||||||
|
initialDisplayedMonthMillis = LocalDate.now().minusYears(20).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
||||||
|
yearRange = IntRange(1900, LocalDate.now().year),
|
||||||
|
initialDisplayMode = DisplayMode.Picker,
|
||||||
|
locale = java.util.Locale.getDefault()
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadUserProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadUserProfile() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
try {
|
||||||
|
// Здесь должен быть запрос к API или базе данных для получения профиля пользователя
|
||||||
|
// Пока используем заполнитель с фиктивными данными
|
||||||
|
_userProfile.value = UserProfile(
|
||||||
|
username = "user123",
|
||||||
|
email = "user@example.com",
|
||||||
|
firstName = "Иван",
|
||||||
|
lastName = "Иванов",
|
||||||
|
phone = "+7 (999) 123-45-67"
|
||||||
|
)
|
||||||
|
_error.value = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Не удалось загрузить профиль: ${e.message}"
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateUserProfile(profile: UserProfile) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
try {
|
||||||
|
// Здесь должен быть запрос к API или базе данных для обновления профиля
|
||||||
|
// Пока просто обновляем локальное состояние
|
||||||
|
_userProfile.value = profile
|
||||||
|
_error.value = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Не удалось обновить профиль: ${e.message}"
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_error.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,10 +59,8 @@ fun SettingsScreen(
|
|||||||
NotificationSettingsCard(
|
NotificationSettingsCard(
|
||||||
isWaterReminderEnabled = uiState.isWaterReminderEnabled,
|
isWaterReminderEnabled = uiState.isWaterReminderEnabled,
|
||||||
isCycleReminderEnabled = uiState.isCycleReminderEnabled,
|
isCycleReminderEnabled = uiState.isCycleReminderEnabled,
|
||||||
isSleepReminderEnabled = uiState.isSleepReminderEnabled,
|
onWaterReminderToggle = viewModel::updateWaterReminder,
|
||||||
onWaterReminderToggle = viewModel::toggleWaterReminder,
|
onCycleReminderToggle = viewModel::updateCycleReminder
|
||||||
onCycleReminderToggle = viewModel::toggleCycleReminder,
|
|
||||||
onSleepReminderToggle = viewModel::toggleSleepReminder
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,24 +77,22 @@ fun SettingsScreen(
|
|||||||
GoalsSettingsCard(
|
GoalsSettingsCard(
|
||||||
waterGoal = uiState.waterGoal,
|
waterGoal = uiState.waterGoal,
|
||||||
stepsGoal = uiState.stepsGoal,
|
stepsGoal = uiState.stepsGoal,
|
||||||
sleepGoal = uiState.sleepGoal,
|
|
||||||
onWaterGoalChange = viewModel::updateWaterGoal,
|
onWaterGoalChange = viewModel::updateWaterGoal,
|
||||||
onStepsGoalChange = viewModel::updateStepsGoal,
|
onStepsGoalChange = viewModel::updateStepsGoal
|
||||||
onSleepGoalChange = viewModel::updateSleepGoal
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
AppearanceSettingsCard(
|
AppearanceSettingsCard(
|
||||||
isDarkTheme = uiState.isDarkTheme,
|
isDarkTheme = uiState.isDarkTheme,
|
||||||
onThemeToggle = viewModel::toggleTheme
|
onThemeToggle = viewModel::updateTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
DataManagementCard(
|
DataManagementCard(
|
||||||
onExportData = viewModel::exportData,
|
onExportData = viewModel::exportData,
|
||||||
onImportData = viewModel::importData,
|
onImportData = { viewModel.importData(it) },
|
||||||
onClearData = viewModel::clearAllData
|
onClearData = viewModel::clearAllData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -155,10 +151,8 @@ private fun SettingsHeader(
|
|||||||
private fun NotificationSettingsCard(
|
private fun NotificationSettingsCard(
|
||||||
isWaterReminderEnabled: Boolean,
|
isWaterReminderEnabled: Boolean,
|
||||||
isCycleReminderEnabled: Boolean,
|
isCycleReminderEnabled: Boolean,
|
||||||
isSleepReminderEnabled: Boolean,
|
|
||||||
onWaterReminderToggle: (Boolean) -> Unit,
|
onWaterReminderToggle: (Boolean) -> Unit,
|
||||||
onCycleReminderToggle: (Boolean) -> Unit,
|
onCycleReminderToggle: (Boolean) -> Unit,
|
||||||
onSleepReminderToggle: (Boolean) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
@@ -181,15 +175,6 @@ private fun NotificationSettingsCard(
|
|||||||
isChecked = isCycleReminderEnabled,
|
isChecked = isCycleReminderEnabled,
|
||||||
onCheckedChange = onCycleReminderToggle
|
onCheckedChange = onCycleReminderToggle
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
SettingsSwitchItem(
|
|
||||||
title = "Напоминания о сне",
|
|
||||||
subtitle = "Уведомления о режиме сна",
|
|
||||||
isChecked = isSleepReminderEnabled,
|
|
||||||
onCheckedChange = onSleepReminderToggle
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,10 +219,8 @@ private fun CycleSettingsCard(
|
|||||||
private fun GoalsSettingsCard(
|
private fun GoalsSettingsCard(
|
||||||
waterGoal: Float,
|
waterGoal: Float,
|
||||||
stepsGoal: Int,
|
stepsGoal: Int,
|
||||||
sleepGoal: Float,
|
|
||||||
onWaterGoalChange: (Float) -> Unit,
|
onWaterGoalChange: (Float) -> Unit,
|
||||||
onStepsGoalChange: (Int) -> Unit,
|
onStepsGoalChange: (Int) -> Unit,
|
||||||
onSleepGoalChange: (Float) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
@@ -266,18 +249,6 @@ private fun GoalsSettingsCard(
|
|||||||
},
|
},
|
||||||
suffix = "шагов"
|
suffix = "шагов"
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
|
|
||||||
SettingsDecimalField(
|
|
||||||
title = "Цель по сну",
|
|
||||||
subtitle = "Количество часов сна (6-10 часов)",
|
|
||||||
value = sleepGoal,
|
|
||||||
onValueChange = { value ->
|
|
||||||
if (value in 6.0f..10.0f) onSleepGoalChange(value)
|
|
||||||
},
|
|
||||||
suffix = "часов"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +275,7 @@ private fun AppearanceSettingsCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DataManagementCard(
|
private fun DataManagementCard(
|
||||||
onExportData: () -> Unit,
|
onExportData: () -> Unit,
|
||||||
onImportData: () -> Unit,
|
onImportData: (String) -> Unit,
|
||||||
onClearData: () -> Unit,
|
onClearData: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -326,7 +297,7 @@ private fun DataManagementCard(
|
|||||||
title = "Импорт данных",
|
title = "Импорт данных",
|
||||||
subtitle = "Загрузить данные из файла",
|
subtitle = "Загрузить данные из файла",
|
||||||
icon = Icons.Default.Upload,
|
icon = Icons.Default.Upload,
|
||||||
onClick = onImportData
|
onClick = { onImportData("") }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|||||||
@@ -14,12 +14,10 @@ import javax.inject.Inject
|
|||||||
data class SettingsUiState(
|
data class SettingsUiState(
|
||||||
val isWaterReminderEnabled: Boolean = true,
|
val isWaterReminderEnabled: Boolean = true,
|
||||||
val isCycleReminderEnabled: Boolean = true,
|
val isCycleReminderEnabled: Boolean = true,
|
||||||
val isSleepReminderEnabled: Boolean = true,
|
|
||||||
val cycleLength: Int = 28,
|
val cycleLength: Int = 28,
|
||||||
val periodLength: Int = 5,
|
val periodLength: Int = 5,
|
||||||
val waterGoal: Float = 2.5f,
|
val waterGoal: Float = 2.5f,
|
||||||
val stepsGoal: Int = 10000,
|
val stepsGoal: Int = 10000,
|
||||||
val sleepGoal: Float = 8.0f,
|
|
||||||
val isDarkTheme: Boolean = false,
|
val isDarkTheme: Boolean = false,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null
|
||||||
@@ -38,23 +36,17 @@ class SettingsViewModel @Inject constructor(
|
|||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
repository.getSettings().catch { e ->
|
// TODO: Временно используем заглушки до реализации методов в repository
|
||||||
|
repository.getAppSettings().catch { e ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = e.message
|
error = e.message
|
||||||
)
|
)
|
||||||
}.collect { settings ->
|
}.collect { settings ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isWaterReminderEnabled = settings.isWaterReminderEnabled,
|
isDarkTheme = settings.darkModeEnabled,
|
||||||
isCycleReminderEnabled = settings.isCycleReminderEnabled,
|
isLoading = false,
|
||||||
isSleepReminderEnabled = settings.isSleepReminderEnabled,
|
error = null
|
||||||
cycleLength = settings.cycleLength,
|
|
||||||
periodLength = settings.periodLength,
|
|
||||||
waterGoal = settings.waterGoal,
|
|
||||||
stepsGoal = settings.stepsGoal,
|
|
||||||
sleepGoal = settings.sleepGoal,
|
|
||||||
isDarkTheme = settings.isDarkTheme,
|
|
||||||
isLoading = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -66,11 +58,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Уведомления
|
// Обновление настроек уведомлений
|
||||||
fun toggleWaterReminder(enabled: Boolean) {
|
fun updateWaterReminder(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updateWaterReminderSetting(enabled)
|
// TODO: Реализовать через repository
|
||||||
_uiState.value = _uiState.value.copy(isWaterReminderEnabled = enabled)
|
_uiState.value = _uiState.value.copy(isWaterReminderEnabled = enabled)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
@@ -78,10 +70,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleCycleReminder(enabled: Boolean) {
|
fun updateCycleReminder(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updateCycleReminderSetting(enabled)
|
// TODO: Реализовать через repository
|
||||||
_uiState.value = _uiState.value.copy(isCycleReminderEnabled = enabled)
|
_uiState.value = _uiState.value.copy(isCycleReminderEnabled = enabled)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
@@ -89,23 +81,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
fun updateCycleLength(length: Int) {
|
||||||
if (length in 21..35) {
|
if (length in 21..35) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updateCycleLength(length)
|
// TODO: Реализовать через repository
|
||||||
_uiState.value = _uiState.value.copy(cycleLength = length)
|
_uiState.value = _uiState.value.copy(cycleLength = length)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
@@ -115,10 +96,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updatePeriodLength(length: Int) {
|
fun updatePeriodLength(length: Int) {
|
||||||
if (length in 3..8) {
|
if (length in 3..7) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updatePeriodLength(length)
|
// TODO: Реализовать через repository
|
||||||
_uiState.value = _uiState.value.copy(periodLength = length)
|
_uiState.value = _uiState.value.copy(periodLength = length)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
@@ -127,12 +108,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Цели
|
// Обновление целей
|
||||||
fun updateWaterGoal(goal: Float) {
|
fun updateWaterGoal(goal: Float) {
|
||||||
if (goal in 1.5f..4.0f) {
|
if (goal > 0) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updateWaterGoal(goal)
|
// TODO: Реализовать через repository
|
||||||
_uiState.value = _uiState.value.copy(waterGoal = goal)
|
_uiState.value = _uiState.value.copy(waterGoal = goal)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
@@ -142,10 +123,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateStepsGoal(goal: Int) {
|
fun updateStepsGoal(goal: Int) {
|
||||||
if (goal in 5000..20000) {
|
if (goal > 0) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updateStepsGoal(goal)
|
// TODO: Реализовать через repository
|
||||||
_uiState.value = _uiState.value.copy(stepsGoal = goal)
|
_uiState.value = _uiState.value.copy(stepsGoal = goal)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
@@ -154,24 +135,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSleepGoal(goal: Float) {
|
// Обновление темы
|
||||||
if (goal in 6.0f..10.0f) {
|
fun updateTheme(isDark: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.updateSleepGoal(goal)
|
// TODO: Реализовать через repository
|
||||||
_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)
|
_uiState.value = _uiState.value.copy(isDarkTheme = isDark)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
@@ -179,35 +147,33 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Управление данными
|
// Экспорт данных
|
||||||
fun exportData() {
|
fun exportData() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.exportUserData()
|
// TODO: Реализовать экспорт данных
|
||||||
// Показать сообщение об успехе
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun importData() {
|
// Импорт данных
|
||||||
|
fun importData(data: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.importUserData()
|
// TODO: Реализовать импорт данных
|
||||||
loadSettings() // Перезагрузить настройки
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Очистка данных
|
||||||
fun clearAllData() {
|
fun clearAllData() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.clearAllUserData()
|
// TODO: Реализовать очистку данных
|
||||||
// Сбросить на дефолтные значения
|
|
||||||
_uiState.value = SettingsUiState()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(error = e.message)
|
_uiState.value = _uiState.value.copy(error = e.message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,875 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,675 +0,0 @@
|
|||||||
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 // Возвращаем значение по умолчанию
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
61
app/src/main/java/kr/smartsoltech/wellshe/util/Result.kt
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package kr.smartsoltech.wellshe.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для представления результатов операций, которые могут завершиться успешно или с ошибкой
|
||||||
|
*/
|
||||||
|
sealed class Result<out T> {
|
||||||
|
data class Success<out T>(val data: T) : Result<T>()
|
||||||
|
data class Error(val exception: Exception) : Result<Nothing>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, был ли результат успешным
|
||||||
|
*/
|
||||||
|
val isSuccess: Boolean get() = this is Success
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, содержит ли результат ошибку
|
||||||
|
*/
|
||||||
|
val isError: Boolean get() = this is Error
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает данные в случае успешного результата или null
|
||||||
|
*/
|
||||||
|
fun getOrNull(): T? = when (this) {
|
||||||
|
is Success -> data
|
||||||
|
is Error -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает исключение в случае ошибки или null
|
||||||
|
*/
|
||||||
|
fun exceptionOrNull(): Exception? = when (this) {
|
||||||
|
is Success -> null
|
||||||
|
is Error -> exception
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует успешный результат с помощью переданной функции
|
||||||
|
*/
|
||||||
|
inline fun <R> map(transform: (T) -> R): Result<R> {
|
||||||
|
return when (this) {
|
||||||
|
is Success -> Success(transform(data))
|
||||||
|
is Error -> Error(exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет действие в зависимости от результата
|
||||||
|
*/
|
||||||
|
inline fun onSuccess(action: (T) -> Unit): Result<T> {
|
||||||
|
if (this is Success) action(data)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет действие при ошибке
|
||||||
|
*/
|
||||||
|
inline fun onError(action: (Exception) -> Unit): Result<T> {
|
||||||
|
if (this is Error) action(exception)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package kr.smartsoltech.wellshe.util
|
||||||
|
|
||||||
|
import android.util.Patterns
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, является ли строка корректным email
|
||||||
|
*/
|
||||||
|
fun String.isValidEmail(): Boolean {
|
||||||
|
return isNotEmpty() && Patterns.EMAIL_ADDRESS.matcher(this).matches()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, является ли строка достаточно надежным паролем
|
||||||
|
* Требования: минимум 8 символов, хотя бы 1 заглавная буква, 1 цифра, 1 специальный символ
|
||||||
|
*/
|
||||||
|
fun String.isValidPassword(): Boolean {
|
||||||
|
if (length < 8) return false
|
||||||
|
if (!contains(Regex("[A-Z]"))) return false
|
||||||
|
if (!contains(Regex("[0-9]"))) return false
|
||||||
|
if (!contains(Regex("[^A-Za-z0-9]"))) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, является ли строка корректным телефонным номером в международном формате
|
||||||
|
*/
|
||||||
|
fun String.isValidPhone(): Boolean {
|
||||||
|
return matches(Regex("^\\+[0-9]{10,15}\$"))
|
||||||
|
}
|
||||||
@@ -1,170 +1,74 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
|
android:width="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<path
|
<path android:fillColor="#3DDC84"
|
||||||
android:fillColor="#3DDC84"
|
|
||||||
android:pathData="M0,0h108v108h-108z"/>
|
android:pathData="M0,0h108v108h-108z"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M9,0L9,108"
|
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||||
android:pathData="M19,0L19,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M29,0L29,108"
|
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||||
android:pathData="M39,0L39,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M49,0L49,108"
|
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||||
android:pathData="M59,0L59,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M69,0L69,108"
|
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||||
android:pathData="M79,0L79,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M89,0L89,108"
|
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||||
android:pathData="M99,0L99,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M0,9L108,9"
|
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||||
android:pathData="M0,19L108,19"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M0,29L108,29"
|
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" 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>
|
</vector>
|
||||||
|
|||||||
10
app/src/main/res/drawable/ic_status_active.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#4CAF50"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM16.59,7.58L10,14.17l-2.59,-2.58L6,13l4,4 8,-8z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
10
app/src/main/res/drawable/ic_status_cancelled.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF9800"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM13,7h-2v6h2V7zM13,15h-2v2h2v-2z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
10
app/src/main/res/drawable/ic_status_error.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#F44336"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM13,7h-2v6h2V7zM13,15h-2v2h2v-2z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
10
app/src/main/res/drawable/ic_status_resolved.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#2196F3"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM16.59,7.58L10,14.17l-2.59,-2.58L6,13l4,4 8,-8z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
21
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<androidx.fragment.app.FragmentContainerView
|
||||||
|
android:id="@+id/nav_host_fragment"
|
||||||
|
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:defaultNavHost="true"
|
||||||
|
app:navGraph="@navigation/nav_graph" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
124
app/src/main/res/layout/fragment_emergency.xml
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:padding="16dp"
|
||||||
|
tools:context=".ui.emergency.EmergencyFragment">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTitle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Экстренная помощь"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
android:layout_marginTop="16dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonSos"
|
||||||
|
android:layout_width="200dp"
|
||||||
|
android:layout_height="200dp"
|
||||||
|
android:text="SOS"
|
||||||
|
android:textSize="36sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:backgroundTint="#FF0000"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
app:cornerRadius="100dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Group
|
||||||
|
android:id="@+id/emergencyStatusGroup"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:constraint_referenced_ids="statusIndicator,statusText,notifiedContactsCount,notifiedContactsLabel,emergencyServicesStatus,buttonCancelAlert" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/statusIndicator"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
|
||||||
|
app:srcCompat="@drawable/ic_status_active" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/statusText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="Статус оповещения"
|
||||||
|
android:textAlignment="center"
|
||||||
|
android:textSize="16sp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/statusIndicator" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/notifiedContactsLabel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="Уведомлено контактов:"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/notifiedContactsCount"
|
||||||
|
app:layout_constraintHorizontal_bias="0.5"
|
||||||
|
app:layout_constraintHorizontal_chainStyle="packed"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/statusText" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/notifiedContactsCount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="0/0"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constraintBaseline_toBaselineOf="@+id/notifiedContactsLabel"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="0.5"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/notifiedContactsLabel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/emergencyServicesStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="Ожидание ответа экстренных служб"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/notifiedContactsLabel" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonCancelAlert"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="Отменить оповещение"
|
||||||
|
android:backgroundTint="#FF9800"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/buttonSos" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
style="?android:attr/progressBarStyle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
105
app/src/main/res/layout/fragment_login.xml
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:padding="16dp"
|
||||||
|
tools:context=".ui.auth.LoginFragment">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTitle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Вход"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/tilEmailUsername"
|
||||||
|
app:layout_constraintVertical_chainStyle="packed" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilEmailUsername"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="32dp"
|
||||||
|
android:hint="Email или имя пользователя"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/tilPassword">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etEmailUsername"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textEmailAddress" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilPassword"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:hint="Пароль"
|
||||||
|
app:passwordToggleEnabled="true"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tilEmailUsername"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/btnLogin">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etPassword"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textPassword" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnLogin"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:text="Войти"
|
||||||
|
android:enabled="false"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tilPassword"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/btnRegister" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnRegister"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="Регистрация"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.TextButton"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/btnLogin"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
style="?android:attr/progressBarStyle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Button"
|
||||||
|
tools:layout_editor_absoluteX="162dp"
|
||||||
|
tools:layout_editor_absoluteY="364dp" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
36
app/src/main/res/layout/fragment_main.xml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:padding="16dp"
|
||||||
|
tools:context=".ui.main.MainFragment">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvWelcome"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Добро пожаловать в WellShe!"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
android:layout_marginTop="24dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnEmergency"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Экстренная помощь"
|
||||||
|
android:backgroundTint="#FF0000"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tvWelcome"
|
||||||
|
android:layout_marginTop="24dp" />
|
||||||
|
|
||||||
|
<!-- Здесь будут другие элементы главного экрана -->
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
192
app/src/main/res/layout/fragment_register.xml
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:padding="16dp"
|
||||||
|
tools:context=".ui.auth.RegisterFragment">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTitle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Регистрация"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
android:layout_marginTop="16dp" />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tvTitle">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilEmail"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="Email"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etEmail"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textEmailAddress" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilUsername"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:hint="Имя пользователя"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tilEmail">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etUsername"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilPassword"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:hint="Пароль"
|
||||||
|
app:passwordToggleEnabled="true"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tilUsername">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etPassword"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textPassword" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilConfirmPassword"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:hint="Подтвердите пароль"
|
||||||
|
app:passwordToggleEnabled="true"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tilPassword">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etConfirmPassword"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textPassword" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilFirstName"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:hint="Имя"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tilConfirmPassword">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etFirstName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textPersonName" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilLastName"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:hint="Фамилия"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tilFirstName">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etLastName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textPersonName" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilPhone"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:hint="Телефон"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tilLastName">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etPhone"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="phone" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnRegister"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:text="Зарегистрироваться"
|
||||||
|
android:enabled="false"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/tilPhone" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnBackToLogin"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="У меня уже есть аккаунт"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.TextButton"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/btnRegister" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
style="?android:attr/progressBarStyle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.5 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 6.3 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |