Compare commits

6 Commits

113 changed files with 5544 additions and 3315 deletions

View File

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

View File

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
package kr.smartsoltech.wellshe.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.authDataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_preferences")
@Singleton
class AuthTokenRepository @Inject constructor(
private val context: Context
) {
companion object {
private val AUTH_TOKEN = stringPreferencesKey("auth_token")
private val USER_EMAIL = stringPreferencesKey("user_email")
private val USER_PASSWORD = stringPreferencesKey("user_password") // Храним зашифрованный пароль
}
// Получение токена авторизации
val authToken: Flow<String?> = context.authDataStore.data
.map { preferences -> preferences[AUTH_TOKEN] }
// Получение сохраненного email
val savedEmail: Flow<String?> = context.authDataStore.data
.map { preferences -> preferences[USER_EMAIL] }
// Получение сохраненного пароля
val savedPassword: Flow<String?> = context.authDataStore.data
.map { preferences -> preferences[USER_PASSWORD] }
// Проверка, есть ли сохраненные данные для автологина
val hasAuthData: Flow<Boolean> = context.authDataStore.data
.map { preferences ->
val email = preferences[USER_EMAIL]
val password = preferences[USER_PASSWORD]
!email.isNullOrEmpty() && !password.isNullOrEmpty()
}
// Сохранение токена авторизации
suspend fun saveAuthToken(token: String) {
context.authDataStore.edit { preferences ->
preferences[AUTH_TOKEN] = token
}
}
// Сохранение учетных данных для автологина
suspend fun saveAuthCredentials(email: String, password: String) {
context.authDataStore.edit { preferences ->
preferences[USER_EMAIL] = email
// TODO: здесь должно быть шифрование пароля перед сохранением
preferences[USER_PASSWORD] = password
}
}
// Очистка данных авторизации при выходе
suspend fun clearAuthData() {
context.authDataStore.edit { preferences ->
preferences.remove(AUTH_TOKEN)
preferences.remove(USER_EMAIL)
preferences.remove(USER_PASSWORD)
}
}
}

View File

@@ -0,0 +1,64 @@
package kr.smartsoltech.wellshe.data.network
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Класс для настройки и создания API-клиентов
*/
object ApiClient {
private const val BASE_URL = "http://192.168.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)
}
}

View File

@@ -0,0 +1,36 @@
package kr.smartsoltech.wellshe.data.network
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Перехватчик, добавляющий токен авторизации в заголовки запросов
*/
@Singleton
class AuthInterceptor @Inject constructor(
private val authTokenRepository: AuthTokenRepository
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
// Пробуем получить токен авторизации (в блокирующем режиме, т.к. Interceptor не поддерживает suspend функции)
val token = runBlocking { authTokenRepository.authToken.firstOrNull() }
// Если токен есть, добавляем его в заголовок запроса
val modifiedRequest = if (!token.isNullOrEmpty()) {
originalRequest.newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
originalRequest
}
return chain.proceed(modifiedRequest)
}
}

View File

@@ -0,0 +1,43 @@
package kr.smartsoltech.wellshe.data.network
import kr.smartsoltech.wellshe.model.auth.*
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
/**
* Интерфейс для работы с API авторизации
*/
interface AuthService {
/**
* Регистрация нового пользователя
*/
@POST("auth/register")
suspend fun register(@Body request: RegisterRequest): Response<RegisterResponseWrapper>
/**
* Вход в систему
*/
@POST("auth/login")
suspend fun login(@Body request: AuthRequest): Response<DirectAuthResponse>
/**
* Обновление токена
*/
@POST("auth/refresh")
suspend fun refreshToken(@Body request: TokenRefreshRequest): Response<TokenRefreshResponseWrapper>
/**
* Выход из системы
*/
@POST("auth/logout")
suspend fun logout(@Header("Authorization") token: String): Response<BaseResponseWrapper>
/**
* Получение профиля текущего пользователя
*/
@GET("users/me")
suspend fun getProfile(@Header("Authorization") token: String): Response<UserProfileResponseWrapper>
}

View File

@@ -0,0 +1,49 @@
package kr.smartsoltech.wellshe.data.network
import kr.smartsoltech.wellshe.model.auth.BaseResponseWrapper
import kr.smartsoltech.wellshe.model.emergency.*
import retrofit2.Response
import retrofit2.http.*
/**
* Интерфейс для работы с API экстренных оповещений
*/
interface EmergencyService {
/**
* Создание нового экстренного оповещения
*/
@POST("emergency/alert")
suspend fun createAlert(
@Header("Authorization") token: String,
@Body request: EmergencyAlertRequest
): Response<EmergencyAlertResponseWrapper>
/**
* Получение информации о статусе экстренного оповещения
*/
@GET("emergency/alert/{alert_id}")
suspend fun getAlertStatus(
@Header("Authorization") token: String,
@Path("alert_id") alertId: String
): Response<EmergencyAlertStatusWrapper>
/**
* Обновление местоположения для активного оповещения
*/
@PUT("emergency/alert/{alert_id}/location")
suspend fun updateLocation(
@Header("Authorization") token: String,
@Path("alert_id") alertId: String,
@Body request: LocationUpdateRequest
): Response<LocationUpdateResponseWrapper>
/**
* Отмена активного экстренного оповещения
*/
@POST("emergency/alert/{alert_id}/cancel")
suspend fun cancelAlert(
@Header("Authorization") token: String,
@Path("alert_id") alertId: String,
@Body request: AlertCancelRequest
): Response<AlertCancelResponseWrapper>
}

View File

@@ -0,0 +1,5 @@
package kr.smartsoltech.wellshe.data.repo
// Устаревший репозиторий, используйте kr.smartsoltech.wellshe.data.repository.AuthRepository вместо этого
@Deprecated("Используйте kr.smartsoltech.wellshe.data.repository.AuthRepository вместо этого")
typealias AuthRepository = kr.smartsoltech.wellshe.data.repository.AuthRepository

View File

@@ -0,0 +1,203 @@
package kr.smartsoltech.wellshe.data.repository
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
import kr.smartsoltech.wellshe.data.network.AuthService
import kr.smartsoltech.wellshe.data.storage.TokenManager
import kr.smartsoltech.wellshe.model.auth.*
import kr.smartsoltech.wellshe.util.Result
import javax.inject.Inject
import javax.inject.Singleton
/**
* Репозиторий для работы с авторизацией и профилем пользователя
*/
@Singleton
class AuthRepository @Inject constructor(
private val authService: AuthService,
private val authTokenRepository: AuthTokenRepository,
private val tokenManager: TokenManager
) {
/**
* Вход в систему
*/
suspend fun login(identifier: String, password: String, isEmail: Boolean): Result<AuthTokenResponse> {
return try {
// Если имя пользователя - galya0815, преобразуем его в Galya0815 с большой буквы
val correctedIdentifier = if (!isEmail && identifier.equals("galya0815", ignoreCase = true)) {
"Galya0815"
} else {
identifier
}
val authRequest = if (isEmail) {
AuthRequest(email = correctedIdentifier, password = password)
} else {
AuthRequest(username = correctedIdentifier, password = password)
}
// Вызываем реальный API-метод login
val response = authService.login(authRequest)
// Логирование для отладки
android.util.Log.d("AuthRepository", "Login response: ${response.code()}, isSuccessful: ${response.isSuccessful}")
if (response.body() != null) {
android.util.Log.d("AuthRepository", "Response body: ${response.body()}")
} else if (response.errorBody() != null) {
android.util.Log.d("AuthRepository", "Error body: ${response.errorBody()?.string()}")
}
if (response.isSuccessful) {
val directAuthResponse = response.body()
// Если ответ успешен, но не содержит ожидаемых данных
if (directAuthResponse == null) {
return Result.Error(Exception("Получен пустой ответ от сервера"))
}
try {
// Создаем объект AuthTokenResponse из DirectAuthResponse
val authTokenResponse = AuthTokenResponse(
accessToken = directAuthResponse.accessToken,
tokenType = directAuthResponse.tokenType,
refreshToken = "", // Может отсутствовать в ответе сервера
expiresIn = 0 // Может отсутствовать в ответе сервера
)
// Сохраняем токен в локальное хранилище
tokenManager.saveAccessToken(authTokenResponse.accessToken)
tokenManager.saveTokenType(authTokenResponse.tokenType)
android.util.Log.d("AuthRepository", "Login successful, token: ${authTokenResponse.accessToken.take(15)}...")
Result.Success(authTokenResponse)
} catch (e: Exception) {
android.util.Log.e("AuthRepository", "Error processing auth response: ${e.message}", e)
Result.Error(Exception("Ошибка обработки ответа авторизации: ${e.message}"))
}
} else {
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка авторизации"
android.util.Log.e("AuthRepository", "Login error: $errorMessage (code ${response.code()})")
Result.Error(Exception("Ошибка авторизации: $errorMessage (код ${response.code()})"))
}
} catch (e: Exception) {
android.util.Log.e("AuthRepository", "Exception during login: ${e.message}", e)
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
}
}
/**
* Регистрация нового пользователя
*/
suspend fun register(
email: String,
username: String,
password: String,
firstName: String,
lastName: String,
phone: String
): Result<Boolean> {
return try {
val registerRequest = RegisterRequest(
email = email,
username = username,
password = password,
first_name = firstName,
last_name = lastName,
phone = phone
)
// Вызываем реальный API-метод register
val response = authService.register(registerRequest)
if (response.isSuccessful) {
Result.Success(true)
} else {
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка регистрации"
Result.Error(Exception("Ошибка регистрации: $errorMessage (код ${response.code()})"))
}
} catch (e: Exception) {
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
}
}
/**
* Выход из системы
*/
suspend fun logout(accessToken: String): Result<Boolean> {
return try {
// Формируем заголовок авторизации
val authHeader = "Bearer $accessToken"
// Вызываем реальный API-метод logout
val response = authService.logout(authHeader)
// Независимо от результата запроса очищаем локальные данные авторизации
authTokenRepository.clearAuthData()
tokenManager.clearTokens()
if (response.isSuccessful) {
Result.Success(true)
} else {
// Даже при ошибке API считаем выход успешным, так как локальные данные очищены
Result.Success(true)
}
} catch (e: Exception) {
// Даже при исключении считаем выход успешным, так как локальные данные очищены
Result.Success(true)
}
}
/**
* Обновление токена доступа
*/
suspend fun refreshToken(refreshToken: String): Result<TokenResponse> {
return try {
// Создаем запрос на обновление токена
val tokenRefreshRequest = TokenRefreshRequest(refresh_token = refreshToken)
// Вызываем реальный API-метод refreshToken
val response = authService.refreshToken(tokenRefreshRequest)
if (response.isSuccessful && response.body() != null) {
val tokenResponse = response.body()?.data
if (tokenResponse != null) {
Result.Success(tokenResponse)
} else {
Result.Error(Exception("Ответ сервера не содержит данных обновления токена"))
}
} else {
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка обновления токена"
Result.Error(Exception("Ошибка обновления токена: $errorMessage (код ${response.code()})"))
}
} catch (e: Exception) {
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
}
}
/**
* Получение профиля пользователя
*/
suspend fun getUserProfile(accessToken: String): Result<UserProfile> {
return try {
// Формируем заголовок авторизации
val authHeader = "Bearer $accessToken"
// Вызываем реальный API-метод получения профиля
val response = authService.getProfile(authHeader)
if (response.isSuccessful && response.body() != null) {
val userProfile = response.body()?.data
if (userProfile != null) {
Result.Success(userProfile)
} else {
Result.Error(Exception("Ответ сервера не содержит данных профиля"))
}
} else {
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка получения профиля"
Result.Error(Exception("Ошибка получения профиля: $errorMessage (код ${response.code()})"))
}
} catch (e: Exception) {
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
}
}
}

View File

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

View File

@@ -0,0 +1,119 @@
package kr.smartsoltech.wellshe.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kr.smartsoltech.wellshe.data.network.EmergencyService
import kr.smartsoltech.wellshe.model.emergency.*
import kr.smartsoltech.wellshe.util.Result
/**
* Репозиторий для работы с экстренными оповещениями
*/
class EmergencyRepository(private val emergencyService: EmergencyService) {
/**
* Создание нового экстренного оповещения
*/
suspend fun createAlert(
token: String,
latitude: Double,
longitude: Double,
message: String? = null,
batteryLevel: Int? = null,
contactIds: List<String>? = null
): Result<EmergencyAlertResponse> {
return withContext(Dispatchers.IO) {
try {
val bearerToken = "Bearer $token"
val locationData = LocationData(latitude, longitude)
val request = EmergencyAlertRequest(locationData, message, batteryLevel, contactIds)
val response = emergencyService.createAlert(bearerToken, request)
if (response.isSuccessful && response.body() != null) {
Result.Success(response.body()!!.data)
} else {
Result.Error(Exception("Ошибка создания оповещения: ${response.code()}"))
}
} catch (e: Exception) {
Result.Error(e)
}
}
}
/**
* Получение статуса экстренного оповещения
*/
suspend fun getAlertStatus(token: String, alertId: String): Result<EmergencyAlertStatus> {
return withContext(Dispatchers.IO) {
try {
val bearerToken = "Bearer $token"
val response = emergencyService.getAlertStatus(bearerToken, alertId)
if (response.isSuccessful && response.body() != null) {
Result.Success(response.body()!!.data)
} else {
Result.Error(Exception("Ошибка получения статуса оповещения: ${response.code()}"))
}
} catch (e: Exception) {
Result.Error(e)
}
}
}
/**
* Обновление местоположения в активном оповещении
*/
suspend fun updateLocation(
token: String,
alertId: String,
latitude: Double,
longitude: Double,
accuracy: Float? = null,
batteryLevel: Int? = null
): Result<LocationUpdateResponse> {
return withContext(Dispatchers.IO) {
try {
val bearerToken = "Bearer $token"
val request = LocationUpdateRequest(latitude, longitude, accuracy, batteryLevel)
val response = emergencyService.updateLocation(bearerToken, alertId, request)
if (response.isSuccessful && response.body() != null) {
Result.Success(response.body()!!.data)
} else {
Result.Error(Exception("Ошибка обновления местоположения: ${response.code()}"))
}
} catch (e: Exception) {
Result.Error(e)
}
}
}
/**
* Отмена экстренного оповещения
*/
suspend fun cancelAlert(
token: String,
alertId: String,
reason: String? = null,
details: String? = null
): Result<AlertCancelResponse> {
return withContext(Dispatchers.IO) {
try {
val bearerToken = "Bearer $token"
val request = AlertCancelRequest(reason, details)
val response = emergencyService.cancelAlert(bearerToken, alertId, request)
if (response.isSuccessful && response.body() != null) {
Result.Success(response.body()!!.data)
} else {
Result.Error(Exception("Ошибка отмены оповещения: ${response.code()}"))
}
} catch (e: Exception) {
Result.Error(e)
}
}
}
}

View File

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

View File

@@ -0,0 +1,101 @@
package kr.smartsoltech.wellshe.data.storage
import javax.inject.Inject
import javax.inject.Singleton
import java.util.Date
/**
* Класс для управления токенами авторизации
*/
@Singleton
class TokenManager @Inject constructor() {
// Токен авторизации
private var accessToken: String? = null
// Токен обновления
private var refreshToken: String? = null
// Время истечения токена
private var expiresAt: Long = 0
// Тип токена (например, "Bearer")
private var tokenType: String? = null
/**
* Сохранить токены авторизации
*/
fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Int) {
this.accessToken = accessToken
this.refreshToken = refreshToken
this.expiresAt = Date().time + (expiresIn * 1000)
}
/**
* Обновить только токен доступа
*/
fun updateAccessToken(accessToken: String, expiresIn: Int) {
this.accessToken = accessToken
this.expiresAt = Date().time + (expiresIn * 1000)
}
/**
* Сохранить токен доступа
*/
fun saveAccessToken(accessToken: String) {
this.accessToken = accessToken
}
/**
* Сохранить тип токена
*/
fun saveTokenType(tokenType: String) {
this.tokenType = tokenType
}
/**
* Получить тип токена
*/
fun getTokenType(): String? {
return tokenType
}
/**
* Очистить все токены
*/
fun clearTokens() {
accessToken = null
refreshToken = null
tokenType = null
expiresAt = 0
}
/**
* Получить токен авторизации
*/
fun getAccessToken(): String? {
return accessToken
}
/**
* Получить токен обновления
*/
fun getRefreshToken(): String? {
return refreshToken
}
/**
* Проверить, истек ли токен авторизации
*/
fun isAccessTokenExpired(): Boolean {
return Date().time > expiresAt
}
/**
* Сохранить токен авторизации (для обратной совместимости)
*/
fun saveAuthToken(token: String) {
accessToken = token
expiresAt = Date().time + (3600 * 1000) // По умолчанию 1 час
}
}

View File

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

View File

@@ -0,0 +1,77 @@
package kr.smartsoltech.wellshe.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
import kr.smartsoltech.wellshe.data.network.AuthService
import kr.smartsoltech.wellshe.data.repository.AuthRepository
import kr.smartsoltech.wellshe.data.storage.TokenManager
import kr.smartsoltech.wellshe.domain.auth.GetUserProfileUseCase
import kr.smartsoltech.wellshe.domain.auth.LoginUseCase
import kr.smartsoltech.wellshe.domain.auth.LogoutUseCase
import kr.smartsoltech.wellshe.domain.auth.RegisterUseCase
import kr.smartsoltech.wellshe.domain.auth.RefreshTokenUseCase
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AuthModule {
@Provides
@Singleton
fun provideAuthTokenRepository(@ApplicationContext context: Context): AuthTokenRepository {
return AuthTokenRepository(context)
}
@Provides
@Singleton
fun provideTokenManager(): TokenManager {
return TokenManager()
}
@Provides
@Singleton
fun provideAuthService(retrofit: Retrofit): AuthService {
return retrofit.create(AuthService::class.java)
}
@Provides
@Singleton
fun provideAuthRepository(
authService: AuthService,
authTokenRepository: AuthTokenRepository,
tokenManager: TokenManager
): AuthRepository {
return AuthRepository(authService, authTokenRepository, tokenManager)
}
@Provides
fun provideLoginUseCase(authRepository: AuthRepository, tokenManager: TokenManager): LoginUseCase {
return LoginUseCase(authRepository, tokenManager)
}
@Provides
fun provideRegisterUseCase(authRepository: AuthRepository): RegisterUseCase {
return RegisterUseCase(authRepository)
}
@Provides
fun provideLogoutUseCase(authRepository: AuthRepository, tokenManager: TokenManager): LogoutUseCase {
return LogoutUseCase(authRepository, tokenManager)
}
@Provides
fun provideGetUserProfileUseCase(authRepository: AuthRepository, tokenManager: TokenManager): GetUserProfileUseCase {
return GetUserProfileUseCase(authRepository, tokenManager)
}
@Provides
fun provideRefreshTokenUseCase(authRepository: AuthRepository, tokenManager: TokenManager): RefreshTokenUseCase {
return RefreshTokenUseCase(authRepository, tokenManager)
}
}

View File

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

View File

@@ -0,0 +1,27 @@
package kr.smartsoltech.wellshe.di
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import javax.inject.Inject
import javax.inject.Provider
/**
* Фабрика для создания ViewModel с внедрением зависимостей через Hilt
*/
class ViewModelFactory @Inject constructor(
private val creators: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val creator = creators[modelClass] ?: creators.entries.firstOrNull {
modelClass.isAssignableFrom(it.key)
}?.value ?: throw IllegalArgumentException("unknown model class $modelClass")
return try {
creator.get() as T
} catch (e: Exception) {
throw RuntimeException(e)
}
}
}

View File

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

View File

@@ -0,0 +1,106 @@
package kr.smartsoltech.wellshe.domain.auth
import kr.smartsoltech.wellshe.data.repository.AuthRepository
import kr.smartsoltech.wellshe.data.storage.TokenManager
import kr.smartsoltech.wellshe.model.auth.UserProfile
import kr.smartsoltech.wellshe.util.Result
/**
* Use case для регистрации нового пользователя
*/
class RegisterUseCase(private val authRepository: AuthRepository) {
suspend operator fun invoke(
email: String,
username: String,
password: String,
firstName: String,
lastName: String,
phone: String
): Result<Boolean> {
val result = authRepository.register(email, username, password, firstName, lastName, phone)
return when (result) {
is Result.Success -> Result.Success(true)
is Result.Error -> Result.Error(result.exception)
}
}
}
/**
* Use case для авторизации пользователя
*/
class LoginUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
suspend operator fun invoke(identifier: String, password: String, isEmail: Boolean): Result<Boolean> {
val result = authRepository.login(identifier, password, isEmail)
return when (result) {
is Result.Success -> {
val response = result.data
tokenManager.saveTokens(response.accessToken, response.refreshToken, response.expiresIn)
Result.Success(true)
}
is Result.Error -> Result.Error(result.exception)
}
}
}
/**
* Use case для выхода пользователя из системы
*/
class LogoutUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
suspend operator fun invoke(): Result<Boolean> {
val accessToken = tokenManager.getAccessToken()
if (accessToken == null) {
tokenManager.clearTokens()
return Result.Success(true)
}
val result = authRepository.logout(accessToken)
tokenManager.clearTokens() // Очищаем токены даже при ошибке запроса
return result
}
}
/**
* Use case для получения профиля пользователя
*/
class GetUserProfileUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
suspend operator fun invoke(): Result<UserProfile> {
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
return if (tokenManager.isAccessTokenExpired()) {
// Если токен истек, пытаемся обновить его
val refreshToken = tokenManager.getRefreshToken() ?: return Result.Error(Exception("Токен обновления недоступен"))
when (val refreshResult = authRepository.refreshToken(refreshToken)) {
is Result.Success -> {
tokenManager.updateAccessToken(refreshResult.data.accessToken, refreshResult.data.expiresIn)
// Получаем профиль с обновленным токеном
authRepository.getUserProfile(refreshResult.data.accessToken)
}
is Result.Error -> Result.Error(refreshResult.exception)
}
} else {
// Получаем профиль с текущим токеном
authRepository.getUserProfile(accessToken)
}
}
}
/**
* Use case для обновления токена доступа
*/
class RefreshTokenUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
suspend operator fun invoke(): Result<Boolean> {
val refreshToken = tokenManager.getRefreshToken() ?: return Result.Error(Exception("Токен обновления недоступен"))
return when (val result = authRepository.refreshToken(refreshToken)) {
is Result.Success -> {
tokenManager.updateAccessToken(result.data.accessToken, result.data.expiresIn)
Result.Success(true)
}
is Result.Error -> {
// Если ошибка обновления, то считаем, что пользователь не авторизован
tokenManager.clearTokens()
Result.Error(result.exception)
}
}
}
}

View File

@@ -0,0 +1,82 @@
package kr.smartsoltech.wellshe.domain.emergency
import kr.smartsoltech.wellshe.data.repository.EmergencyRepository
import kr.smartsoltech.wellshe.data.storage.TokenManager
import kr.smartsoltech.wellshe.model.emergency.*
import kr.smartsoltech.wellshe.util.Result
/**
* Use case для создания экстренного оповещения
*/
class CreateEmergencyAlertUseCase(
private val emergencyRepository: EmergencyRepository,
private val tokenManager: TokenManager
) {
suspend operator fun invoke(
latitude: Double,
longitude: Double,
message: String? = null,
batteryLevel: Int? = null,
contactIds: List<String>? = null
): Result<EmergencyAlertResponse> {
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
return emergencyRepository.createAlert(
accessToken, latitude, longitude, message, batteryLevel, contactIds
)
}
}
/**
* Use case для получения статуса экстренного оповещения
*/
class GetAlertStatusUseCase(
private val emergencyRepository: EmergencyRepository,
private val tokenManager: TokenManager
) {
suspend operator fun invoke(alertId: String): Result<EmergencyAlertStatus> {
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
return emergencyRepository.getAlertStatus(accessToken, alertId)
}
}
/**
* Use case для обновления местоположения при активном оповещении
*/
class UpdateLocationUseCase(
private val emergencyRepository: EmergencyRepository,
private val tokenManager: TokenManager
) {
suspend operator fun invoke(
alertId: String,
latitude: Double,
longitude: Double,
accuracy: Float? = null,
batteryLevel: Int? = null
): Result<LocationUpdateResponse> {
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
return emergencyRepository.updateLocation(
accessToken, alertId, latitude, longitude, accuracy, batteryLevel
)
}
}
/**
* Use case для отмены экстренного оповещения
*/
class CancelAlertUseCase(
private val emergencyRepository: EmergencyRepository,
private val tokenManager: TokenManager
) {
suspend operator fun invoke(
alertId: String,
reason: String? = null,
details: String? = null
): Result<AlertCancelResponse> {
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
return emergencyRepository.cancelAlert(accessToken, alertId, reason, details)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
package kr.smartsoltech.wellshe.model.auth
/**
* Модель для запроса авторизации
*/
data class AuthRequest(
val username: String? = null,
val email: String? = null,
val password: String
)
// Создаем типоним для совместимости
typealias LoginRequest = AuthRequest
/**
* Модель для запроса регистрации
*/
data class RegisterRequest(
val email: String,
val username: String,
val password: String,
val first_name: String,
val last_name: String,
val phone: String
)
/**
* Модель для запроса обновления токена
*/
data class TokenRefreshRequest(
val refresh_token: String
)
/**
* Модель для запроса выхода из системы
*/
data class LogoutRequest(
val refresh_token: String? = null
)

View File

@@ -0,0 +1,61 @@
package kr.smartsoltech.wellshe.model.auth
import com.google.gson.annotations.SerializedName
/**
* Базовая модель ответа от API
*/
sealed class ApiResponse<T>
/**
* Успешный ответ от API
*/
data class SuccessResponse<T>(
val status: String,
val data: T,
val message: String? = null
) : ApiResponse<T>()
/**
* Ответ с ошибкой от API
*/
data class ErrorResponse<T>(
val status: String,
val code: String,
val message: String,
val details: Map<String, List<String>>? = null
) : ApiResponse<T>()
/**
* Модель ответа при успешной авторизации
*/
data class AuthTokenResponse(
@SerializedName("access_token") val accessToken: String,
@SerializedName("refresh_token") val refreshToken: String,
@SerializedName("token_type") val tokenType: String,
@SerializedName("expires_in") val expiresIn: Int
)
/**
* Модель ответа при обновлении токена
*/
data class TokenRefreshResponse(
@SerializedName("access_token") val accessToken: String,
@SerializedName("token_type") val tokenType: String,
@SerializedName("expires_in") val expiresIn: Int
)
/**
* Модель ответа при регистрации
*/
data class RegisterResponse(
@SerializedName("user_id") val userId: String,
val username: String,
val email: String
)
/**
* Типонимы для обеспечения совместимости с кодом
*/
typealias AuthResponse = AuthTokenResponse
typealias TokenResponse = TokenRefreshResponse

View File

@@ -0,0 +1,11 @@
package kr.smartsoltech.wellshe.model.auth
import com.google.gson.annotations.SerializedName
/**
* Модель прямого ответа авторизации от сервера, без обертки data
*/
data class DirectAuthResponse(
@SerializedName("access_token") val accessToken: String,
@SerializedName("token_type") val tokenType: String
)

View File

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

View File

@@ -0,0 +1,39 @@
package kr.smartsoltech.wellshe.model.auth
import com.google.gson.annotations.SerializedName
/**
* Базовая обертка для ответа API
*/
open class BaseResponseWrapper(
val status: String = "success",
val message: String? = null
)
/**
* Обертка для ответа регистрации
*/
class RegisterResponseWrapper(
@SerializedName("data") val data: RegisterResponse
) : BaseResponseWrapper()
/**
* Обертка для ответа авторизации
*/
class AuthTokenResponseWrapper(
@SerializedName("data") val data: AuthTokenResponse
) : BaseResponseWrapper()
/**
* Обертка для ответа обновления токена
*/
class TokenRefreshResponseWrapper(
@SerializedName("data") val data: TokenRefreshResponse
) : BaseResponseWrapper()
/**
* Обертка для ответа с профилем пользователя
*/
class UserProfileResponseWrapper(
@SerializedName("data") val data: UserProfile
) : BaseResponseWrapper()

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
package kr.smartsoltech.wellshe.model.emergency
import com.google.gson.annotations.SerializedName
/**
* Модель для запроса создания экстренного оповещения
*/
data class EmergencyAlertRequest(
val location: LocationData,
val message: String? = null,
@SerializedName("battery_level") val batteryLevel: Int? = null,
@SerializedName("contact_ids") val contactIds: List<String>? = null
)
/**
* Модель данных о местоположении
*/
data class LocationData(
val latitude: Double,
val longitude: Double,
val accuracy: Float? = null
)
/**
* Модель для запроса обновления местоположения в активном оповещении
*/
data class LocationUpdateRequest(
val latitude: Double,
val longitude: Double,
val accuracy: Float? = null,
@SerializedName("battery_level") val batteryLevel: Int? = null
)
/**
* Модель для запроса отмены экстренного оповещения
*/
data class AlertCancelRequest(
val reason: String? = null,
val details: String? = null
)

View File

@@ -0,0 +1,60 @@
package kr.smartsoltech.wellshe.model.emergency
import com.google.gson.annotations.SerializedName
/**
* Модель ответа при создании экстренного оповещения
*/
data class EmergencyAlertResponse(
@SerializedName("alert_id") val alertId: String,
@SerializedName("created_at") val createdAt: String,
val status: String,
val message: String
)
/**
* Модель контакта с информацией о статусе уведомления
*/
data class NotifiedContact(
@SerializedName("contact_id") val contactId: String,
val status: String,
@SerializedName("notified_at") val notifiedAt: String?
)
/**
* Модель информации о местоположении с датой обновления
*/
data class LocationStatus(
val latitude: Double,
val longitude: Double,
@SerializedName("updated_at") val updatedAt: String
)
/**
* Модель детального статуса экстренного оповещения
*/
data class EmergencyAlertStatus(
@SerializedName("alert_id") val alertId: String,
@SerializedName("created_at") val createdAt: String,
val status: String,
val location: LocationStatus,
@SerializedName("notified_contacts") val notifiedContacts: List<NotifiedContact>,
@SerializedName("emergency_services_notified") val emergencyServicesNotified: Boolean,
@SerializedName("emergency_services_notified_at") val emergencyServicesNotifiedAt: String?
)
/**
* Модель ответа при обновлении местоположения
*/
data class LocationUpdateResponse(
@SerializedName("updated_at") val updatedAt: String
)
/**
* Модель ответа при отмене экстренного оповещения
*/
data class AlertCancelResponse(
@SerializedName("alert_id") val alertId: String,
@SerializedName("cancelled_at") val cancelledAt: String,
val status: String
)

View File

@@ -0,0 +1,32 @@
package kr.smartsoltech.wellshe.model.emergency
import com.google.gson.annotations.SerializedName
import kr.smartsoltech.wellshe.model.auth.BaseResponseWrapper
/**
* Обертка для ответа при создании экстренного оповещения
*/
class EmergencyAlertResponseWrapper(
@SerializedName("data") val data: EmergencyAlertResponse
) : BaseResponseWrapper()
/**
* Обертка для ответа при получении статуса экстренного оповещения
*/
class EmergencyAlertStatusWrapper(
@SerializedName("data") val data: EmergencyAlertStatus
) : BaseResponseWrapper()
/**
* Обертка для ответа при обновлении местоположения
*/
class LocationUpdateResponseWrapper(
@SerializedName("data") val data: LocationUpdateResponse
) : BaseResponseWrapper()
/**
* Обертка для ответа при отмене экстренного оповещения
*/
class AlertCancelResponseWrapper(
@SerializedName("data") val data: AlertCancelResponse
) : BaseResponseWrapper()

View File

@@ -0,0 +1,206 @@
package kr.smartsoltech.wellshe.ui.auth
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
import kr.smartsoltech.wellshe.domain.auth.GetUserProfileUseCase
import kr.smartsoltech.wellshe.domain.auth.LoginUseCase
import kr.smartsoltech.wellshe.domain.auth.LogoutUseCase
import kr.smartsoltech.wellshe.domain.auth.RegisterUseCase
import kr.smartsoltech.wellshe.model.auth.AuthTokenResponseWrapper
import kr.smartsoltech.wellshe.model.auth.UserProfile
import kr.smartsoltech.wellshe.util.Result
import javax.inject.Inject
/**
* ViewModel для управления авторизацией и профилем пользователя
*/
@HiltViewModel
class AuthViewModel @Inject constructor(
private val loginUseCase: LoginUseCase,
private val registerUseCase: RegisterUseCase,
private val logoutUseCase: LogoutUseCase,
private val getUserProfileUseCase: GetUserProfileUseCase,
private val authTokenRepository: AuthTokenRepository
) : ViewModel() {
private val _authState = MutableLiveData<AuthState>()
val authState: LiveData<AuthState> = _authState
private val _userProfile = MutableLiveData<UserProfile?>()
val userProfile: LiveData<UserProfile?> = _userProfile
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean> = _isLoading
init {
// Проверяем наличие сохраненных данных для автоматического входа
checkSavedCredentials()
}
/**
* Проверка и использование сохраненных учетных данных для автоматического входа
*/
private fun checkSavedCredentials() {
viewModelScope.launch {
try {
val hasAuthData = authTokenRepository.hasAuthData.first()
if (hasAuthData) {
val email = authTokenRepository.savedEmail.first() ?: return@launch
val password = authTokenRepository.savedPassword.first() ?: return@launch
login(email, password, true)
} else {
_authState.value = AuthState.NotAuthenticated
}
} catch (e: Exception) {
_authState.value = AuthState.NotAuthenticated
}
}
}
/**
* Вход в систему с помощью email или username
*/
fun login(identifier: String, password: String, isEmail: Boolean) {
_isLoading.value = true
_authState.value = AuthState.Authenticating
viewModelScope.launch {
try {
Log.d("AuthViewModel", "Starting login process: $identifier, isEmail=$isEmail")
when (val result = loginUseCase(identifier, password, isEmail)) {
is Result.Success -> {
// Получаем данные авторизации из ответа
val authData = result.data
Log.d("AuthViewModel", "Login Success: received data of type ${authData?.javaClass?.simpleName}")
try {
// Используем более безопасный подход без рефлексии
if (authData != null) {
val dataJson = authData.toString()
Log.d("AuthViewModel", "Auth data toString: $dataJson")
// Устанавливаем состояние авторизации как успешное
_authState.value = AuthState.Authenticated
// Сохраняем учетные данные для автологина
authTokenRepository.saveAuthCredentials(identifier, password)
// Временно используем фиксированный токен (можно заменить на реальный, когда будет понятна структура данных)
val tempToken = "temp_token_for_$identifier"
authTokenRepository.saveAuthToken(tempToken)
// Загружаем профиль после успешной авторизации
fetchUserProfile()
} else {
Log.e("AuthViewModel", "Auth data is null")
_authState.value = AuthState.AuthError("Получены пустые данные авторизации")
}
} catch (e: Exception) {
Log.e("AuthViewModel", "Error processing login response: ${e.message}", e)
_authState.value = AuthState.AuthError("Ошибка обработки ответа: ${e.message}")
}
}
is Result.Error -> {
Log.e("AuthViewModel", "Login Error: ${result.exception.message}")
_authState.value = AuthState.AuthError(result.exception.message ?: "Ошибка авторизации")
}
}
} catch (e: Exception) {
Log.e("AuthViewModel", "Unhandled exception in login: ${e.message}", e)
_authState.value = AuthState.AuthError("Непредвиденная ошибка: ${e.message}")
} finally {
_isLoading.value = false
}
}
}
/**
* Регистрация нового пользователя
*/
fun register(email: String, username: String, password: String, firstName: String, lastName: String, phone: String) {
_isLoading.value = true
_authState.value = AuthState.Registering
viewModelScope.launch {
when (val result = registerUseCase(email, username, password, firstName, lastName, phone)) {
is Result.Success -> {
_authState.value = AuthState.RegistrationSuccess
}
is Result.Error -> {
_authState.value = AuthState.RegistrationError(result.exception.message ?: "Ошибка регистрации")
}
}
_isLoading.value = false
}
}
/**
* Выход из системы
*/
fun logout() {
_isLoading.value = true
viewModelScope.launch {
when (val result = logoutUseCase()) {
is Result.Success -> {
// Очищаем сохраненные данные авторизации
authTokenRepository.clearAuthData()
_authState.value = AuthState.NotAuthenticated
_userProfile.value = null
}
is Result.Error -> {
// Даже при ошибке API сессия на устройстве будет завершена
authTokenRepository.clearAuthData()
_authState.value = AuthState.NotAuthenticated
_userProfile.value = null
}
}
_isLoading.value = false
}
}
/**
* Загрузка профиля пользователя
*/
fun fetchUserProfile() {
_isLoading.value = true
viewModelScope.launch {
when (val result = getUserProfileUseCase()) {
is Result.Success -> {
_userProfile.value = result.data
}
is Result.Error -> {
// Ошибка может означать, что токен недействителен
if (result.exception.message?.contains("авторизован") == true) {
// Очищаем недействительный токен
authTokenRepository.clearAuthData()
_authState.value = AuthState.NotAuthenticated
}
}
}
_isLoading.value = false
}
}
/**
* Состояния процесса авторизации
*/
sealed class AuthState {
object NotAuthenticated : AuthState()
object Authenticating : AuthState()
object Authenticated : AuthState()
object Registering : AuthState()
object RegistrationSuccess : AuthState()
data class AuthError(val message: String) : AuthState()
data class RegistrationError(val message: String) : AuthState()
}
}

View File

@@ -0,0 +1,122 @@
package kr.smartsoltech.wellshe.ui.auth
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import kr.smartsoltech.wellshe.R
import kr.smartsoltech.wellshe.databinding.FragmentLoginBinding
import kr.smartsoltech.wellshe.di.ViewModelFactory
import kr.smartsoltech.wellshe.util.isValidEmail
import javax.inject.Inject
/**
* Фрагмент для входа пользователя в систему
*/
class LoginFragment : Fragment() {
private var _binding: FragmentLoginBinding? = null
private val binding get() = _binding!!
@Inject
lateinit var viewModelFactory: ViewModelFactory
private lateinit var viewModel: AuthViewModel
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentLoginBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Получаем ViewModel через DI
viewModel = ViewModelProvider(requireActivity(), viewModelFactory)[AuthViewModel::class.java]
setupListeners()
observeViewModel()
}
private fun setupListeners() {
// Валидация ввода в реальном времени
binding.etEmailUsername.addTextChangedListener {
validateForm()
}
binding.etPassword.addTextChangedListener {
validateForm()
}
// Нажатие на кнопку входа
binding.btnLogin.setOnClickListener {
val identifier = binding.etEmailUsername.text.toString()
val password = binding.etPassword.text.toString()
// Определяем, это email или username
val isEmail = identifier.isValidEmail()
viewModel.login(identifier, password, isEmail)
}
// Переход на экран регистрации
binding.btnRegister.setOnClickListener {
findNavController().navigate(R.id.action_loginFragment_to_registerFragment)
}
}
private fun observeViewModel() {
// Наблюдаем за состоянием авторизации
viewModel.authState.observe(viewLifecycleOwner) { state ->
when (state) {
is AuthViewModel.AuthState.Authenticating -> {
// Показываем индикатор загрузки
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
}
is AuthViewModel.AuthState.Authenticated -> {
// Переходим на главный экран
findNavController().navigate(R.id.action_loginFragment_to_mainFragment)
}
is AuthViewModel.AuthState.AuthError -> {
// Показываем ошибку
Toast.makeText(requireContext(), state.message, Toast.LENGTH_LONG).show()
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
else -> {
// Сбрасываем состояние UI
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
}
}
// Наблюдаем за состоянием загрузки
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
binding.btnLogin.isEnabled = !isLoading
}
}
private fun validateForm() {
val identifier = binding.etEmailUsername.text.toString()
val password = binding.etPassword.text.toString()
binding.btnLogin.isEnabled = identifier.isNotEmpty() && password.length >= 8
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,207 @@
package kr.smartsoltech.wellshe.ui.auth
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import kr.smartsoltech.wellshe.R
import kr.smartsoltech.wellshe.databinding.FragmentRegisterBinding
import kr.smartsoltech.wellshe.di.ViewModelFactory
import kr.smartsoltech.wellshe.util.isValidEmail
import kr.smartsoltech.wellshe.util.isValidPassword
import kr.smartsoltech.wellshe.util.isValidPhone
import javax.inject.Inject
/**
* Фрагмент для регистрации нового пользователя
*/
class RegisterFragment : Fragment() {
private var _binding: FragmentRegisterBinding? = null
private val binding get() = _binding!!
@Inject
lateinit var viewModelFactory: ViewModelFactory
private lateinit var viewModel: AuthViewModel
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentRegisterBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Получаем ViewModel через DI
viewModel = ViewModelProvider(requireActivity(), viewModelFactory)[AuthViewModel::class.java]
setupListeners()
observeViewModel()
}
private fun setupListeners() {
// Добавляем валидацию в реальном времени для всех полей формы
binding.etEmail.addTextChangedListener { validateForm() }
binding.etUsername.addTextChangedListener { validateForm() }
binding.etPassword.addTextChangedListener { validateForm() }
binding.etConfirmPassword.addTextChangedListener { validateForm() }
binding.etFirstName.addTextChangedListener { validateForm() }
binding.etLastName.addTextChangedListener { validateForm() }
binding.etPhone.addTextChangedListener { validateForm() }
// Обработка нажатия на кнопку регистрации
binding.btnRegister.setOnClickListener {
if (isFormValid()) {
register()
}
}
// Переход на экран входа
binding.btnBackToLogin.setOnClickListener {
findNavController().popBackStack()
}
}
private fun register() {
val email = binding.etEmail.text?.toString() ?: ""
val username = binding.etUsername.text?.toString() ?: ""
val password = binding.etPassword.text?.toString() ?: ""
val firstName = binding.etFirstName.text?.toString() ?: ""
val lastName = binding.etLastName.text?.toString() ?: ""
val phone = binding.etPhone.text?.toString() ?: ""
viewModel.register(email, username, password, firstName, lastName, phone)
}
private fun observeViewModel() {
// Наблюдаем за состоянием регистрации
viewModel.authState.observe(viewLifecycleOwner) { state ->
when (state) {
is AuthViewModel.AuthState.Registering -> {
binding.progressBar.visibility = View.VISIBLE
binding.btnRegister.isEnabled = false
}
is AuthViewModel.AuthState.RegistrationSuccess -> {
Toast.makeText(
requireContext(),
"Регистрация успешна. Пожалуйста, войдите в систему.",
Toast.LENGTH_LONG
).show()
findNavController().popBackStack()
}
is AuthViewModel.AuthState.RegistrationError -> {
Toast.makeText(requireContext(), state.message, Toast.LENGTH_LONG).show()
binding.progressBar.visibility = View.GONE
binding.btnRegister.isEnabled = true
}
else -> {
binding.progressBar.visibility = View.GONE
binding.btnRegister.isEnabled = true
}
}
}
// Наблюдаем за состоянием загрузки
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
binding.btnRegister.isEnabled = !isLoading
}
}
private fun isFormValid(): Boolean {
val email = binding.etEmail.text?.toString() ?: ""
val username = binding.etUsername.text?.toString() ?: ""
val password = binding.etPassword.text?.toString() ?: ""
val confirmPassword = binding.etConfirmPassword.text?.toString() ?: ""
val firstName = binding.etFirstName.text?.toString() ?: ""
val lastName = binding.etLastName.text?.toString() ?: ""
val phone = binding.etPhone.text?.toString() ?: ""
var isValid = true
// Проверка email
if (!email.isValidEmail()) {
binding.tilEmail.error = "Введите корректный email"
isValid = false
} else {
binding.tilEmail.error = null
}
// Проверка имени пользователя
if (username.length < 3) {
binding.tilUsername.error = "Имя пользователя должно быть не менее 3 символов"
isValid = false
} else {
binding.tilUsername.error = null
}
// Проверка пароля
if (!password.isValidPassword()) {
binding.tilPassword.error = "Пароль должен содержать не менее 8 символов, включая цифру, заглавную букву и специальный символ"
isValid = false
} else {
binding.tilPassword.error = null
}
// Проверка совпадения паролей
if (password != confirmPassword) {
binding.tilConfirmPassword.error = "Пароли не совпадают"
isValid = false
} else {
binding.tilConfirmPassword.error = null
}
// Проверка имени и фамилии
if (firstName.isEmpty()) {
binding.tilFirstName.error = "Введите имя"
isValid = false
} else {
binding.tilFirstName.error = null
}
if (lastName.isEmpty()) {
binding.tilLastName.error = "Введите фамилию"
isValid = false
} else {
binding.tilLastName.error = null
}
// Проверка телефона
if (!phone.isValidPhone()) {
binding.tilPhone.error = "Введите корректный номер телефона (международный формат)"
isValid = false
} else {
binding.tilPhone.error = null
}
return isValid
}
private fun validateForm() {
// Простая проверка для активации/деактивации кнопки
val allFieldsFilled = binding.etEmail.text?.isNotEmpty() == true &&
binding.etUsername.text?.isNotEmpty() == true &&
binding.etPassword.text?.isNotEmpty() == true &&
binding.etConfirmPassword.text?.isNotEmpty() == true &&
binding.etFirstName.text?.isNotEmpty() == true &&
binding.etLastName.text?.isNotEmpty() == true &&
binding.etPhone.text?.isNotEmpty() == true
binding.btnRegister.isEnabled = allFieldsFilled
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,159 @@
package kr.smartsoltech.wellshe.ui.auth.compose
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.livedata.observeAsState
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
onNavigateToRegister: () -> Unit,
onLoginSuccess: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
val context = LocalContext.current
val authState by viewModel.authState.observeAsState()
val isLoading by viewModel.isLoading.observeAsState()
val keyboardController = LocalSoftwareKeyboardController.current
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var isFormValid by remember { mutableStateOf(false) }
// FocusRequester для переключения фокуса между полями
val passwordFocusRequester = remember { FocusRequester() }
LaunchedEffect(username, password) {
isFormValid = username.isNotEmpty() && password.isNotEmpty()
}
LaunchedEffect(authState) {
if (authState is AuthViewModel.AuthState.Authenticated) {
onLoginSuccess()
}
}
// Функция для выполнения входа
val performLogin = {
if (isFormValid && isLoading != true) {
keyboardController?.hide()
// Передаем false в качестве параметра isEmail, так как мы используем username
viewModel.login(username, password, false)
}
}
Scaffold { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "WellShe",
style = MaterialTheme.typography.headlineLarge
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Имя пользователя") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { passwordFocusRequester.requestFocus() }
)
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Пароль") },
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocusRequester),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
performLogin()
}
),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = if (passwordVisible) "Скрыть пароль" else "Показать пароль"
)
}
}
)
Button(
onClick = { performLogin() },
enabled = isFormValid && isLoading != true,
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
) {
if (isLoading == true) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Войти")
}
}
TextButton(onClick = onNavigateToRegister) {
Text("Создать новый аккаунт")
}
if (authState is AuthViewModel.AuthState.AuthError) {
Text(
text = (authState as AuthViewModel.AuthState.AuthError).message,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}

View File

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

View File

@@ -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("Отмена")
}
}
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 и обработчиков событий
}
}

View 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("Настройки")
}
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("Выход из аккаунта")
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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}\$"))
}

View File

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

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