Compare commits

11 Commits

239 changed files with 37265 additions and 4639 deletions

123
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-10-18T10:08:22.623831996Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=R3CT80VPBQZ" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

1
.idea/gradle.xml generated
View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>

1
.idea/misc.xml generated
View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -17,6 +17,34 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Добавляем путь для экспорта схемы Room
javaCompileOptions {
annotationProcessorOptions {
arguments += mapOf(
"room.schemaLocation" to "$projectDir/schemas",
"room.incremental" to "true"
)
}
}
buildConfigField("String", "API_BASE_URL", "\"${project.findProperty("API_BASE_URL")}\"")
// Add backend/port buildConfig fields derived from gradle.properties (with safe defaults)
buildConfigField("String", "BACKEND_HOST", "\"${project.findProperty("BASE_URL") ?: "10.0.2.2"}\"")
buildConfigField("String", "API_PORT", "\"${project.findProperty("API_PORT") ?: "8002"}\"")
buildConfigField("String", "WS_PORT", "\"${project.findProperty("WS_PORT") ?: "8003"}\"")
buildConfigField(
"String",
"EMERGENCY_API_BASE",
"\"http://${project.findProperty("BASE_URL") ?: "10.0.2.2"}:${project.findProperty("API_PORT") ?: "8002"}/\""
)
buildConfigField(
"String",
"EMERGENCY_WS_BASE",
"\"ws://${project.findProperty("BASE_URL") ?: "10.0.2.2"}:${project.findProperty("WS_PORT") ?: "8002"}/api/v1/\""
)
}
buildTypes {
@@ -37,6 +65,8 @@ android {
}
buildFeatures {
compose = true
viewBinding = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
@@ -54,6 +84,7 @@ dependencies {
implementation(libs.androidx.compose.material3)
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
implementation(libs.hilt.android)
implementation(libs.material)
kapt(libs.hilt.compiler)
implementation("androidx.room:room-runtime:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
@@ -67,9 +98,32 @@ dependencies {
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("com.google.code.gson:gson:2.10.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
implementation("com.squareup.moshi:moshi:1.15.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
implementation("com.squareup.moshi:moshi-adapters:1.15.0")
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")
// Emergency Module dependencies
implementation("com.google.android.gms:play-services-location:21.0.1")
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("com.google.android.gms:play-services-maps:18.2.0")
// Testing dependencies
testImplementation(libs.junit)
testImplementation("io.mockk:mockk:1.13.8")
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
package kr.smartsoltech.wellshe.emergency.debug
import android.content.Context
// Debug implementation delegates to ProperAuthTester
object AuthTester {
suspend fun runFullTest(context: Context): Boolean {
return ProperAuthTester.runFullTest(context)
}
}

View File

@@ -0,0 +1,182 @@
package kr.smartsoltech.wellshe.emergency.debug
import android.content.Context
import android.util.Base64
import android.util.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONObject
import kr.smartsoltech.wellshe.BuildConfig
import java.nio.charset.Charset
import java.util.concurrent.TimeUnit
import okhttp3.logging.HttpLoggingInterceptor
object ProperAuthTester {
private const val TAG = "ProperAuthTester"
private val client = OkHttpClient.Builder()
.readTimeout(10, TimeUnit.SECONDS)
.addInterceptor(
HttpLoggingInterceptor { message -> Log.d(TAG, message) }
.apply { level = HttpLoggingInterceptor.Level.BODY }
)
.build()
suspend fun loginAndGetToken(): String? = withContext(Dispatchers.IO) {
try {
val loginUrl = BuildConfig.API_BASE_URL + "auth/login"
Log.d(TAG, "Login URL: $loginUrl")
val json = JSONObject()
json.put("email", "shadow85@list.ru")
json.put("password", "R0sebud1985")
val mediaType = "application/json; charset=utf-8".toMediaType()
val body = json.toString().toRequestBody(mediaType)
val request = Request.Builder()
.url(loginUrl)
.post(body)
.build()
client.newCall(request).execute().use { resp ->
val code = resp.code
val text = resp.body?.string()
Log.d(TAG, "Auth response code=$code body=$text")
if (code == 200 && !text.isNullOrEmpty()) {
val obj = JSONObject(text)
// попытка извлечь access_token или accessToken
val token = when {
obj.has("access_token") -> obj.getString("access_token")
obj.has("accessToken") -> obj.getString("accessToken")
obj.has("token") -> obj.getString("token")
else -> null
}
if (!token.isNullOrEmpty()) {
Log.i(TAG, "Obtained token: ${token.take(50)}...")
return@withContext token
}
}
Log.w(TAG, "Failed to obtain token: code=$code body=$text")
return@withContext null
}
} catch (e: Exception) {
Log.e(TAG, "Error during login: ${e.message}", e)
return@withContext null
}
}
fun decodeJwtPayload(token: String): JSONObject? {
return try {
val parts = token.split('.')
if (parts.size < 2) return null
val payload = parts[1]
val decoded = Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
val json = String(decoded, Charset.forName("UTF-8"))
JSONObject(json)
} catch (e: Exception) {
Log.e(TAG, "Failed to decode JWT: ${e.message}", e)
null
}
}
suspend fun testWebSocketWithJwtToken(token: String): Boolean = withContext(Dispatchers.IO) {
try {
// Build WS URL
// BuildConfig.EMERGENCY_WS_BASE already contains ws://host:port/api/v1/
val wsUrl = BuildConfig.EMERGENCY_WS_BASE + "emergency/ws/current_user_id?token=$token"
Log.d(TAG, "WS URL: $wsUrl")
val request = Request.Builder()
.url(wsUrl)
.build()
// Инициализируем deferred без неверных параметров
val deferredMsg = CompletableDeferred<String?>()
val opened = CompletableDeferred<Boolean>()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
Log.i(TAG, "WebSocket opened: ${response.code}")
// безопасно complete true
if (!opened.isCompleted) opened.complete(true)
// send ping-like message
val ping = JSONObject().apply {
put("type", "ping")
put("message", "Hello from Android debug tester")
}
webSocket.send(ping.toString())
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.i(TAG, "WS message: $text")
if (!deferredMsg.isCompleted) deferredMsg.complete(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.w(TAG, "WS closing: $code / $reason")
webSocket.close(1000, null)
if (!deferredMsg.isCompleted) deferredMsg.complete(null)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
Log.e(TAG, "WS failure: ${t.message}", t)
if (!deferredMsg.isCompleted) deferredMsg.completeExceptionally(t)
}
}
val ws = client.newWebSocket(request, listener)
// wait for open or timeout
try {
opened.await()
} catch (e: Exception) {
Log.e(TAG, "WebSocket did not open: ${e.message}")
ws.cancel()
return@withContext false
}
// wait for incoming message (short timeout)
return@withContext try {
val msg = kotlinx.coroutines.withTimeoutOrNull(5000) { deferredMsg.await() }
Log.d(TAG, "Received WS reply: $msg")
ws.close(1000, "done")
msg != null
} catch (e: Exception) {
Log.e(TAG, "Error waiting for WS message: ${e.message}", e)
ws.cancel()
false
}
} catch (e: Exception) {
Log.e(TAG, "Error connecting to WS: ${e.message}", e)
false
}
}
suspend fun runFullTest(context: Context): Boolean = withContext(Dispatchers.IO) {
Log.i(TAG, "Starting full proper-auth test")
val token = loginAndGetToken()
if (token.isNullOrEmpty()) {
Log.e(TAG, "No token obtained — aborting test")
return@withContext false
}
// decode payload for inspection
val payload = decodeJwtPayload(token)
if (payload != null) {
Log.i(TAG, "Token payload: $payload")
} else {
Log.w(TAG, "Could not decode token payload")
}
val wsResult = testWebSocketWithJwtToken(token)
Log.i(TAG, "WebSocket test result: $wsResult")
wsResult
}
}

View File

@@ -15,6 +15,15 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Разрешения для геолокации (добавлены: без них системный диалог не покажется) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Background location требует отдельной обработки на Android 10+; добавить при необходимости -->
<!-- <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> -->
<!-- Разрешение для совершения экстренных звонков -->
<uses-permission android:name="android.permission.CALL_PHONE" />
<application
android:name=".WellSheApplication"
android:allowBackup="true"
@@ -24,6 +33,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.WellShe">
<activity
@@ -49,6 +59,17 @@
android:value="androidx.startup" />
</provider>
<!-- BroadcastReceiver для обработки действий из уведомлений -->
<receiver
android:name=".emergency.utils.EmergencyActionReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="HELP_ACTION" />
<action android:name="CALL_POLICE_ACTION" />
</intent-filter>
</receiver>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -1,22 +1,69 @@
package kr.smartsoltech.wellshe
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
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 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
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
WellSheTheme {
WellSheNavigation()
try {
setContent {
WellSheTheme {
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

@@ -2,12 +2,31 @@ package kr.smartsoltech.wellshe
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.android.EntryPointAccessors
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@HiltAndroidApp
class WellSheApplication : Application() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface EmergencyRepositoryEntryPoint {
fun emergencyRepository(): EmergencyRepository
}
val emergencyRepository: EmergencyRepository by lazy {
val hiltEntryPoint = EntryPointAccessors.fromApplication(
this,
EmergencyRepositoryEntryPoint::class.java
)
hiltEntryPoint.emergencyRepository()
}
override fun onCreate() {
super.onCreate()
// TODO: Initialize app components when repositories are ready
// Hilt will inject dependencies after onCreate
}
}

View File

@@ -5,30 +5,105 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import kr.smartsoltech.wellshe.data.entity.*
import kr.smartsoltech.wellshe.data.dao.*
import kr.smartsoltech.wellshe.data.converter.DateConverters
import java.time.LocalDate
import androidx.room.TypeConverter
@Database(
entities = [
// Основные сущности
WaterLogEntity::class,
WorkoutEntity::class,
SleepLogEntity::class,
CyclePeriodEntity::class,
HealthRecordEntity::class,
CalorieEntity::class,
StepsEntity::class,
UserProfileEntity::class
UserProfileEntity::class,
WorkoutSession::class,
WorkoutSessionParam::class,
WorkoutEvent::class,
CyclePeriodEntity::class,
HealthRecordEntity::class,
// Новые сущности модуля "Настройки цикла"
CycleSettingsEntity::class,
CycleHistoryEntity::class,
CycleForecastEntity::class,
// Дополнительные сущности из BodyEntities.kt
Nutrient::class,
Beverage::class,
BeverageServing::class,
BeverageServingNutrient::class,
WaterLog::class,
BeverageLog::class,
BeverageLogNutrient::class,
WeightLog::class,
Exercise::class,
ExerciseParam::class,
ExerciseFormula::class,
ExerciseFormulaVar::class,
CatalogVersion::class,
// Emergency Module entities
kr.smartsoltech.wellshe.emergency.data.entities.EmergencyEventEntity::class,
kr.smartsoltech.wellshe.emergency.data.entities.EmergencyResponseEntity::class
],
version = 1,
exportSchema = false
version = 14, // Увеличиваем версию для Emergency Module
exportSchema = true
)
@TypeConverters(DateConverters::class)
@TypeConverters(LocalDateConverter::class, InstantConverter::class, StringListConverter::class, EmergencyTypeConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun waterLogDao(): WaterLogDao
abstract fun workoutDao(): WorkoutDao
abstract fun sleepLogDao(): SleepLogDao
abstract fun cyclePeriodDao(): CyclePeriodDao
abstract fun healthRecordDao(): HealthRecordDao
abstract fun calorieDao(): CalorieDao
abstract fun stepsDao(): StepsDao
abstract fun userProfileDao(): UserProfileDao
abstract fun cyclePeriodDao(): CyclePeriodDao
abstract fun healthRecordDao(): HealthRecordDao
// Новые DAO для модуля "Настройки цикла"
abstract fun cycleSettingsDao(): CycleSettingsDao
abstract fun cycleHistoryDao(): CycleHistoryDao
abstract fun cycleForecastDao(): CycleForecastDao
// Дополнительные DAO для repo
abstract fun beverageDao(): BeverageDao
abstract fun beverageServingDao(): BeverageServingDao
abstract fun beverageLogDao(): BeverageLogDao
abstract fun beverageLogNutrientDao(): BeverageLogNutrientDao
abstract fun beverageServingNutrientDao(): BeverageServingNutrientDao
abstract fun weightLogDao(): WeightLogDao
abstract fun workoutSessionDao(): WorkoutSessionDao
abstract fun workoutSessionParamDao(): WorkoutSessionParamDao
abstract fun workoutEventDao(): WorkoutEventDao
abstract fun exerciseDao(): ExerciseDao
abstract fun exerciseParamDao(): ExerciseParamDao
abstract fun exerciseFormulaDao(): ExerciseFormulaDao
abstract fun exerciseFormulaVarDao(): ExerciseFormulaVarDao
abstract fun nutrientDao(): NutrientDao
abstract fun catalogVersionDao(): CatalogVersionDao
// Emergency Module DAO
abstract fun emergencyDao(): kr.smartsoltech.wellshe.emergency.data.dao.EmergencyDao
}
class LocalDateConverter {
@TypeConverter
fun fromTimestamp(value: Long?): LocalDate? = value?.let { java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault()).toLocalDate() }
@TypeConverter
fun dateToTimestamp(date: LocalDate?): Long? = date?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
}
class EmergencyTypeConverter {
@TypeConverter
fun fromEmergencyType(type: kr.smartsoltech.wellshe.emergency.data.models.EmergencyType): String = type.name
@TypeConverter
fun toEmergencyType(name: String): kr.smartsoltech.wellshe.emergency.data.models.EmergencyType =
kr.smartsoltech.wellshe.emergency.data.models.EmergencyType.valueOf(name)
@TypeConverter
fun fromEmergencyStatus(status: kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus): String = status.name
@TypeConverter
fun toEmergencyStatus(name: String): kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus =
kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus.valueOf(name)
}

View File

@@ -0,0 +1,21 @@
package kr.smartsoltech.wellshe.data
import androidx.room.TypeConverter
import java.time.Instant
class InstantConverter {
@TypeConverter
fun fromTimestamp(value: Long?): Instant? = value?.let { Instant.ofEpochMilli(it) }
@TypeConverter
fun instantToTimestamp(instant: Instant?): Long? = instant?.toEpochMilli()
}
class StringListConverter {
@TypeConverter
fun fromString(value: String?): List<String>? = value?.let {
if (it.isEmpty()) emptyList() else it.split("||")
}
@TypeConverter
fun listToString(list: List<String>?): String = list?.joinToString("||") ?: ""
}

View File

@@ -0,0 +1,776 @@
package kr.smartsoltech.wellshe.data
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* Миграция базы данных с версии 1 на версию 2.
* Добавляет таблицы для модуля "Настройки цикла".
*/
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создание таблицы cycle_settings
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `cycle_settings` (
`id` INTEGER NOT NULL,
`baselineCycleLength` INTEGER NOT NULL,
`cycleVariabilityDays` INTEGER NOT NULL,
`periodLengthDays` INTEGER NOT NULL,
`lutealPhaseDays` TEXT NOT NULL,
`lastPeriodStart` INTEGER,
`ovulationMethod` TEXT NOT NULL,
`allowManualOvulation` INTEGER NOT NULL,
`hormonalContraception` TEXT NOT NULL,
`isPregnant` INTEGER NOT NULL,
`isPostpartum` INTEGER NOT NULL,
`isLactating` INTEGER NOT NULL,
`perimenopause` INTEGER NOT NULL,
`historyWindowCycles` INTEGER NOT NULL,
`excludeOutliers` INTEGER NOT NULL,
`tempUnit` TEXT NOT NULL,
`bbtTimeWindow` TEXT NOT NULL,
`timezone` TEXT NOT NULL,
`periodReminderDaysBefore` INTEGER NOT NULL,
`ovulationReminderDaysBefore` INTEGER NOT NULL,
`pmsWindowDays` INTEGER NOT NULL,
`deviationAlertDays` INTEGER NOT NULL,
`fertileWindowMode` TEXT NOT NULL,
PRIMARY KEY(`id`)
)
""".trimIndent()
)
// Создание таблицы cycle_history
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `cycle_history` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`periodStart` INTEGER NOT NULL,
`periodEnd` INTEGER,
`ovulationDate` INTEGER,
`notes` TEXT NOT NULL,
`atypical` INTEGER NOT NULL,
`flow` TEXT NOT NULL DEFAULT '',
`symptoms` TEXT NOT NULL DEFAULT '',
`mood` TEXT NOT NULL DEFAULT '',
`cycleLength` INTEGER
)
""".trimIndent()
)
// Индекс для cycle_history по периоду начала
database.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS `index_cycle_history_periodStart` ON `cycle_history` (`periodStart`)"
)
// Создание таблицы cycle_forecast
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `cycle_forecast` (
`id` INTEGER NOT NULL,
`nextPeriodStart` INTEGER,
`nextOvulation` INTEGER,
`fertileStart` INTEGER,
`fertileEnd` INTEGER,
`pmsStart` INTEGER,
`updatedAt` INTEGER NOT NULL,
`isReliable` INTEGER NOT NULL,
PRIMARY KEY(`id`)
)
""".trimIndent()
)
// Импорт существующих данных из таблицы cycle_periods в cycle_history
database.execSQL(
"""
INSERT OR IGNORE INTO cycle_history (periodStart, periodEnd, notes, atypical)
SELECT startDate, endDate,
CASE WHEN flow != '' OR mood != '' OR symptoms != ''
THEN 'Flow: ' || flow || ', Mood: ' || mood
ELSE ''
END,
0
FROM cycle_periods
""".trimIndent()
)
}
}
/**
* Миграция базы данных с версии 2 на версию 3.
* Исправляет проблему с типом данных столбца date в таблице water_logs.
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `water_logs_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`date` INTEGER NOT NULL,
`amount` INTEGER NOT NULL,
`timestamp` INTEGER NOT NULL
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
// Для этого используем SQLite функцию strftime для преобразования строковой даты в timestamp
database.execSQL(
"""
INSERT INTO water_logs_new (id, date, amount, timestamp)
SELECT id,
CASE
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
ELSE strftime('%s', 'now') * 1000
END as date_int,
amount,
timestamp
FROM water_logs
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE water_logs")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE water_logs_new RENAME TO water_logs")
}
}
/**
* Миграция базы данных с версии 3 на версию 4.
* Исправляет проблему с типом данных столбца date в таблице sleep_logs.
*/
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `sleep_logs_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`date` INTEGER NOT NULL,
`bedTime` TEXT NOT NULL,
`wakeTime` TEXT NOT NULL,
`duration` REAL NOT NULL,
`quality` TEXT NOT NULL,
`notes` TEXT NOT NULL
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
database.execSQL(
"""
INSERT INTO sleep_logs_new (id, date, bedTime, wakeTime, duration, quality, notes)
SELECT id,
CASE
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
ELSE strftime('%s', 'now') * 1000
END as date_int,
bedTime,
wakeTime,
duration,
quality,
notes
FROM sleep_logs
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE sleep_logs")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE sleep_logs_new RENAME TO sleep_logs")
}
}
/**
* Миграция базы данных с версии 4 на версию 5.
* Исправляет проблему с типом данных столбца date в таблице workouts.
*/
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `workouts_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`date` INTEGER NOT NULL,
`type` TEXT NOT NULL,
`name` TEXT NOT NULL,
`duration` INTEGER NOT NULL,
`caloriesBurned` INTEGER NOT NULL,
`intensity` TEXT NOT NULL,
`notes` TEXT NOT NULL
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
database.execSQL(
"""
INSERT INTO workouts_new (id, date, type, name, duration, caloriesBurned, intensity, notes)
SELECT id,
CASE
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
ELSE strftime('%s', 'now') * 1000
END as date_int,
type,
name,
duration,
caloriesBurned,
intensity,
notes
FROM workouts
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE workouts")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE workouts_new RENAME TO workouts")
}
}
/**
* Миграция базы данных с версии 5 на версию 6.
* Исправляет проблему с типом данных столбца date в таблице calories.
*/
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `calories_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`date` INTEGER NOT NULL,
`consumed` INTEGER NOT NULL,
`burned` INTEGER NOT NULL,
`target` INTEGER NOT NULL
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
database.execSQL(
"""
INSERT INTO calories_new (id, date, consumed, burned, target)
SELECT id,
CASE
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
ELSE strftime('%s', 'now') * 1000
END as date_int,
consumed,
burned,
target
FROM calories
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE calories")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE calories_new RENAME TO calories")
}
}
/**
* Миграция базы данных с версии 6 на версию 7.
* Исправляет проблему с типом данных столбца date во всех оставшихся таблицах.
*/
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
// Пропускаем таблицу steps, так как она будет обработана в MIGRATION_7_8
// Начинаем с проверки существования таблицы health_records и наличия в ней поля date с типом TEXT
try {
val cursor = database.query("PRAGMA table_info(health_records)")
var hasDateColumn = false
var isTextType = false
if (cursor.moveToFirst()) {
do {
val columnName = cursor.getString(cursor.getColumnIndex("name"))
val columnType = cursor.getString(cursor.getColumnIndex("type"))
if (columnName == "date" && columnType.equals("TEXT", ignoreCase = true)) {
hasDateColumn = true
isTextType = true
break
}
} while (cursor.moveToNext())
}
cursor.close()
if (hasDateColumn && isTextType) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `health_records_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`date` INTEGER NOT NULL,
`type` TEXT NOT NULL,
`value` REAL NOT NULL,
`notes` TEXT NOT NULL
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
database.execSQL(
"""
INSERT INTO health_records_new (id, date, type, value, notes)
SELECT id,
CASE
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
ELSE strftime('%s', 'now') * 1000
END as date_int,
type,
value,
notes
FROM health_records
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE health_records")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE health_records_new RENAME TO health_records")
}
} catch (e: Exception) {
// Если таблица не существует или возникла другая ошибка, просто продолжаем
}
// Проверяем другие таблицы, которые могут содержать поля даты с типом TEXT
// Список таблиц для проверки
val tablesToCheck = listOf(
"cycle_periods",
"user_profiles",
"weight_logs",
"beverage_logs",
"workout_sessions"
)
for (table in tablesToCheck) {
try {
val cursor = database.query("PRAGMA table_info($table)")
val dateColumns = mutableListOf<String>()
val columns = mutableListOf<Pair<String, String>>()
if (cursor.moveToFirst()) {
do {
val columnNameIndex = cursor.getColumnIndex("name")
val columnTypeIndex = cursor.getColumnIndex("type")
if (columnNameIndex >= 0 && columnTypeIndex >= 0) {
val columnName = cursor.getString(columnNameIndex)
val columnType = cursor.getString(columnTypeIndex)
columns.add(columnName to columnType)
// Проверяем, есть ли в названии колонки слово "date" и тип TEXT
if (columnName.contains("date", ignoreCase = true) &&
columnType.equals("TEXT", ignoreCase = true)) {
dateColumns.add(columnName)
}
}
} while (cursor.moveToNext())
}
cursor.close()
// Если найдены столбцы с датами типа TEXT
if (dateColumns.isNotEmpty()) {
// Создаем имя для временной таблицы
val newTableName = "${table}_new"
// Создаем SQL для создания новой таблицы с правильными типами
val createTableSQL = StringBuilder()
createTableSQL.append("CREATE TABLE IF NOT EXISTS `$newTableName` (")
val columnDefinitions = columns.map { (name, type) ->
// Для столбцов с датой меняем тип на INTEGER
if (dateColumns.contains(name)) {
"`$name` INTEGER" + if (name == "id") " PRIMARY KEY AUTOINCREMENT NOT NULL" else " NOT NULL"
} else {
"`$name` $type"
}
}
createTableSQL.append(columnDefinitions.joinToString(", "))
createTableSQL.append(")")
// Выполняем создание новой таблицы
database.execSQL(createTableSQL.toString())
// Создаем SQL для копирования данных
val insertSQL = StringBuilder()
insertSQL.append("INSERT INTO `$newTableName` SELECT ")
val columnSelects = columns.map { (name, _) ->
if (dateColumns.contains(name)) {
"CASE WHEN $name IS NOT NULL THEN strftime('%s', $name) * 1000 ELSE strftime('%s', 'now') * 1000 END as $name"
} else {
name
}
}
insertSQL.append(columnSelects.joinToString(", "))
insertSQL.append(" FROM `$table`")
// Выполняем копирование данных
database.execSQL(insertSQL.toString())
// Удаляем старую таблицу и переименовываем новую
database.execSQL("DROP TABLE `$table`")
database.execSQL("ALTER TABLE `$newTableName` RENAME TO `$table`")
}
} catch (e: Exception) {
// Если таблица не существует или возникла другая ошибка, просто продолжаем
}
}
}
}
/**
* Миграция базы данных с версии 7 на версию 8.
* Исправляет проблему с типом данных столбца date в таблице steps.
*/
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `steps_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`date` INTEGER NOT NULL,
`steps` INTEGER NOT NULL,
`distance` REAL NOT NULL,
`caloriesBurned` INTEGER NOT NULL,
`target` INTEGER NOT NULL
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
database.execSQL(
"""
INSERT INTO steps_new (id, date, steps, distance, caloriesBurned, target)
SELECT id,
CASE
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
ELSE strftime('%s', 'now') * 1000
END as date_int,
steps,
distance,
caloriesBurned,
target
FROM steps
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE steps")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE steps_new RENAME TO steps")
}
}
/**
* Миграция базы данных с версии 8 на версию 9.
* Исправляет проблему с типом данных столбца lastPeriodDate в таблице user_profile.
*/
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильными типами данных
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `user_profile_new` (
`id` INTEGER PRIMARY KEY NOT NULL,
`name` TEXT NOT NULL,
`email` TEXT NOT NULL,
`age` INTEGER NOT NULL,
`height` INTEGER NOT NULL,
`weight` REAL NOT NULL,
`targetWeight` REAL NOT NULL,
`activityLevel` TEXT NOT NULL,
`dailyWaterGoal` INTEGER NOT NULL,
`dailyCalorieGoal` INTEGER NOT NULL,
`dailyStepsGoal` INTEGER NOT NULL,
`cycleLength` INTEGER NOT NULL,
`periodLength` INTEGER NOT NULL,
`lastPeriodDate` INTEGER,
`profileImagePath` TEXT NOT NULL
)
""".trimIndent()
)
// Копируем данные из старой таблицы в новую, преобразуя lastPeriodDate из TEXT в INTEGER
database.execSQL(
"""
INSERT INTO user_profile_new (
id, name, email, age, height, weight, targetWeight, activityLevel,
dailyWaterGoal, dailyCalorieGoal, dailyStepsGoal, cycleLength,
periodLength, lastPeriodDate, profileImagePath
)
SELECT
id, name, email, age, height, weight, targetWeight, activityLevel,
dailyWaterGoal, dailyCalorieGoal, dailyStepsGoal, cycleLength,
periodLength,
CASE
WHEN lastPeriodDate IS NOT NULL THEN strftime('%s', lastPeriodDate) * 1000
ELSE NULL
END,
profileImagePath
FROM user_profile
""".trimIndent()
)
// Удаляем старую таблицу
database.execSQL("DROP TABLE user_profile")
// Переименовываем новую таблицу в старое имя
database.execSQL("ALTER TABLE user_profile_new RENAME TO user_profile")
}
}
/**
* Миграция базы данных с версии 9 на версию 10.
* Исправляет проблему с отсутствующей таблицей WorkoutSession.
*/
val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
// Проверяем, существует ли таблица WorkoutSession
try {
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='WorkoutSession'")
val hasTable = cursor.count > 0
cursor.close()
if (!hasTable) {
// Создаем таблицу WorkoutSession если она не существует
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `WorkoutSession` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`startedAt` INTEGER NOT NULL,
`endedAt` INTEGER,
`exerciseId` INTEGER NOT NULL,
`kcalTotal` REAL,
`distanceKm` REAL,
`notes` TEXT
)
""".trimIndent()
)
// Создаем индекс для столбца startedAt
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `WorkoutSession` (`startedAt`)"
)
} else {
// Если таблица существует, проверяем наличие необходимых столбцов
val columnCursor = database.query("PRAGMA table_info(WorkoutSession)")
val columns = mutableListOf<String>()
while (columnCursor.moveToNext()) {
val columnName = columnCursor.getString(columnCursor.getColumnIndex("name"))
columns.add(columnName)
}
columnCursor.close()
// Если нужных колонок нет, пересоздаем таблицу
val requiredColumns = listOf("id", "startedAt", "endedAt", "exerciseId",
"kcalTotal", "distanceKm", "notes")
if (!columns.containsAll(requiredColumns)) {
// Переименовываем старую таблицу
database.execSQL("ALTER TABLE `WorkoutSession` RENAME TO `WorkoutSession_old`")
// Создаем новую таблицу с правильной структурой
database.execSQL(
"""
CREATE TABLE `WorkoutSession` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`startedAt` INTEGER NOT NULL,
`endedAt` INTEGER,
`exerciseId` INTEGER NOT NULL,
`kcalTotal` REAL,
`distanceKm` REAL,
`notes` TEXT
)
""".trimIndent()
)
// Создаем индекс для столбца startedAt
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `WorkoutSession` (`startedAt`)"
)
// Пытаемся скопировать данные из старой таблицы, если это возможно
try {
database.execSQL(
"""
INSERT INTO WorkoutSession (id, startedAt, endedAt, exerciseId, kcalTotal, distanceKm, notes)
SELECT id, startedAt, endedAt, exerciseId, kcalTotal, distanceKm, notes
FROM WorkoutSession_old
""".trimIndent()
)
} catch (e: Exception) {
// Если копирование не удалось, просто продолжаем без данных
}
// Удаляем старую таблицу
database.execSQL("DROP TABLE IF EXISTS `WorkoutSession_old`")
}
}
} catch (e: Exception) {
// В случае любой ошибки, создаем таблицу заново
database.execSQL("DROP TABLE IF EXISTS `WorkoutSession`")
database.execSQL(
"""
CREATE TABLE `WorkoutSession` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`startedAt` INTEGER NOT NULL,
`endedAt` INTEGER,
`exerciseId` INTEGER NOT NULL,
`kcalTotal` REAL,
`distanceKm` REAL,
`notes` TEXT
)
""".trimIndent()
)
// Создаем индекс для столбца startedAt
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `WorkoutSession` (`startedAt`)"
)
}
// Также проверяем наличие таблицы WorkoutSessionParam и создаем её при необходимости
try {
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='WorkoutSessionParam'")
val hasTable = cursor.count > 0
cursor.close()
if (!hasTable) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `WorkoutSessionParam` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`sessionId` INTEGER NOT NULL,
`key` TEXT NOT NULL,
`valueNum` REAL,
`valueText` TEXT,
`unit` TEXT
)
""".trimIndent()
)
}
} catch (e: Exception) {
database.execSQL("DROP TABLE IF EXISTS `WorkoutSessionParam`")
database.execSQL(
"""
CREATE TABLE `WorkoutSessionParam` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`sessionId` INTEGER NOT NULL,
`key` TEXT NOT NULL,
`valueNum` REAL,
`valueText` TEXT,
`unit` TEXT
)
""".trimIndent()
)
}
// И проверяем наличие таблицы WorkoutEvent
try {
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='WorkoutEvent'")
val hasTable = cursor.count > 0
cursor.close()
if (!hasTable) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `WorkoutEvent` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`sessionId` INTEGER NOT NULL,
`timestamp` INTEGER NOT NULL,
`eventType` TEXT NOT NULL,
`valueNum` REAL,
`valueText` TEXT
)
""".trimIndent()
)
}
} catch (e: Exception) {
database.execSQL("DROP TABLE IF EXISTS `WorkoutEvent`")
database.execSQL(
"""
CREATE TABLE `WorkoutEvent` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`sessionId` INTEGER NOT NULL,
`timestamp` INTEGER NOT NULL,
`eventType` TEXT NOT NULL,
`valueNum` REAL,
`valueText` TEXT
)
""".trimIndent()
)
}
}
}
/**
* Миграция базы данных с версии 10 на версию 11.
* Исправляет проблему с несоответствием структуры таблицы WorkoutEvent.
*/
val MIGRATION_10_11 = object : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем временную таблицу с правильной структурой
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `WorkoutEvent_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`sessionId` INTEGER NOT NULL,
`ts` INTEGER NOT NULL,
`eventType` TEXT NOT NULL,
`payloadJson` TEXT NOT NULL
)
""".trimIndent()
)
// Пытаемся скопировать данные из старой таблицы, преобразовывая структуру
try {
database.execSQL(
"""
INSERT INTO WorkoutEvent_new (id, sessionId, ts, eventType, payloadJson)
SELECT
id,
sessionId,
timestamp AS ts,
eventType,
CASE
WHEN valueText IS NOT NULL THEN valueText
WHEN valueNum IS NOT NULL THEN json_object('value', valueNum)
ELSE '{}'
END AS payloadJson
FROM WorkoutEvent
""".trimIndent()
)
} catch (e: Exception) {
// Если копирование не удалось из-за несовместимости данных,
// просто создаем пустую таблицу
}
// Удаляем старую таблицу
database.execSQL("DROP TABLE IF EXISTS WorkoutEvent")
// Переименовываем новую таблицу
database.execSQL("ALTER TABLE WorkoutEvent_new RENAME TO WorkoutEvent")
}
}

View File

@@ -0,0 +1,151 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kr.smartsoltech.wellshe.data.entity.*
import java.time.Instant
import java.time.LocalDate
@Dao
interface NutrientDao {
@Query("SELECT * FROM Nutrient WHERE code = :code LIMIT 1")
suspend fun getByCode(code: String): Nutrient?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(nutrient: Nutrient): Long
@Query("SELECT * FROM Nutrient")
suspend fun getAll(): List<Nutrient>
}
@Dao
interface BeverageDao {
@Query("SELECT * FROM Beverage WHERE id = :id")
suspend fun getById(id: Long): Beverage?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(beverage: Beverage): Long
@Query("SELECT * FROM Beverage WHERE name LIKE :query LIMIT 20")
suspend fun search(query: String): List<Beverage>
}
@Dao
interface BeverageServingDao {
@Query("SELECT * FROM BeverageServing WHERE beverageId = :beverageId")
suspend fun getByBeverage(beverageId: Long): List<BeverageServing>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(serving: BeverageServing): Long
}
@Dao
interface BeverageServingNutrientDao {
@Query("SELECT * FROM BeverageServingNutrient WHERE servingId = :servingId")
suspend fun getByServing(servingId: Long): List<BeverageServingNutrient>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(nutrient: BeverageServingNutrient): Long
}
@Dao
interface WaterLogDao {
@Query("SELECT * FROM water_logs WHERE date = :date ORDER BY timestamp DESC")
suspend fun getWaterLogsForDate(date: LocalDate): List<WaterLogEntity>
@Query("SELECT SUM(amount) FROM water_logs WHERE date = :date")
suspend fun getTotalWaterForDate(date: LocalDate): Int?
@Insert
suspend fun insertWaterLog(waterLog: WaterLogEntity)
@Delete
suspend fun deleteWaterLog(waterLog: WaterLogEntity)
}
@Dao
interface BeverageLogDao {
@Query("SELECT * FROM BeverageLog WHERE ts BETWEEN :from AND :to ORDER BY ts DESC")
suspend fun getLogs(from: Instant, to: Instant): List<BeverageLog>
@Insert
suspend fun insert(log: BeverageLog): Long
}
@Dao
interface BeverageLogNutrientDao {
@Query("SELECT * FROM BeverageLogNutrient WHERE beverageLogId = :beverageLogId")
suspend fun getByLog(beverageLogId: Long): List<BeverageLogNutrient>
@Insert
suspend fun insert(nutrient: BeverageLogNutrient): Long
}
@Dao
interface WeightLogDao {
@Query("SELECT * FROM WeightLog ORDER BY ts DESC LIMIT 1")
suspend fun getLatestWeightKg(): WeightLog?
@Insert
suspend fun insert(log: WeightLog): Long
@Query("SELECT * FROM WeightLog WHERE ts BETWEEN :from AND :to ORDER BY ts DESC")
suspend fun getLogs(from: Instant, to: Instant): List<WeightLog>
}
@Dao
interface ExerciseDao {
@Query("SELECT * FROM Exercise WHERE id = :id")
suspend fun getById(id: Long): Exercise?
@Query("SELECT * FROM Exercise WHERE name LIKE :query LIMIT 20")
suspend fun search(query: String): List<Exercise>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(exercise: Exercise): Long
}
@Dao
interface ExerciseParamDao {
@Query("SELECT * FROM ExerciseParam WHERE exerciseId = :exerciseId")
suspend fun getByExercise(exerciseId: Long): List<ExerciseParam>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(param: ExerciseParam): Long
}
@Dao
interface ExerciseFormulaDao {
@Query("SELECT * FROM ExerciseFormula WHERE exerciseId = :exerciseId")
suspend fun getByExercise(exerciseId: Long): List<ExerciseFormula>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(formula: ExerciseFormula): Long
}
@Dao
interface ExerciseFormulaVarDao {
@Query("SELECT * FROM ExerciseFormulaVar WHERE formulaId = :formulaId")
suspend fun getByFormula(formulaId: Long): List<ExerciseFormulaVar>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(varDef: ExerciseFormulaVar): Long
}
@Dao
interface WorkoutSessionDao {
@Query("SELECT * FROM WorkoutSession WHERE startedAt BETWEEN :from AND :to ORDER BY startedAt DESC")
suspend fun getSessions(from: Instant, to: Instant): List<WorkoutSession>
@Insert
suspend fun insert(session: WorkoutSession): Long
@Query("SELECT * FROM WorkoutSession WHERE id = :id")
suspend fun getById(id: Long): WorkoutSession?
}
@Dao
interface WorkoutSessionParamDao {
@Query("SELECT * FROM WorkoutSessionParam WHERE sessionId = :sessionId")
suspend fun getBySession(sessionId: Long): List<WorkoutSessionParam>
@Insert
suspend fun insert(param: WorkoutSessionParam): Long
}
@Dao
interface WorkoutEventDao {
@Query("SELECT * FROM WorkoutEvent WHERE sessionId = :sessionId ORDER BY ts ASC")
suspend fun getBySession(sessionId: Long): List<WorkoutEvent>
@Insert
suspend fun insert(event: WorkoutEvent): Long
}
@Dao
interface CatalogVersionDao {
@Query("SELECT * FROM CatalogVersion WHERE source = :source LIMIT 1")
suspend fun getBySource(source: String): CatalogVersion?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(version: CatalogVersion): Long
}

View File

@@ -0,0 +1,33 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.data.entity.CycleForecastEntity
@Dao
interface CycleForecastDao {
@Query("SELECT * FROM cycle_forecast WHERE id = 1")
fun getForecastFlow(): Flow<CycleForecastEntity?>
@Query("SELECT * FROM cycle_forecast WHERE id = 1")
suspend fun getForecast(): CycleForecastEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(forecast: CycleForecastEntity): Long
@Update
suspend fun update(forecast: CycleForecastEntity)
@Transaction
suspend fun insertOrUpdate(forecast: CycleForecastEntity) {
val existing = getForecast()
if (existing == null) {
insert(forecast)
} else {
update(forecast)
}
}
@Query("DELETE FROM cycle_forecast")
suspend fun deleteAll()
}

View File

@@ -0,0 +1,42 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
import java.time.LocalDate
@Dao
interface CycleHistoryDao {
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC")
fun getAllFlow(): Flow<List<CycleHistoryEntity>>
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC")
suspend fun getAll(): List<CycleHistoryEntity>
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC LIMIT :limit")
suspend fun getRecentCycles(limit: Int): List<CycleHistoryEntity>
@Query("SELECT * FROM cycle_history WHERE atypical = 0 ORDER BY periodStart DESC LIMIT :limit")
suspend fun getRecentTypicalCycles(limit: Int): List<CycleHistoryEntity>
@Query("SELECT * FROM cycle_history WHERE id = :id")
suspend fun getById(id: Long): CycleHistoryEntity?
@Query("SELECT * FROM cycle_history WHERE periodStart = :date")
suspend fun getByStartDate(date: LocalDate): CycleHistoryEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(cycle: CycleHistoryEntity): Long
@Update
suspend fun update(cycle: CycleHistoryEntity)
@Delete
suspend fun delete(cycle: CycleHistoryEntity)
@Query("DELETE FROM cycle_history")
suspend fun deleteAll()
@Query("SELECT * FROM cycle_history WHERE periodStart BETWEEN :startDate AND :endDate")
suspend fun getCyclesInRange(startDate: LocalDate, endDate: LocalDate): List<CycleHistoryEntity>
}

View File

@@ -0,0 +1,24 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
import java.time.LocalDate
@Dao
interface CyclePeriodDao {
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC")
suspend fun getAll(): List<CyclePeriodEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(period: CyclePeriodEntity): Long
@Update
suspend fun update(period: CyclePeriodEntity)
@Delete
suspend fun delete(period: CyclePeriodEntity)
@Query("SELECT * FROM cycle_periods WHERE startDate = :date LIMIT 1")
suspend fun getByStartDate(date: LocalDate): CyclePeriodEntity?
}

View File

@@ -0,0 +1,36 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
@Dao
interface CycleSettingsDao {
@Query("SELECT * FROM cycle_settings WHERE id = 1")
fun getSettingsFlow(): Flow<CycleSettingsEntity?>
@Query("SELECT * FROM cycle_settings WHERE id = 1")
suspend fun getSettings(): CycleSettingsEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(settings: CycleSettingsEntity): Long
@Update
suspend fun update(settings: CycleSettingsEntity)
@Transaction
suspend fun insertOrUpdate(settings: CycleSettingsEntity) {
val existing = getSettings()
if (existing == null) {
insert(settings)
} else {
update(settings)
}
}
@Query("DELETE FROM cycle_settings")
suspend fun deleteAll()
@Query("UPDATE cycle_settings SET lastPeriodStart = :date WHERE id = 1")
suspend fun updateLastPeriodStart(date: java.time.LocalDate)
}

View File

@@ -5,72 +5,6 @@ import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.data.entity.*
import java.time.LocalDate
@Dao
interface WaterLogDao {
@Query("SELECT * FROM water_logs WHERE date = :date ORDER BY timestamp DESC")
fun getWaterLogsForDate(date: LocalDate): Flow<List<WaterLogEntity>>
@Query("SELECT SUM(amount) FROM water_logs WHERE date = :date")
suspend fun getTotalWaterForDate(date: LocalDate): Int?
@Insert
suspend fun insertWaterLog(waterLog: WaterLogEntity)
@Delete
suspend fun deleteWaterLog(waterLog: WaterLogEntity)
@Query("SELECT * FROM water_logs WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
fun getWaterLogsForPeriod(startDate: LocalDate, endDate: LocalDate): Flow<List<WaterLogEntity>>
}
@Dao
interface CyclePeriodDao {
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC")
fun getAllPeriods(): Flow<List<CyclePeriodEntity>>
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT 1")
suspend fun getLastPeriod(): CyclePeriodEntity?
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT 1")
fun getCurrentPeriod(): Flow<CyclePeriodEntity?>
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT :limit")
fun getRecentPeriods(limit: Int): Flow<List<CyclePeriodEntity>>
@Insert
suspend fun insertPeriod(period: CyclePeriodEntity)
@Update
suspend fun updatePeriod(period: CyclePeriodEntity)
@Delete
suspend fun deletePeriod(period: CyclePeriodEntity)
@Query("SELECT * FROM cycle_periods WHERE startDate BETWEEN :startDate AND :endDate")
fun getPeriodsInRange(startDate: LocalDate, endDate: LocalDate): Flow<List<CyclePeriodEntity>>
}
@Dao
interface SleepLogDao {
@Query("SELECT * FROM sleep_logs WHERE date = :date")
suspend fun getSleepForDate(date: LocalDate): SleepLogEntity?
@Query("SELECT * FROM sleep_logs ORDER BY date DESC LIMIT 7")
fun getRecentSleepLogs(): Flow<List<SleepLogEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSleepLog(sleepLog: SleepLogEntity)
@Update
suspend fun updateSleepLog(sleepLog: SleepLogEntity)
@Delete
suspend fun deleteSleepLog(sleepLog: SleepLogEntity)
@Query("SELECT * FROM sleep_logs WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
fun getSleepLogsForPeriod(startDate: LocalDate, endDate: LocalDate): Flow<List<SleepLogEntity>>
}
@Dao
interface WorkoutDao {
@Query("SELECT * FROM workouts WHERE date = :date ORDER BY id DESC")

View File

@@ -1,36 +1,29 @@
package kr.smartsoltech.wellshe.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
import java.time.LocalDate
@Dao
interface HealthRecordDao {
@Query("SELECT * FROM health_records WHERE date = :date")
suspend fun getHealthRecordForDate(date: LocalDate): HealthRecordEntity?
@Query("SELECT * FROM health_records ORDER BY date DESC LIMIT :limit")
fun getRecentHealthRecords(limit: Int = 30): Flow<List<HealthRecordEntity>>
@Query("SELECT * FROM health_records ORDER BY date DESC")
suspend fun getAll(): List<HealthRecordEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertHealthRecord(record: HealthRecordEntity)
suspend fun insert(record: HealthRecordEntity): Long
@Update
suspend fun updateHealthRecord(record: HealthRecordEntity)
suspend fun update(record: HealthRecordEntity)
@Delete
suspend fun deleteHealthRecord(record: HealthRecordEntity)
suspend fun delete(record: HealthRecordEntity)
@Query("DELETE FROM health_records WHERE id = :id")
suspend fun deleteHealthRecordById(id: Long)
@Query("SELECT * FROM health_records WHERE date = :date LIMIT 1")
suspend fun getByDate(date: LocalDate): HealthRecordEntity?
@Query("SELECT * FROM health_records WHERE date BETWEEN :startDate AND :endDate ORDER BY date")
suspend fun getHealthRecordsInRange(startDate: LocalDate, endDate: LocalDate): List<HealthRecordEntity>
@Query("SELECT * FROM health_records ORDER BY date DESC")
fun getAllFlow(): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>>
@Query("SELECT AVG(weight) FROM health_records WHERE weight IS NOT NULL AND date BETWEEN :startDate AND :endDate")
suspend fun getAverageWeight(startDate: LocalDate, endDate: LocalDate): Float?
@Query("SELECT AVG(heartRate) FROM health_records WHERE heartRate IS NOT NULL AND date BETWEEN :startDate AND :endDate")
suspend fun getAverageHeartRate(startDate: LocalDate, endDate: LocalDate): Float?
@Query("SELECT * FROM health_records WHERE date = :date LIMIT 1")
fun getByDateFlow(date: LocalDate): kotlinx.coroutines.flow.Flow<HealthRecordEntity?>
}

View File

@@ -0,0 +1,156 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Index
import java.time.Instant
@Entity(tableName = "Nutrient", indices = [Index("code", unique = true)])
data class Nutrient(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val code: String,
val name: String,
val unit: String
)
@Entity(tableName = "Beverage")
data class Beverage(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val brand: String?,
val category: String,
val source: String,
val sourceRef: String,
val isCaffeinated: Boolean,
val isSweetened: Boolean,
val createdAt: Instant
)
@Entity(tableName = "BeverageServing")
data class BeverageServing(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val beverageId: Long,
val label: String,
val volumeMl: Int
)
@Entity(tableName = "BeverageServingNutrient")
data class BeverageServingNutrient(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val servingId: Long,
val nutrientId: Long,
val amountPerServing: Float
)
@Entity(tableName = "WaterLog", indices = [Index("ts")])
data class WaterLog(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val ts: Instant,
val volumeMl: Int,
val source: String
)
@Entity(tableName = "BeverageLog", indices = [Index("ts")])
data class BeverageLog(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val ts: Instant,
val beverageId: Long,
val servingId: Long,
val servingsCount: Int,
val notes: String?
)
@Entity(tableName = "BeverageLogNutrient")
data class BeverageLogNutrient(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val beverageLogId: Long,
val nutrientId: Long,
val amountTotal: Float
)
@Entity(tableName = "WeightLog", indices = [Index("ts")])
data class WeightLog(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val ts: Instant,
val weightKg: Float,
val source: String
)
@Entity(tableName = "Exercise")
data class Exercise(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val category: String,
val description: String?,
val metValue: Float?,
val source: String,
val sourceRef: String?
)
@Entity(tableName = "ExerciseParam")
data class ExerciseParam(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val exerciseId: Long,
val key: String,
val valueType: String,
val unit: String?,
val required: Boolean,
val defaultValue: String?
)
@Entity(tableName = "ExerciseFormula")
data class ExerciseFormula(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val exerciseId: Long,
val name: String,
val exprKcal: String,
val notes: String?
)
@Entity(tableName = "ExerciseFormulaVar")
data class ExerciseFormulaVar(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val formulaId: Long,
val varKey: String,
val required: Boolean,
val unit: String?
)
@Entity(tableName = "WorkoutSession", indices = [Index("startedAt")])
data class WorkoutSession(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val startedAt: Instant,
val endedAt: Instant?,
val exerciseId: Long,
val kcalTotal: Float?,
val distanceKm: Float?,
val notes: String?
)
@Entity(tableName = "WorkoutSessionParam")
data class WorkoutSessionParam(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: Long,
val key: String,
val valueNum: Float?,
val valueText: String?,
val unit: String?
)
@Entity(tableName = "WorkoutEvent")
data class WorkoutEvent(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: Long,
val ts: Instant,
val eventType: String,
val payloadJson: String
)
@Entity(tableName = "CatalogVersion", indices = [Index("source", unique = true)])
data class CatalogVersion(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val localVersion: Int,
val source: String,
val lastSyncAt: Instant
)

View File

@@ -0,0 +1,21 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.Instant
import java.time.LocalDate
/**
* Кэш прогнозов цикла для быстрого доступа в UI.
*/
@Entity(tableName = "cycle_forecast")
data class CycleForecastEntity(
@PrimaryKey val id: Int = 1, // Singleton
val nextPeriodStart: LocalDate? = null,
val nextOvulation: LocalDate? = null,
val fertileStart: LocalDate? = null,
val fertileEnd: LocalDate? = null,
val pmsStart: LocalDate? = null,
val updatedAt: Instant = Instant.now(),
val isReliable: Boolean = true // Flag для пониженной точности при определенных статусах
)

View File

@@ -0,0 +1,26 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import java.time.LocalDate
/**
* История циклов для расчета прогнозов и анализа.
*/
@Entity(
tableName = "cycle_history",
indices = [Index(value = ["periodStart"], unique = true)]
)
data class CycleHistoryEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val periodStart: LocalDate,
val periodEnd: LocalDate? = null,
val ovulationDate: LocalDate? = null,
val notes: String = "",
val atypical: Boolean = false,
// Добавляем поля для соответствия с CyclePeriodEntity
val flow: String = "",
val symptoms: List<String> = emptyList(),
val cycleLength: Int? = null
)

View File

@@ -0,0 +1,15 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDate
@Entity(tableName = "cycle_periods")
data class CyclePeriodEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val startDate: LocalDate,
val endDate: LocalDate?,
val flow: String = "",
val symptoms: List<String> = emptyList(),
val cycleLength: Int? = null
)

View File

@@ -0,0 +1,47 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDate
/**
* Основные настройки для модуля отслеживания менструального цикла.
*/
@Entity(tableName = "cycle_settings")
data class CycleSettingsEntity(
@PrimaryKey val id: Int = 1, // Singleton
// Основные параметры цикла
val baselineCycleLength: Int = 28,
val cycleVariabilityDays: Int = 3,
val periodLengthDays: Int = 5,
val lutealPhaseDays: String = "auto", // "auto" или число (8-17)
val lastPeriodStart: LocalDate? = null,
// Метод определения овуляции
val ovulationMethod: String = "auto", // auto, bbt, lh_test, cervical_mucus, medical
val allowManualOvulation: Boolean = false,
// Статусы влияющие на точность
val hormonalContraception: String = "none", // none, coc, iud, implant, other
val isPregnant: Boolean = false,
val isPostpartum: Boolean = false,
val isLactating: Boolean = false,
val perimenopause: Boolean = false,
// Настройки истории и исключения выбросов
val historyWindowCycles: Int = 6,
val excludeOutliers: Boolean = true,
// Сенсоры и единицы измерения
val tempUnit: String = "C", // C или F
val bbtTimeWindow: String = "06:00-10:00",
val timezone: String = "Asia/Seoul",
// Уведомления
val periodReminderDaysBefore: Int = 2,
val ovulationReminderDaysBefore: Int = 1,
val pmsWindowDays: Int = 3,
val deviationAlertDays: Int = 5,
val fertileWindowMode: String = "balanced" // conservative, balanced, broad
)

View File

@@ -13,47 +13,6 @@ data class WaterLogEntity(
val timestamp: Long = System.currentTimeMillis()
)
@Entity(tableName = "cycle_periods")
data class CyclePeriodEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val startDate: LocalDate,
val endDate: LocalDate?,
val cycleLength: Int = 28,
val flow: String = "medium", // light, medium, heavy
val symptoms: String = "", // JSON строка симптомов
val mood: String = "neutral"
)
@Entity(tableName = "sleep_logs")
data class SleepLogEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val date: LocalDate,
val bedTime: String, // HH:mm
val wakeTime: String, // HH:mm
val duration: Float, // часы
val quality: String = "good", // poor, fair, good, excellent
val notes: String = ""
)
@Entity(tableName = "health_records")
data class HealthRecordEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val date: LocalDate,
val weight: Float? = null,
val heartRate: Int? = null,
val bloodPressureS: Int? = null, // систолическое
val bloodPressureD: Int? = null, // диастолическое
val temperature: Float? = null,
val mood: String = "neutral",
val energyLevel: Int = 5, // 1-10
val stressLevel: Int = 5, // 1-10
val symptoms: String = "", // JSON строка симптомов
val notes: String = ""
)
@Entity(tableName = "workouts")
data class WorkoutEntity(
@PrimaryKey(autoGenerate = true)
@@ -105,5 +64,10 @@ data class UserProfileEntity(
val cycleLength: Int = 28,
val periodLength: Int = 5,
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

@@ -0,0 +1,16 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.TypeConverter
class HealthRecordConverters {
@TypeConverter
fun fromSymptomsList(list: List<String>?): String? {
return list?.joinToString(separator = "|")
}
@TypeConverter
fun toSymptomsList(data: String?): List<String>? {
return data?.split("|")?.filter { it.isNotEmpty() }
}
}

View File

@@ -0,0 +1,19 @@
package kr.smartsoltech.wellshe.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDate
@Entity(tableName = "health_records")
data class HealthRecordEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val date: LocalDate,
val weight: Float?,
val heartRate: Int?,
val bloodPressureS: Int?,
val bloodPressureD: Int?,
val temperature: Float?,
val energyLevel: Int?,
val symptoms: List<String>?,
val notes: String?
)

View File

@@ -0,0 +1,77 @@
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)
}
}
// Удалить только auth token (не трогая сохранённые credentials)
suspend fun clearAuthToken() {
context.authDataStore.edit { preferences ->
preferences.remove(AUTH_TOKEN)
}
}
}

View File

@@ -0,0 +1,382 @@
package kr.smartsoltech.wellshe.data.mapper
import kr.smartsoltech.wellshe.data.entity.*
import kr.smartsoltech.wellshe.domain.model.*
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.Instant
// =================
// ПОЛЬЗОВАТЕЛЬ
// =================
fun UserProfileEntity.toDomainModel() = User(
id = id,
name = name,
email = email,
age = age,
height = height.toFloat(),
weight = weight,
dailyWaterGoal = dailyWaterGoal / 1000f, // конвертируем в литры
dailyStepsGoal = dailyStepsGoal,
dailyCaloriesGoal = dailyCalorieGoal
)
fun User.toEntity() = UserProfileEntity(
id = id,
name = name,
email = email,
age = age,
height = height.toInt(),
weight = weight,
dailyWaterGoal = (dailyWaterGoal * 1000).toInt(), // конвертируем в мл
dailyStepsGoal = dailyStepsGoal,
dailyCalorieGoal = dailyCaloriesGoal
)
// =================
// ВОДНЫЙ БАЛАНС (BodyEntities.kt)
// =================
fun WaterLog.toDomainModel() = WaterIntake(
id = id,
date = ts.atZone(ZoneId.systemDefault()).toLocalDate(),
time = ts.atZone(ZoneId.systemDefault()).toLocalTime(),
amount = volumeMl / 1000f // конвертируем в литры
)
fun WaterIntake.toWaterLog() = WaterLog(
id = id,
ts = date.atTime(time).atZone(ZoneId.systemDefault()).toInstant(),
volumeMl = (amount * 1000).toInt(),
source = "manual"
)
// Для совместимости с простой структурой WaterLogEntity
fun WaterLogEntity.toDomainModel() = WaterIntake(
id = id,
date = date,
time = LocalTime.of(
((timestamp % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)).toInt(),
((timestamp % (60 * 60 * 1000)) / (60 * 1000)).toInt()
),
amount = amount / 1000f // конвертируем в литры
)
fun WaterIntake.toEntity() = WaterLogEntity(
id = id,
date = date,
amount = (amount * 1000).toInt(), // конвертируем в мл
timestamp = System.currentTimeMillis()
)
// =================
// ВЕС (BodyEntities.kt)
// =================
fun WeightLog.toDomainModel() = WeightData(
id = id,
date = ts.atZone(ZoneId.systemDefault()).toLocalDate(),
weight = weightKg,
source = source
)
fun WeightData.toWeightLog() = WeightLog(
id = id,
ts = date.atStartOfDay(ZoneId.systemDefault()).toInstant(),
weightKg = weight,
source = source
)
// =================
// ТРЕНИРОВКИ (BodyEntities.kt)
// =================
fun kr.smartsoltech.wellshe.data.entity.WorkoutSession.toDomainModel() = WorkoutSessionData(
id = id,
startTime = startedAt.atZone(ZoneId.systemDefault()).toLocalDateTime(),
endTime = endedAt?.atZone(ZoneId.systemDefault())?.toLocalDateTime(),
exerciseId = exerciseId,
caloriesBurned = kcalTotal?.toInt() ?: 0,
distance = distanceKm ?: 0f,
notes = notes ?: ""
)
fun WorkoutSessionData.toWorkoutSession() = kr.smartsoltech.wellshe.data.entity.WorkoutSession(
id = id,
startedAt = startTime.atZone(ZoneId.systemDefault()).toInstant(),
endedAt = endTime?.atZone(ZoneId.systemDefault())?.toInstant(),
exerciseId = exerciseId,
kcalTotal = caloriesBurned.toFloat(),
distanceKm = distance,
notes = notes
)
// Для совместимости с простой структурой WorkoutEntity
fun WorkoutEntity.toDomainModel() = kr.smartsoltech.wellshe.domain.model.WorkoutSession(
id = id,
type = type,
duration = duration,
caloriesBurned = caloriesBurned,
date = date,
startTime = date.atStartOfDay()
)
fun kr.smartsoltech.wellshe.domain.model.WorkoutSession.toEntity() = WorkoutEntity(
id = id,
date = date,
type = type,
name = "$type тренировка",
duration = duration,
caloriesBurned = caloriesBurned,
intensity = "moderate"
)
fun WorkoutEntity.toWorkoutData() = WorkoutData(
id = id.toString(),
date = date,
type = 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
},
duration = duration,
intensity = when (intensity.lowercase()) {
"low" -> WorkoutIntensity.LOW
"moderate" -> WorkoutIntensity.MODERATE
"high" -> WorkoutIntensity.HIGH
"intense" -> WorkoutIntensity.INTENSE
else -> WorkoutIntensity.MODERATE
},
caloriesBurned = caloriesBurned
)
fun WorkoutData.toWorkoutEntity() = WorkoutEntity(
id = id.toLongOrNull() ?: 0,
date = date,
type = type.name.lowercase(),
name = "${type.name} тренировка",
duration = duration,
caloriesBurned = caloriesBurned,
intensity = intensity.name.lowercase()
)
// =================
// ФИТНЕС ДАННЫЕ
// =================
fun StepsEntity.toFitnessData() = FitnessData(
id = id,
date = date,
steps = steps,
distance = distance,
caloriesBurned = caloriesBurned,
activeMinutes = 0 // TODO: добавить в entity
)
fun FitnessData.toStepsEntity() = StepsEntity(
id = id,
date = date,
steps = steps,
distance = distance,
caloriesBurned = caloriesBurned,
target = 10000
)
// =================
// КАЛОРИИ
// =================
fun CalorieEntity.toDomainModel() = CalorieData(
id = id.toString(),
date = date,
consumed = consumed,
burned = burned,
target = target
)
fun CalorieData.toEntity() = CalorieEntity(
id = id.toLongOrNull() ?: 0,
date = date,
consumed = consumed,
burned = burned,
target = target
)
// =================
// ЗДОРОВЬЕ
// =================
fun HealthRecordEntity.toHealthData() = HealthData(
id = id.toString(),
date = date,
weight = weight ?: 0f,
heartRate = heartRate ?: 70,
bloodPressureSystolic = bloodPressureS ?: 120,
bloodPressureDiastolic = bloodPressureD ?: 80,
mood = Mood.NEUTRAL, // TODO: добавить mood в HealthRecordEntity
energyLevel = energyLevel ?: 5,
stressLevel = 5, // TODO: добавить stressLevel в HealthRecordEntity
symptoms = symptoms ?: emptyList()
)
fun HealthData.toEntity() = HealthRecordEntity(
id = id.toLongOrNull() ?: 0,
date = date,
weight = weight,
heartRate = heartRate,
bloodPressureS = bloodPressureSystolic,
bloodPressureD = bloodPressureDiastolic,
temperature = null,
energyLevel = energyLevel,
symptoms = symptoms,
notes = null
)
// =================
// МЕНСТРУАЛЬНЫЙ ЦИКЛ
// =================
fun CyclePeriodEntity.toCycleData() = CycleData(
id = id.toString(),
cycleLength = cycleLength ?: 28,
periodLength = if (endDate != null) {
(endDate.toEpochDay() - startDate.toEpochDay()).toInt()
} else 5,
lastPeriodDate = startDate,
nextPeriodDate = startDate.plusDays(cycleLength?.toLong() ?: 28),
ovulationDate = startDate.plusDays((cycleLength ?: 28) / 2L)
)
fun CycleData.toPeriodEntity() = CyclePeriodEntity(
id = id.toLongOrNull() ?: 0,
startDate = lastPeriodDate,
endDate = lastPeriodDate.plusDays(periodLength.toLong()),
flow = "",
symptoms = emptyList(),
cycleLength = cycleLength
)
// =================
// НАПИТКИ И ПИТАНИЕ
// =================
fun Beverage.toDomainModel() = BeverageData(
id = id,
name = name,
brand = brand,
category = category,
isCaffeinated = isCaffeinated,
isSweetened = isSweetened
)
fun BeverageLog.toDomainModel() = BeverageLogData(
id = id,
timestamp = ts,
beverageId = beverageId,
servingId = servingId,
servingsCount = servingsCount,
notes = notes
)
// =================
// УПРАЖНЕНИЯ
// =================
fun Exercise.toDomainModel() = ExerciseData(
id = id,
name = name,
category = category,
description = description,
metValue = metValue,
source = source
)
// =================
// ДОПОЛНИТЕЛЬНЫЕ МОДЕЛИ ДАННЫХ
// =================
data class CalorieData(
val id: String = "",
val date: LocalDate = LocalDate.now(),
val consumed: Int = 0,
val burned: Int = 0,
val target: Int = 2000
)
data class WeightData(
val id: Long = 0,
val date: LocalDate = LocalDate.now(),
val weight: Float = 0f,
val source: String = "manual"
)
data class WorkoutSessionData(
val id: Long = 0,
val startTime: LocalDateTime = LocalDateTime.now(),
val endTime: LocalDateTime? = null,
val exerciseId: Long = 0,
val caloriesBurned: Int = 0,
val distance: Float = 0f,
val notes: String = ""
)
data class BeverageData(
val id: Long = 0,
val name: String = "",
val brand: String? = null,
val category: String = "",
val isCaffeinated: Boolean = false,
val isSweetened: Boolean = false
)
data class BeverageLogData(
val id: Long = 0,
val timestamp: Instant = Instant.now(),
val beverageId: Long = 0,
val servingId: Long = 0,
val servingsCount: Int = 1,
val notes: String? = null
)
data class ExerciseData(
val id: Long = 0,
val name: String = "",
val category: String = "",
val description: String? = null,
val metValue: Float? = null,
val source: String = ""
)
// =================
// ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
// =================
fun Long.toLocalDateTime(): LocalDateTime =
LocalDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneId.systemDefault())
fun LocalDateTime.toTimestamp(): Long =
this.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
fun LocalDate.toTimestamp(): Long =
this.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
fun Instant.toLocalDate(): LocalDate =
this.atZone(ZoneId.systemDefault()).toLocalDate()
fun Instant.toLocalDateTime(): LocalDateTime =
this.atZone(ZoneId.systemDefault()).toLocalDateTime()
fun LocalDateTime.toInstant(): Instant =
this.atZone(ZoneId.systemDefault()).toInstant()
fun LocalDate.toInstant(): Instant =
this.atStartOfDay(ZoneId.systemDefault()).toInstant()

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,119 @@
package kr.smartsoltech.wellshe.data.repo
import kr.smartsoltech.wellshe.data.dao.*
import kr.smartsoltech.wellshe.data.entity.*
import java.time.Instant
class BeverageCatalogRepository(
private val beverageDao: BeverageDao,
private val servingDao: BeverageServingDao,
private val servingNutrientDao: BeverageServingNutrientDao
) {
suspend fun search(query: String): List<Beverage> = beverageDao.search("%$query%")
suspend fun getServings(beverageId: Long): List<BeverageServing> = servingDao.getByBeverage(beverageId)
suspend fun getNutrients(servingId: Long): List<BeverageServingNutrient> = servingNutrientDao.getByServing(servingId)
// Методы syncFromUsda(), syncFromOpenFoodFacts() будут реализованы отдельно
}
class DrinkLogger(
private val waterLogDao: WaterLogDao,
private val beverageLogDao: BeverageLogDao,
private val beverageLogNutrientDao: BeverageLogNutrientDao,
private val servingNutrientDao: BeverageServingNutrientDao
) {
suspend fun logWater(ts: Instant, volumeMl: Int, source: String = "manual") {
waterLogDao.insertWaterLog(WaterLogEntity(date = ts.atZone(java.time.ZoneId.systemDefault()).toLocalDate(), amount = volumeMl, timestamp = ts.toEpochMilli()))
}
suspend fun logBeverage(ts: Instant, beverageId: Long, servingId: Long, servingsCount: Int, notes: String? = null) {
val logId = beverageLogDao.insert(BeverageLog(ts = ts, beverageId = beverageId, servingId = servingId, servingsCount = servingsCount, notes = notes))
val nutrients = servingNutrientDao.getByServing(servingId)
nutrients.forEach { n ->
beverageLogNutrientDao.insert(
BeverageLogNutrient(
beverageLogId = logId,
nutrientId = n.nutrientId,
amountTotal = n.amountPerServing * servingsCount
)
)
}
}
suspend fun getWaterHistory(days: Int): List<Int> {
val today = java.time.LocalDate.now()
return (0 until days).map { offset ->
val date = today.minusDays(offset.toLong())
waterLogDao.getTotalWaterForDate(date) ?: 0
}.reversed()
}
}
class WeightRepository(private val weightLogDao: WeightLogDao) {
suspend fun addWeight(ts: Instant, kg: Float, source: String = "manual") {
weightLogDao.insert(WeightLog(ts = ts, weightKg = kg, source = source))
}
suspend fun getLatestWeightKg(): Float? = weightLogDao.getLatestWeightKg()?.weightKg
suspend fun getWeightHistory(days: Int): List<Pair<String, Float>> {
val today = java.time.LocalDate.now()
return (0 until days).map { offset ->
val date = today.minusDays(offset.toLong())
val logs = weightLogDao.getLogs(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant(), date.plusDays(1).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant())
val weight = logs.firstOrNull()?.weightKg ?: 0f
date.toString() to weight
}.reversed()
}
}
class ExerciseCatalogRepository(
private val exerciseDao: ExerciseDao,
private val paramDao: ExerciseParamDao,
private val formulaDao: ExerciseFormulaDao
) {
suspend fun searchExercises(query: String): List<Exercise> = exerciseDao.search("%$query%")
suspend fun getParams(exerciseId: Long): List<ExerciseParam> = paramDao.getByExercise(exerciseId)
suspend fun getFormulas(exerciseId: Long): List<ExerciseFormula> = formulaDao.getByExercise(exerciseId)
// Методы syncFromWger(), syncMetTables() будут реализованы отдельно
}
class WorkoutService(
private val sessionDao: WorkoutSessionDao,
private val paramDao: WorkoutSessionParamDao,
private val eventDao: WorkoutEventDao,
private val weightRepo: WeightRepository,
private val formulaDao: ExerciseFormulaDao,
private val formulaVarDao: ExerciseFormulaVarDao,
private val exerciseDao: ExerciseDao
) {
suspend fun searchExercises(query: String): List<Exercise> = exerciseDao.search("%$query%")
suspend fun getSessions(days: Int): List<WorkoutSession> {
val now = java.time.Instant.now()
val start = now.minusSeconds(days * 24 * 3600L)
return sessionDao.getSessions(start, now)
}
suspend fun stopSession(sessionId: Long) {
val session = sessionDao.getById(sessionId)
if (session != null && session.endedAt == null) {
sessionDao.insert(session.copy(endedAt = java.time.Instant.now()))
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = java.time.Instant.now(), eventType = "stop", payloadJson = "{}"))
}
}
suspend fun startSession(exerciseId: Long): Long {
val baseWeightKg = weightRepo.getLatestWeightKg() ?: 70f
val sessionId = sessionDao.insert(
WorkoutSession(
startedAt = Instant.now(),
endedAt = null,
exerciseId = exerciseId,
kcalTotal = null,
distanceKm = null,
notes = null
)
)
paramDao.insert(WorkoutSessionParam(sessionId = sessionId, key = "baseWeightKg", valueNum = baseWeightKg, valueText = null, unit = "kg"))
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = Instant.now(), eventType = "start", payloadJson = "{}"))
return sessionId
}
suspend fun updateParam(sessionId: Long, key: String, valueNum: Float?, valueText: String?, unit: String?) {
paramDao.insert(WorkoutSessionParam(sessionId = sessionId, key = key, valueNum = valueNum, valueText = valueText, unit = unit))
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = Instant.now(), eventType = "param_change", payloadJson = "{\"key\":\"$key\"}"))
}
// tick(sessionId) и stopSession(sessionId) будут реализованы с расчетом калорий по формуле
}

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

@@ -0,0 +1,311 @@
package kr.smartsoltech.wellshe.data.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kr.smartsoltech.wellshe.data.dao.CycleForecastDao
import kr.smartsoltech.wellshe.data.dao.CycleHistoryDao
import kr.smartsoltech.wellshe.data.dao.CycleSettingsDao
import kr.smartsoltech.wellshe.data.entity.CycleForecastEntity
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
import kr.smartsoltech.wellshe.domain.models.CycleForecast
import kr.smartsoltech.wellshe.domain.models.CycleSettings
import java.time.Instant
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
/**
* Репозиторий для работы с данными цикла, настройками и прогнозами.
*/
@Singleton
class CycleRepository @Inject constructor(
private val settingsDao: CycleSettingsDao,
private val historyDao: CycleHistoryDao,
private val forecastDao: CycleForecastDao
) {
// Настройки цикла
fun getSettingsFlow(): Flow<CycleSettingsEntity?> = settingsDao.getSettingsFlow()
suspend fun getSettings(): CycleSettingsEntity? = settingsDao.getSettings()
suspend fun saveSettings(settings: CycleSettingsEntity) {
settingsDao.insertOrUpdate(settings)
recalculateForecasts() // Пересчитываем прогнозы при изменении настроек
}
suspend fun updateLastPeriodStart(date: LocalDate) {
val settings = settingsDao.getSettings() ?: createDefaultSettings()
settingsDao.insertOrUpdate(settings.copy(lastPeriodStart = date))
recalculateForecasts()
}
private suspend fun createDefaultSettings(): CycleSettingsEntity {
return CycleSettingsEntity()
}
suspend fun resetToDefaults() {
settingsDao.insertOrUpdate(CycleSettingsEntity())
recalculateForecasts()
}
/**
* Обновляет настройки цикла
*/
suspend fun updateSettings(settings: CycleSettingsEntity) {
settingsDao.insertOrUpdate(settings)
}
// История циклов
fun getAllHistoryFlow(): Flow<List<CycleHistoryEntity>> = historyDao.getAllFlow()
suspend fun getAllHistory(): List<CycleHistoryEntity> = historyDao.getAll()
suspend fun getRecentCycles(limit: Int): List<CycleHistoryEntity> = historyDao.getRecentCycles(limit)
suspend fun getRecentTypicalCycles(limit: Int): List<CycleHistoryEntity> =
historyDao.getRecentTypicalCycles(limit)
suspend fun addCycleToHistory(cycle: CycleHistoryEntity): Long {
val id = historyDao.insert(cycle)
recalculateForecasts()
return id
}
suspend fun updateCycleInHistory(cycle: CycleHistoryEntity) {
historyDao.update(cycle)
recalculateForecasts()
}
suspend fun deleteCycleFromHistory(cycle: CycleHistoryEntity) {
historyDao.delete(cycle)
recalculateForecasts()
}
suspend fun markCycleAsAtypical(id: Long, atypical: Boolean) {
historyDao.getById(id)?.let { cycle ->
historyDao.update(cycle.copy(atypical = atypical))
recalculateForecasts()
}
}
// Прогнозы
fun getForecastFlow(): Flow<CycleForecastEntity?> = forecastDao.getForecastFlow()
suspend fun getForecast(): CycleForecastEntity? = forecastDao.getForecast()
/**
* Пересчитывает прогнозы на основе текущих настроек и истории циклов.
* Вызывается автоматически при изменении настроек или истории.
*/
suspend fun recalculateForecasts() {
val settings = settingsDao.getSettings() ?: return
val history = if (settings.excludeOutliers) {
historyDao.getRecentTypicalCycles(settings.historyWindowCycles)
} else {
historyDao.getRecentCycles(settings.historyWindowCycles)
}
val forecast = calculateForecast(settings, history)
forecastDao.insertOrUpdate(forecast)
// Здесь также можно вызвать планирование уведомлений на основе новых прогнозов
scheduleNotifications(forecast)
}
/**
* Расчет прогноза цикла на основе настроек и истории.
*/
private fun calculateForecast(
settings: CycleSettingsEntity,
history: List<CycleHistoryEntity>
): CycleForecastEntity {
// Определяем надежность прогноза
val isReliable = !(settings.isPregnant || settings.isPostpartum ||
settings.isLactating || settings.perimenopause ||
settings.hormonalContraception != "none")
// Если нет истории и нет даты последней менструации, не можем сделать прогноз
if (history.isEmpty() && settings.lastPeriodStart == null) {
return CycleForecastEntity(
isReliable = isReliable,
updatedAt = Instant.now()
)
}
// Находим дату последней менструации
val lastPeriodStart = settings.lastPeriodStart ?: history.firstOrNull()?.periodStart
if (lastPeriodStart == null) {
return CycleForecastEntity(
isReliable = isReliable,
updatedAt = Instant.now()
)
}
// Рассчитываем средний цикл на основе истории или используем базовые настройки
val cycleLength = if (history.size >= 2) {
calculateAverageCycleLength(history)
} else {
settings.baselineCycleLength
}
// Рассчитываем л<><D0BB>теиновую фазу
val lutealPhase = if (settings.lutealPhaseDays == "auto") {
14 // Стандартная длина лютеиновой фазы
} else {
try {
settings.lutealPhaseDays.toInt()
} catch (e: NumberFormatException) {
14
}
}
// Рассчитываем даты
val today = LocalDate.now()
val daysSinceLastPeriod = today.toEpochDay() - lastPeriodStart.toEpochDay()
val nextPeriodStart = if (daysSinceLastPeriod >= cycleLength) {
lastPeriodStart.plusDays(cycleLength.toLong() * (daysSinceLastPeriod / cycleLength + 1))
} else {
lastPeriodStart.plusDays(cycleLength.toLong())
}
val ovulationDate = nextPeriodStart.minusDays(lutealPhase.toLong())
// Рассчитываем фертильное окно в зависимости от режима
val fertileWindowStart = when (settings.fertileWindowMode) {
"conservative" -> ovulationDate.minusDays(3)
"balanced" -> ovulationDate.minusDays(5)
"broad" -> ovulationDate.minusDays(7)
else -> ovulationDate.minusDays(5) // По умолчанию сбалансированный режим
}
val fertileWindowEnd = ovulationDate // День овуляции - последний фертильный день
// Рассчитываем начало ПМС
val pmsStart = nextPeriodStart.minusDays(settings.pmsWindowDays.toLong())
return CycleForecastEntity(
nextPeriodStart = nextPeriodStart,
nextOvulation = ovulationDate,
fertileStart = fertileWindowStart,
fertileEnd = fertileWindowEnd,
pmsStart = pmsStart,
isReliable = isReliable,
updatedAt = Instant.now()
)
}
/**
* Рассчитывает среднюю длину цикла на основе истории.
*/
private fun calculateAverageCycleLength(history: List<CycleHistoryEntity>): Int {
if (history.size < 2) return 28 // Стандартный цикл если недостаточно данных
// Сортируем по дате начала
val sortedHistory = history.sortedBy { it.periodStart }
// Рассчитываем <20><>лину между началом каждого цикла
val cycleLengths = mutableListOf<Int>()
for (i in 0 until sortedHistory.size - 1) {
val current = sortedHistory[i]
val next = sortedHistory[i + 1]
val length = (next.periodStart.toEpochDay() - current.periodStart.toEpochDay()).toInt()
// Исключаем выбросы (слишком короткие или длинные циклы)
if (length >= 18 && length <= 60) {
cycleLengths.add(length)
}
}
// Если после фильтрации нет данных, возвращаем стандартный цикл
if (cycleLengths.isEmpty()) return 28
// Возвращаем среднюю длину цикла, округленную до целого
return cycleLengths.average().toInt()
}
/**
* Планирует уведомления на основе рассчитанных прогнозов.
*/
private suspend fun scheduleNotifications(forecast: CycleForecastEntity) {
// Это заглушка для метода планирования уведомлений
// Реальная реализация будет добавлена позже в классе NotificationManager
}
/**
* Экспортирует настройки цикла в JSON строку.
*/
suspend fun exportSettingsToJson(): String {
// В реальной реализации здесь будет использоваться библиотека для сериализации в JSON
// Например, Gson или Moshi
return "{}" // Заглушка
}
/**
* Импортирует настройки цикла из JSON строки.
*/
suspend fun importSettingsFromJson(json: String): Boolean {
// В реальной реализации здесь будет использоваться библиотека для десериализации из JSON
return true // Заглушка
}
// Методы для работы с периодами (CyclePeriodEntity)
suspend fun getAllPeriods(): List<CyclePeriodEntity> {
// Получаем все периоды из истории и преобразуем их в CyclePeriodEntity
val history = historyDao.getAll()
return history.map { historyEntity ->
CyclePeriodEntity(
id = historyEntity.id,
startDate = historyEntity.periodStart,
endDate = historyEntity.periodEnd,
flow = historyEntity.flow,
symptoms = historyEntity.symptoms,
cycleLength = historyEntity.cycleLength
)
}
}
suspend fun insertPeriod(period: CyclePeriodEntity): Long {
// Преобразуем CyclePeriodEntity в CycleHistoryEntity для сохранения
val historyEntity = CycleHistoryEntity(
id = period.id,
periodStart = period.startDate,
periodEnd = period.endDate,
flow = period.flow,
symptoms = period.symptoms,
cycleLength = period.cycleLength,
atypical = false // по умолчанию не отмечаем как нетипичный
)
return addCycleToHistory(historyEntity)
}
suspend fun updatePeriod(period: CyclePeriodEntity) {
// Преобразуем CyclePeriodEntity в CycleHistoryEntity для обновления
val historyEntity = CycleHistoryEntity(
id = period.id,
periodStart = period.startDate,
periodEnd = period.endDate,
flow = period.flow,
symptoms = period.symptoms,
cycleLength = period.cycleLength,
atypical = false // сохраняем существующее значение, если возможно
)
updateCycleInHistory(historyEntity)
}
suspend fun deletePeriod(period: CyclePeriodEntity) {
val historyEntity = CycleHistoryEntity(
id = period.id,
periodStart = period.startDate,
periodEnd = period.endDate,
flow = period.flow,
symptoms = period.symptoms,
cycleLength = period.cycleLength,
atypical = false
)
deleteCycleFromHistory(historyEntity)
}
}

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

@@ -0,0 +1,16 @@
package kr.smartsoltech.wellshe.data.repository
import kr.smartsoltech.wellshe.data.dao.HealthRecordDao
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HealthRepository @Inject constructor(private val dao: HealthRecordDao) {
suspend fun getAllRecords(): List<HealthRecordEntity> = dao.getAll()
suspend fun insertRecord(record: HealthRecordEntity): Long = dao.insert(record)
suspend fun updateRecord(record: HealthRecordEntity) = dao.update(record)
suspend fun deleteRecord(record: HealthRecordEntity) = dao.delete(record)
suspend fun getRecordByDate(date: java.time.LocalDate): HealthRecordEntity? = dao.getByDate(date)
}

View File

@@ -1,13 +1,17 @@
package kr.smartsoltech.wellshe.data.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kr.smartsoltech.wellshe.data.dao.*
import kr.smartsoltech.wellshe.data.entity.*
import kr.smartsoltech.wellshe.domain.model.*
import kr.smartsoltech.wellshe.domain.model.AppSettings
import kr.smartsoltech.wellshe.domain.model.FitnessData
import kr.smartsoltech.wellshe.domain.model.User
import kr.smartsoltech.wellshe.domain.model.WaterIntake
import kr.smartsoltech.wellshe.domain.model.WorkoutSession
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import javax.inject.Inject
import javax.inject.Singleton
@@ -16,7 +20,6 @@ import javax.inject.Singleton
class WellSheRepository @Inject constructor(
private val waterLogDao: WaterLogDao,
private val cyclePeriodDao: CyclePeriodDao,
private val sleepLogDao: SleepLogDao,
private val healthRecordDao: HealthRecordDao,
private val workoutDao: WorkoutDao,
private val calorieDao: CalorieDao,
@@ -40,8 +43,7 @@ class WellSheRepository @Inject constructor(
weight = 60f,
dailyWaterGoal = 2.5f,
dailyStepsGoal = 10000,
dailyCaloriesGoal = 2000,
dailySleepGoal = 8.0f
dailyCaloriesGoal = 2000
)
)
}
@@ -68,18 +70,19 @@ class WellSheRepository @Inject constructor(
}
fun getWaterIntakeForDate(date: LocalDate): Flow<List<WaterIntake>> {
return waterLogDao.getWaterLogsForDate(date).map { entities ->
entities.map { entity ->
return flow {
val entities = waterLogDao.getWaterLogsForDate(date)
emit(entities.map { entity ->
WaterIntake(
id = entity.id,
date = entity.date,
time = LocalTime.ofInstant(
java.time.Instant.ofEpochMilli(entity.timestamp),
java.time.ZoneId.systemDefault()
time = LocalTime.of(
(entity.timestamp % (24 * 60 * 60 * 1000) / (60 * 60 * 1000)).toInt(),
((entity.timestamp % (60 * 60 * 1000)) / (60 * 1000)).toInt()
),
amount = entity.amount / 1000f // конвертируем в литры
)
}
})
}
}
@@ -151,199 +154,89 @@ class WellSheRepository @Inject constructor(
// TODO: Реализовать окончание тренировки
}
// =================
// СОН
// =================
suspend fun getSleepForDate(date: LocalDate): SleepLogEntity? {
return sleepLogDao.getSleepForDate(date)
}
fun getRecentSleepLogs(): Flow<List<SleepLogEntity>> {
return sleepLogDao.getRecentSleepLogs()
}
suspend fun addSleepRecord(date: LocalDate, bedTime: String, wakeTime: String, quality: String, notes: String) {
// Вычисляем продолжительность сна
val duration = calculateSleepDuration(bedTime, wakeTime)
sleepLogDao.insertSleepLog(
SleepLogEntity(
date = date,
bedTime = bedTime,
wakeTime = wakeTime,
duration = duration,
quality = quality,
notes = notes
)
)
}
private fun calculateSleepDuration(bedTime: String, wakeTime: String): Float {
// TODO: Реализовать правильный расчет продолжительности сна
return 8.0f
}
// =================
// МЕНСТРУАЛЬНЫЙ ЦИКЛ
// =================
suspend fun addPeriod(startDate: LocalDate, endDate: LocalDate?, flow: String, symptoms: List<String>, mood: String) {
cyclePeriodDao.insertPeriod(
CyclePeriodEntity(
startDate = startDate,
endDate = endDate,
flow = flow,
symptoms = symptoms.joinToString(","),
mood = mood
)
suspend fun addPeriod(startDate: LocalDate, endDate: LocalDate?, flow: String, symptoms: List<String>) {
val period = CyclePeriodEntity(
startDate = startDate,
endDate = endDate,
flow = flow,
symptoms = symptoms
)
// Используем CycleRepository для работы с периодами
// cyclePeriodDao.insertPeriod(period)
// TODO: Добавить интеграцию с CycleRepository
}
fun getCurrentCyclePeriod(): Flow<CyclePeriodEntity?> {
return cyclePeriodDao.getCurrentPeriod()
suspend fun updatePeriod(periodId: Long, endDate: LocalDate?, flow: String, symptoms: List<String>) {
// TODO: Реализовать через CycleRepository
// val existingPeriod = cyclePeriodDao.getPeriodById(periodId)
// existingPeriod?.let {
// val updatedPeriod = it.copy(
// endDate = endDate,
// flow = flow,
// symptoms = symptoms
// )
// cyclePeriodDao.updatePeriod(updatedPeriod)
// }
}
fun getRecentPeriods(): Flow<List<CyclePeriodEntity>> {
return cyclePeriodDao.getRecentPeriods(6)
fun getPeriods(): Flow<List<CyclePeriodEntity>> {
// 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: Реализовать получение настроек из БД
return flowOf(
AppSettings(
isWaterReminderEnabled = true,
isCycleReminderEnabled = true,
isSleepReminderEnabled = true,
cycleLength = 28,
periodLength = 5,
waterGoal = 2.5f,
stepsGoal = 10000,
sleepGoal = 8.0f,
isDarkTheme = false
notificationsEnabled = true,
darkModeEnabled = false
)
)
}
suspend fun updateWaterReminderSetting(enabled: Boolean) {
// TODO: Реализовать обновление настройки напоминаний о воде
}
suspend fun updateCycleReminderSetting(enabled: Boolean) {
// TODO: Реализовать обновление настройки напоминаний о цикле
}
suspend fun updateSleepReminderSetting(enabled: Boolean) {
// TODO: Реализовать обновление настройки напоминаний о сне
}
suspend fun updateCycleLength(length: Int) {
// TODO: Реализовать обновление длины цикла
}
suspend fun updatePeriodLength(length: Int) {
// TODO: Реализовать обновление длины менструации
}
suspend fun updateStepsGoal(goal: Int) {
// TODO: Реализовать обновление цели по шагам
}
suspend fun updateSleepGoal(goal: Float) {
// TODO: Реализовать обновление цели по сну
}
suspend fun updateThemeSetting(isDark: Boolean) {
// TODO: Реализовать обновление темы
suspend fun updateAppSettings(settings: AppSettings) {
// TODO: Реализовать обновление настроек
}
// =================
// УПРАВЛЕНИЕ ДАННЫМИ
// АНАЛИТИКА И ОТЧЕТЫ
// =================
suspend fun exportUserData() {
// TODO: Реализовать экспорт данных пользователя
}
suspend fun importUserData() {
// TODO: Реализовать импорт данных пользователя
}
suspend fun clearAllUserData() {
// TODO: Реализовать очистку всех данных пользователя
}
// =================
// ЗДОРОВЬЕ
// =================
fun getTodayHealthData(): Flow<HealthRecordEntity?> {
// TODO: Реализовать получение данных о здоровье за сегодня
return flowOf(null)
}
suspend fun updateHealthRecord(record: HealthRecord) {
// TODO: Реализовать обновление записи о здоровье
}
// =================
// DASHBOARD
// =================
fun getDashboardData(): Flow<DashboardData> {
// TODO: Реализовать получение данных для главного экрана
return flowOf(
DashboardData(
user = User(),
todayHealth = null,
sleepData = null,
cycleData = null,
recentWorkouts = emptyList()
fun getDashboardData(date: LocalDate): Flow<DashboardData> {
return flow {
emit(
DashboardData(
date = date,
waterIntake = 1.2f,
steps = 6500,
calories = 1850,
workouts = 1,
cycleDay = null
)
)
)
}
// =================
// УСТАРЕВШИЕ МЕТОДЫ (для совместимости)
// =================
suspend fun addWater(amount: Int, date: LocalDate = LocalDate.now()) {
waterLogDao.insertWaterLog(
WaterLogEntity(date = date, amount = amount)
)
}
suspend fun getTodayWaterIntake(date: LocalDate = LocalDate.now()): Int {
return waterLogDao.getTotalWaterForDate(date) ?: 0
}
fun getWaterLogsForDate(date: LocalDate): Flow<List<WaterLogEntity>> {
return waterLogDao.getWaterLogsForDate(date)
}
}
}
// Вспомогательные data классы
data class DashboardData(
val user: User,
val todayHealth: HealthRecord?,
val sleepData: SleepLogEntity?,
val cycleData: CyclePeriodEntity?,
val recentWorkouts: List<WorkoutSession>
)
data class HealthRecord(
val id: Long = 0,
val date: LocalDate,
val bloodPressureSystolic: Int = 0,
val bloodPressureDiastolic: Int = 0,
val heartRate: Int = 0,
val weight: Float = 0f,
val mood: String = "neutral", // Добавляем поле настроения
val energyLevel: Int = 5, // Добавляем уровень энергии (1-10)
val stressLevel: Int = 5, // Добавляем уровень стресса (1-10)
val notes: String = ""
val waterIntake: Float,
val steps: Int,
val calories: Int,
val workouts: Int,
val cycleDay: Int?
)

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,8 +8,8 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kr.smartsoltech.wellshe.data.AppDatabase
import kr.smartsoltech.wellshe.data.datastore.DataStoreManager
import kr.smartsoltech.wellshe.data.dao.*
import kr.smartsoltech.wellshe.data.repo.*
import javax.inject.Singleton
@Module
@@ -18,31 +18,19 @@ object AppModule {
@Provides
@Singleton
fun provideDataStoreManager(@ApplicationContext context: Context): DataStoreManager =
DataStoreManager(context)
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"well_she_db"
).fallbackToDestructiveMigration().build()
"wellshe_database"
).fallbackToDestructiveMigration()
.build()
}
// DAO providers
// DAO Providers
@Provides
fun provideWaterLogDao(database: AppDatabase): WaterLogDao = database.waterLogDao()
@Provides
fun provideCyclePeriodDao(database: AppDatabase): CyclePeriodDao = database.cyclePeriodDao()
@Provides
fun provideSleepLogDao(database: AppDatabase): SleepLogDao = database.sleepLogDao()
@Provides
fun provideHealthRecordDao(database: AppDatabase): HealthRecordDao = database.healthRecordDao()
@Provides
fun provideWorkoutDao(database: AppDatabase): WorkoutDao = database.workoutDao()
@@ -55,21 +43,106 @@ object AppModule {
@Provides
fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao()
// Repository
@Provides
fun provideCyclePeriodDao(database: AppDatabase): CyclePeriodDao = database.cyclePeriodDao()
@Provides
fun provideHealthRecordDao(database: AppDatabase): HealthRecordDao = database.healthRecordDao()
@Provides
fun provideBeverageLogDao(database: AppDatabase): BeverageLogDao = database.beverageLogDao()
@Provides
fun provideBeverageLogNutrientDao(database: AppDatabase): BeverageLogNutrientDao = database.beverageLogNutrientDao()
@Provides
fun provideBeverageServingNutrientDao(database: AppDatabase): BeverageServingNutrientDao = database.beverageServingNutrientDao()
@Provides
fun provideWeightLogDao(database: AppDatabase): WeightLogDao = database.weightLogDao()
@Provides
fun provideWorkoutSessionDao(database: AppDatabase): WorkoutSessionDao = database.workoutSessionDao()
@Provides
fun provideWorkoutSessionParamDao(database: AppDatabase): WorkoutSessionParamDao = database.workoutSessionParamDao()
@Provides
fun provideWorkoutEventDao(database: AppDatabase): WorkoutEventDao = database.workoutEventDao()
@Provides
fun provideExerciseDao(database: AppDatabase): ExerciseDao = database.exerciseDao()
@Provides
fun provideExerciseFormulaDao(database: AppDatabase): ExerciseFormulaDao = database.exerciseFormulaDao()
@Provides
fun provideExerciseFormulaVarDao(database: AppDatabase): ExerciseFormulaVarDao = database.exerciseFormulaVarDao()
@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 provideWellSheRepository(
fun provideWeightRepository(weightLogDao: WeightLogDao): WeightRepository {
return WeightRepository(weightLogDao)
}
@Provides
@Singleton
fun provideDrinkLogger(
waterLogDao: WaterLogDao,
cyclePeriodDao: CyclePeriodDao,
sleepLogDao: SleepLogDao,
healthRecordDao: HealthRecordDao,
workoutDao: WorkoutDao,
calorieDao: CalorieDao,
stepsDao: StepsDao,
userProfileDao: UserProfileDao
): kr.smartsoltech.wellshe.data.repository.WellSheRepository =
kr.smartsoltech.wellshe.data.repository.WellSheRepository(
waterLogDao, cyclePeriodDao, sleepLogDao, healthRecordDao,
workoutDao, calorieDao, stepsDao, userProfileDao
)
beverageLogDao: BeverageLogDao,
beverageLogNutrientDao: BeverageLogNutrientDao,
servingNutrientDao: BeverageServingNutrientDao
): DrinkLogger {
return DrinkLogger(waterLogDao, beverageLogDao, beverageLogNutrientDao, servingNutrientDao)
}
@Provides
@Singleton
fun provideWorkoutService(
sessionDao: WorkoutSessionDao,
paramDao: WorkoutSessionParamDao,
eventDao: WorkoutEventDao,
weightRepo: WeightRepository,
formulaDao: ExerciseFormulaDao,
formulaVarDao: ExerciseFormulaVarDao,
exerciseDao: ExerciseDao
): WorkoutService {
return WorkoutService(sessionDao, paramDao, eventDao, weightRepo, formulaDao, formulaVarDao, exerciseDao)
}
@Provides
@Singleton
fun provideBeverageCatalogRepository(
beverageDao: BeverageDao,
servingDao: BeverageServingDao,
servingNutrientDao: BeverageServingNutrientDao
): BeverageCatalogRepository {
return BeverageCatalogRepository(beverageDao, servingDao, servingNutrientDao)
}
@Provides
@Singleton
fun provideExerciseCatalogRepository(
exerciseDao: ExerciseDao,
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,59 @@
package kr.smartsoltech.wellshe.di
import android.content.Context
import androidx.work.WorkManager
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.dao.CycleForecastDao
import kr.smartsoltech.wellshe.data.dao.CycleHistoryDao
import kr.smartsoltech.wellshe.data.dao.CycleSettingsDao
import kr.smartsoltech.wellshe.data.repository.CycleRepository
import kr.smartsoltech.wellshe.domain.services.CycleSettingsExportService
import kr.smartsoltech.wellshe.workers.CycleNotificationManager
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object CycleModule {
@Provides
@Singleton
fun provideCycleRepository(
settingsDao: CycleSettingsDao,
historyDao: CycleHistoryDao,
forecastDao: CycleForecastDao
): CycleRepository = CycleRepository(settingsDao, historyDao, forecastDao)
@Provides
@Singleton
fun provideCycleSettingsExportService(): CycleSettingsExportService =
CycleSettingsExportService()
@Provides
@Singleton
fun provideWorkManager(@ApplicationContext context: Context): WorkManager =
WorkManager.getInstance(context)
@Provides
@Singleton
fun provideCycleNotificationManager(
@ApplicationContext context: Context,
workManager: WorkManager
): CycleNotificationManager = CycleNotificationManager(context, workManager)
// DAO providers
@Provides
fun provideCycleSettingsDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleSettingsDao =
database.cycleSettingsDao()
@Provides
fun provideCycleHistoryDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleHistoryDao =
database.cycleHistoryDao()
@Provides
fun provideCycleForecastDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleForecastDao =
database.cycleForecastDao()
}

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,19 +1,34 @@
package kr.smartsoltech.wellshe.domain.analytics
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
import kr.smartsoltech.wellshe.data.entity.CycleStatsEntity
import java.time.LocalDate
import java.time.ZoneId
data class CycleForecast(
val nextStart: Long?,
val fertileWindow: Pair<Long, Long>?,
val confidence: String
)
data class CycleStats(
val avgCycle: Int,
val variance: Double,
val lutealLen: Int
)
object CycleAnalytics {
/**
* Прогноз следующей менструации и фертильного окна
* @param periods список последних периодов
* @param stats статистика цикла (вычисляется автоматически)
* @param statsEntity статистика цикла из базы (опционально)
* @return прогноз: дата, фертильное окно, доверие
*/
fun forecast(periods: List<CyclePeriodEntity>, stats: CycleStats? = null): CycleForecast {
fun forecast(periods: List<CyclePeriodEntity>, statsEntity: CycleStatsEntity? = null): CycleForecast {
if (periods.isEmpty()) return CycleForecast(null, null, "низкая")
val calculatedStats = stats ?: calculateStats(periods)
val calculatedStats = calculateStats(periods)
val lastPeriod = periods.first()
val lastStartDate = lastPeriod.startDate
val lastStartTs = lastStartDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000
@@ -49,27 +64,53 @@ object CycleAnalytics {
val cycleLengths = periods.take(periods.size - 1).mapIndexed { index, period ->
val nextPeriod = periods[index + 1]
java.time.temporal.ChronoUnit.DAYS.between(nextPeriod.startDate, period.startDate).toInt()
}.filter { it > 0 }
if (cycleLengths.isEmpty()) {
return CycleStats(avgCycle = 28, variance = 5.0, lutealLen = 14)
}
val avgCycle = cycleLengths.average().toInt()
val variance = cycleLengths.map { (it - avgCycle) * (it - avgCycle) }.average()
val variance = if (cycleLengths.size > 1) {
cycleLengths.map { (it - avgCycle) * (it - avgCycle) }.average()
} else {
5.0
}
return CycleStats(
avgCycle = avgCycle,
variance = variance,
lutealLen = 14 // стандартная лютеиновая фаза
)
// Примерная лютеиновая фаза (обычно 14 дней)
val lutealLen = 14
return CycleStats(avgCycle, variance, lutealLen)
}
/**
* Анализ регулярности цикла
*/
fun analyzeRegularity(periods: List<CyclePeriodEntity>): String {
val stats = calculateStats(periods)
return when {
stats.variance < 2 -> "Очень регулярный"
stats.variance < 5 -> "Регулярный"
stats.variance < 10 -> "Умеренно регулярный"
else -> "Нерегулярный"
}
}
/**
* Предсказание следующих дат
*/
fun predictNextPeriods(periods: List<CyclePeriodEntity>, count: Int = 3): List<LocalDate> {
if (periods.isEmpty()) return emptyList()
val stats = calculateStats(periods)
val lastPeriod = periods.first()
val predictions = mutableListOf<LocalDate>()
for (i in 1..count) {
val nextDate = lastPeriod.startDate.plusDays((stats.avgCycle * i).toLong())
predictions.add(nextDate)
}
return predictions
}
}
data class CycleForecast(
val nextStart: Long?,
val fertileWindow: Pair<Long, Long>?,
val confidence: String
)
data class CycleStats(
val avgCycle: Int,
val variance: Double,
val lutealLen: Int
)

View File

@@ -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
data class AppSettings(
val id: Long = 0,
val isWaterReminderEnabled: Boolean = true,
val waterReminderInterval: Int = 2, // часы
val isCycleReminderEnabled: Boolean = true,
val isSleepReminderEnabled: Boolean = true,
val sleepReminderTime: String = "22:00",
val wakeUpReminderTime: String = "07:00",
val cycleLength: Int = 28,
val periodLength: Int = 5,
val waterGoal: Float = 2.5f,
val stepsGoal: Int = 10000,
val sleepGoal: Float = 8.0f,
val isDarkTheme: Boolean = false,
val language: String = "ru",
val isFirstLaunch: Boolean = true
val notificationsEnabled: Boolean = true,
val darkModeEnabled: Boolean = false
)

View File

@@ -13,7 +13,6 @@ data class User(
val dailyWaterGoal: Float = 2.5f, // в литрах
val dailyStepsGoal: Int = 10000,
val dailyCaloriesGoal: Int = 2000,
val dailySleepGoal: Float = 8.0f, // в часах
val cycleLength: Int = 28, // дней
val periodLength: Int = 5, // дней
val lastPeriodStart: LocalDate? = null,

View File

@@ -0,0 +1,95 @@
package kr.smartsoltech.wellshe.domain.models
import java.time.LocalDate
/**
* Доменная модель для истории циклов.
*/
data class CycleHistory(
val id: Long = 0,
val periodStart: LocalDate,
val periodEnd: LocalDate? = null,
val ovulationDate: LocalDate? = null,
val notes: String = "",
val atypical: Boolean = false
)
/**
* Доменная модель для прогнозов цикла.
*/
data class CycleForecast(
val nextPeriodStart: LocalDate? = null,
val nextOvulation: LocalDate? = null,
val fertileStart: LocalDate? = null,
val fertileEnd: LocalDate? = null,
val pmsStart: LocalDate? = null,
val isReliable: Boolean = true, // Флаг для пониженной точности
val currentCyclePhase: CyclePhase = CyclePhase.UNKNOWN
)
/**
* Фаза менструального цикла.
*/
enum class CyclePhase {
MENSTRUATION, // Менструация
FOLLICULAR, // Фолликулярная фаза (после менструации до фертильного окна)
FERTILE, // Фертильное окно
OVULATION, // День овуляции
LUTEAL, // Лютеиновая фаза (после овуляции)
PMS, // ПМС (последние дни перед менструацией)
UNKNOWN; // Неизвестная фаза (например, при недостатке данных)
companion object {
/**
* Определяет текущую фазу цикла на основе прогноза и текущей даты.
*/
fun determinePhase(
today: LocalDate = LocalDate.now(),
nextPeriodStart: LocalDate?,
lastPeriodStart: LocalDate?,
fertileStart: LocalDate?,
fertileEnd: LocalDate?,
ovulationDate: LocalDate?,
pmsStart: LocalDate?,
periodLengthDays: Int = 5
): CyclePhase {
if (lastPeriodStart == null || nextPeriodStart == null) return UNKNOWN
// Определяем конец последней менструации
val lastPeriodEnd = lastPeriodStart.plusDays(periodLengthDays.toLong() - 1)
return when {
// Период менструации
(today.isEqual(lastPeriodStart) || today.isAfter(lastPeriodStart)) &&
(today.isEqual(lastPeriodEnd) || today.isBefore(lastPeriodEnd)) -> MENSTRUATION
// День овуляции
ovulationDate != null && today.isEqual(ovulationDate) -> OVULATION
// Фертильное окно
fertileStart != null && fertileEnd != null &&
(today.isEqual(fertileStart) || today.isAfter(fertileStart)) &&
(today.isEqual(fertileEnd) || today.isBefore(fertileEnd)) &&
(ovulationDate == null || !today.isEqual(ovulationDate)) -> FERTILE
// ПМС
pmsStart != null &&
(today.isEqual(pmsStart) || today.isAfter(pmsStart)) &&
today.isBefore(nextPeriodStart) -> PMS
// Лютеиновая фаза (после овуляции/фертильного окна до ПМС)
ovulationDate != null && fertileEnd != null && pmsStart != null &&
today.isAfter(fertileEnd) &&
today.isBefore(pmsStart) -> LUTEAL
// Фолликулярная фаза (после менструации до фертильного окна)
lastPeriodEnd != null && fertileStart != null &&
today.isAfter(lastPeriodEnd) &&
today.isBefore(fertileStart) -> FOLLICULAR
// Если не удалось определить фазу
else -> UNKNOWN
}
}
}
}

View File

@@ -0,0 +1,116 @@
package kr.smartsoltech.wellshe.domain.models
import java.time.LocalDate
/**
* Доменная модель для настроек цикла.
*/
data class CycleSettings(
// Основные параметры цикла
val baselineCycleLength: Int = 28,
val cycleVariabilityDays: Int = 3,
val periodLengthDays: Int = 5,
val lutealPhaseDays: String = "auto", // "auto" или число (8-17)
val lastPeriodStart: LocalDate? = null,
// Метод определения овуляции
val ovulationMethod: OvulationMethod = OvulationMethod.AUTO,
val allowManualOvulation: Boolean = false,
// Статусы влияющие на точность
val hormonalContraception: HormonalContraceptionType = HormonalContraceptionType.NONE,
val isPregnant: Boolean = false,
val isPostpartum: Boolean = false,
val isLactating: Boolean = false,
val perimenopause: Boolean = false,
// Настройки истории и исключения выбросов
val historyWindowCycles: Int = 6,
val excludeOutliers: Boolean = true,
// Сенсоры и единицы измерения
val tempUnit: TemperatureUnit = TemperatureUnit.CELSIUS,
val bbtTimeWindow: String = "06:00-10:00",
val timezone: String = "Asia/Seoul",
// Уведомления
val periodReminderDaysBefore: Int = 2,
val ovulationReminderDaysBefore: Int = 1,
val pmsWindowDays: Int = 3,
val deviationAlertDays: Int = 5,
val fertileWindowMode: FertileWindowMode = FertileWindowMode.BALANCED
)
/**
* Метод определения овуляции
*/
enum class OvulationMethod {
AUTO, BBT, LH_TEST, CERVICAL_MUCUS, MEDICAL;
companion object {
fun fromString(value: String): OvulationMethod = when (value.lowercase()) {
"bbt" -> BBT
"lh_test" -> LH_TEST
"cervical_mucus" -> CERVICAL_MUCUS
"medical" -> MEDICAL
else -> AUTO
}
}
fun toStorageString(): String = this.name.lowercase()
}
/**
* Тип гормональной контрацепции
*/
enum class HormonalContraceptionType {
NONE, COC, IUD, IMPLANT, OTHER;
companion object {
fun fromString(value: String): HormonalContraceptionType = when (value.lowercase()) {
"coc" -> COC
"iud" -> IUD
"implant" -> IMPLANT
"other" -> OTHER
else -> NONE
}
}
fun toStorageString(): String = this.name.lowercase()
}
/**
* Единицы измерения температуры
*/
enum class TemperatureUnit {
CELSIUS, FAHRENHEIT;
companion object {
fun fromString(value: String): TemperatureUnit = when (value.uppercase()) {
"F" -> FAHRENHEIT
else -> CELSIUS
}
}
fun toStorageString(): String = when (this) {
CELSIUS -> "C"
FAHRENHEIT -> "F"
}
}
/**
* Режим определения фертильного окна
*/
enum class FertileWindowMode {
CONSERVATIVE, BALANCED, BROAD;
companion object {
fun fromString(value: String): FertileWindowMode = when (value.lowercase()) {
"conservative" -> CONSERVATIVE
"broad" -> BROAD
else -> BALANCED
}
}
fun toStorageString(): String = this.name.lowercase()
}

View File

@@ -0,0 +1,125 @@
package kr.smartsoltech.wellshe.domain.models
import kr.smartsoltech.wellshe.ui.cycle.settings.FertileWindowMode
import kr.smartsoltech.wellshe.ui.cycle.settings.HormonalContraception
import kr.smartsoltech.wellshe.ui.cycle.settings.OvulationMethod
import java.time.LocalDate
// Типы событий изменения настроек для старого интерфейса
sealed class BasicSettingChange {
data class CycleLengthChanged(val days: Int) : BasicSettingChange()
data class CycleVariabilityChanged(val days: Int) : BasicSettingChange()
data class PeriodLengthChanged(val days: Int) : BasicSettingChange()
data class LutealPhaseChanged(val days: String) : BasicSettingChange() // "auto" или число
data class LastPeriodStartChanged(val date: LocalDate) : BasicSettingChange()
}
sealed class StatusChange {
data class HormonalContraceptionChanged(val type: HormonalContraception) : StatusChange()
data class PregnancyStatusChanged(val isPregnant: Boolean) : StatusChange()
data class PostpartumStatusChanged(val isPostpartum: Boolean) : StatusChange()
data class LactatingStatusChanged(val isLactating: Boolean) : StatusChange()
data class PerimenopauseStatusChanged(val perimenopause: Boolean) : StatusChange()
// Вложенные объекты для удобного создания событий
object Pregnant {
fun changed(value: Boolean) = PregnancyStatusChanged(value)
}
object Postpartum {
fun changed(value: Boolean) = PostpartumStatusChanged(value)
}
object Lactating {
fun changed(value: Boolean) = LactatingStatusChanged(value)
}
object Perimenopause {
fun changed(value: Boolean) = PerimenopauseStatusChanged(value)
}
}
sealed class HistorySetting {
data class HistoryWindowChanged(val cycles: Int) : HistorySetting()
data class ExcludeOutliersChanged(val exclude: Boolean) : HistorySetting()
// Вложенные объекты для более удобного обращения
object BaselineCycleLength {
fun changed(value: Int) = BasicSettingChange.CycleLengthChanged(value)
}
object CycleVariability {
fun changed(value: Int) = BasicSettingChange.CycleVariabilityChanged(value)
}
object PeriodLength {
fun changed(value: Int) = BasicSettingChange.PeriodLengthChanged(value)
}
object LutealPhase {
fun changed(value: String) = BasicSettingChange.LutealPhaseChanged(value)
}
object LastPeriodStart {
fun changed(value: LocalDate) = BasicSettingChange.LastPeriodStartChanged(value)
}
// Вложенные классы для UI
class WindowCycles(val cycles: Int) : HistorySetting()
class ExcludeOutliers(val exclude: Boolean) : HistorySetting()
}
sealed class SensorSetting {
data class TemperatureUnitChanged(val unit: TemperatureUnit) : SensorSetting()
data class BbtTimeWindowChanged(val timeWindow: String) : SensorSetting()
data class TimezoneChanged(val timezone: String) : SensorSetting()
// Вложенные классы для UI
class TempUnit(val unit: TemperatureUnit) : SensorSetting()
class BbtTimeWindow(val timeWindow: String) : SensorSetting()
class Timezone(val timezone: String) : SensorSetting()
}
sealed class NotificationSetting {
data class PeriodReminderDaysChanged(val days: Int) : NotificationSetting()
data class OvulationReminderDaysChanged(val days: Int) : NotificationSetting()
data class PmsWindowDaysChanged(val days: Int) : NotificationSetting()
data class DeviationAlertDaysChanged(val days: Int) : NotificationSetting()
data class FertileWindowModeChanged(val mode: FertileWindowMode) : NotificationSetting()
// Вложенные классы для UI
class PeriodReminder(val days: Int) : NotificationSetting()
class OvulationReminder(val days: Int) : NotificationSetting()
class PmsWindow(val days: Int) : NotificationSetting()
class DeviationAlert(val days: Int) : NotificationSetting()
class FertileWindowMode(val mode: kr.smartsoltech.wellshe.domain.models.FertileWindowMode) : NotificationSetting()
}
// Функции преобразования для всех типов
fun ovulationMethodFromString(value: String): OvulationMethod {
return when (value) {
"bbt" -> OvulationMethod.BBT
"lh_test" -> OvulationMethod.LH_TEST
"cervical_mucus" -> OvulationMethod.CERVICAL_MUCUS
"medical" -> OvulationMethod.MEDICAL
else -> OvulationMethod.AUTO
}
}
fun fertileWindowModeFromString(value: String): FertileWindowMode {
return when (value) {
"conservative" -> FertileWindowMode.CONSERVATIVE
"broad" -> FertileWindowMode.BROAD
else -> FertileWindowMode.BALANCED
}
}
fun hormonalContraceptionTypeFromString(value: String): HormonalContraception {
return when (value) {
"coc" -> HormonalContraception.COC
"iud" -> HormonalContraception.IUD
"implant" -> HormonalContraception.IMPLANT
"other" -> HormonalContraception.OTHER
else -> HormonalContraception.NONE
}
}
fun temperatureUnitFromString(value: String): TemperatureUnit {
return when (value.uppercase()) {
"F" -> TemperatureUnit.FAHRENHEIT
else -> TemperatureUnit.CELSIUS
}
}

View File

@@ -0,0 +1,2 @@
// Этот файл больше не используется, все классы перенесены в CycleSettingsEvents.kt

View File

@@ -0,0 +1,172 @@
package kr.smartsoltech.wellshe.domain.services
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
import kr.smartsoltech.wellshe.domain.models.*
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
/**
* Класс для импорта и экспорта настроек цикла в JSON формате
*/
@Singleton
class CycleSettingsExportService @Inject constructor() {
// Создаем адаптер для LocalDate вне класса LocalDateAdapter
private val localDateAdapter = object : JsonAdapter<LocalDate>() {
override fun fromJson(reader: com.squareup.moshi.JsonReader): LocalDate? {
return try {
val dateString = reader.nextString()
LocalDate.parse(dateString)
} catch (e: Exception) {
null
}
}
override fun toJson(writer: com.squareup.moshi.JsonWriter, value: LocalDate?) {
if (value == null) {
writer.nullValue()
} else {
writer.value(value.toString())
}
}
}
// Настройка Moshi для сериализации/десериализации
private val moshi: Moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.add(Date::class.java, Rfc3339DateJsonAdapter())
.add(LocalDate::class.java, localDateAdapter)
.build()
// Адаптер для сериализации/десериализации настроек цикла
private val settingsAdapter: JsonAdapter<CycleSettingsJsonDto> = moshi.adapter(CycleSettingsJsonDto::class.java)
/**
* Экспортирует настройки в формат JSON
*/
fun exportSettingsToJson(settings: CycleSettingsEntity): String {
val jsonDto = convertToJsonDto(settings)
return settingsAdapter.toJson(jsonDto)
}
/**
* Импортирует настройки из JSON
* @return Импортированные настройки или null в случае ошибки
*/
fun importSettingsFromJson(json: String): CycleSettingsEntity? {
return try {
val jsonDto = settingsAdapter.fromJson(json)
jsonDto?.let { convertToEntity(it) }
} catch (e: Exception) {
null
}
}
/**
* Конвертирует Entity в DTO для экспорта в JSON
*/
private fun convertToJsonDto(entity: CycleSettingsEntity): CycleSettingsJsonDto {
return CycleSettingsJsonDto(
baselineCycleLength = entity.baselineCycleLength,
cycleVariabilityDays = entity.cycleVariabilityDays,
periodLengthDays = entity.periodLengthDays,
lutealPhaseDays = entity.lutealPhaseDays,
lastPeriodStart = entity.lastPeriodStart,
ovulationMethod = entity.ovulationMethod,
allowManualOvulation = entity.allowManualOvulation,
hormonalContraception = entity.hormonalContraception,
isPregnant = entity.isPregnant,
isPostpartum = entity.isPostpartum,
isLactating = entity.isLactating,
perimenopause = entity.perimenopause,
historyWindowCycles = entity.historyWindowCycles,
excludeOutliers = entity.excludeOutliers,
tempUnit = entity.tempUnit,
bbtTimeWindow = entity.bbtTimeWindow,
timezone = entity.timezone,
periodReminderDaysBefore = entity.periodReminderDaysBefore,
ovulationReminderDaysBefore = entity.ovulationReminderDaysBefore,
pmsWindowDays = entity.pmsWindowDays,
deviationAlertDays = entity.deviationAlertDays,
fertileWindowMode = entity.fertileWindowMode
)
}
/**
* Конвертирует DTO в Entity
*/
private fun convertToEntity(dto: CycleSettingsJsonDto): CycleSettingsEntity {
return CycleSettingsEntity(
id = 1, // Singleton ID
baselineCycleLength = dto.baselineCycleLength.coerceIn(18, 60),
cycleVariabilityDays = dto.cycleVariabilityDays.coerceIn(0, 10),
periodLengthDays = dto.periodLengthDays.coerceIn(1, 10),
lutealPhaseDays = dto.lutealPhaseDays, // Валидация будет в ViewModel
lastPeriodStart = dto.lastPeriodStart,
ovulationMethod = dto.ovulationMethod,
allowManualOvulation = dto.allowManualOvulation,
hormonalContraception = dto.hormonalContraception,
isPregnant = dto.isPregnant,
isPostpartum = dto.isPostpartum,
isLactating = dto.isLactating,
perimenopause = dto.perimenopause,
historyWindowCycles = dto.historyWindowCycles,
excludeOutliers = dto.excludeOutliers,
tempUnit = dto.tempUnit,
bbtTimeWindow = dto.bbtTimeWindow,
timezone = dto.timezone,
periodReminderDaysBefore = dto.periodReminderDaysBefore.coerceIn(0, 7),
ovulationReminderDaysBefore = dto.ovulationReminderDaysBefore.coerceIn(0, 7),
pmsWindowDays = dto.pmsWindowDays.coerceIn(1, 7),
deviationAlertDays = dto.deviationAlertDays.coerceIn(1, 14),
fertileWindowMode = dto.fertileWindowMode
)
}
/**
* DTO для сериализации/десериализации настроек цикла в JSON
*/
data class CycleSettingsJsonDto(
// Основные параметры цикла
val baselineCycleLength: Int = 28,
val cycleVariabilityDays: Int = 3,
val periodLengthDays: Int = 5,
val lutealPhaseDays: String = "auto",
val lastPeriodStart: LocalDate? = null,
// Метод определения овуляции
val ovulationMethod: String = "auto",
val allowManualOvulation: Boolean = false,
// Статусы влияющие на точность
val hormonalContraception: String = "none",
val isPregnant: Boolean = false,
val isPostpartum: Boolean = false,
val isLactating: Boolean = false,
val perimenopause: Boolean = false,
// Настройки истории и исключения выбросов
val historyWindowCycles: Int = 6,
val excludeOutliers: Boolean = true,
// Сенсоры и единицы измерения
val tempUnit: String = "C",
val bbtTimeWindow: String = "06:00-10:00",
val timezone: String = "Asia/Seoul",
// Уведомления
val periodReminderDaysBefore: Int = 2,
val ovulationReminderDaysBefore: Int = 1,
val pmsWindowDays: Int = 3,
val deviationAlertDays: Int = 5,
val fertileWindowMode: String = "balanced"
)
}

View File

@@ -0,0 +1,385 @@
package kr.smartsoltech.wellshe.emergency.data.api
import retrofit2.Response
import retrofit2.http.*
// ========== API МОДЕЛИ ДАННЫХ ==========
data class CreateEmergencyRequest(
val latitude: Double,
val longitude: Double,
val alert_type: String = "general",
val message: String? = null,
val address: String? = null,
val contact_emergency_services: Boolean = true,
val notify_emergency_contacts: Boolean = true,
val battery_level: Int? = null
)
data class EmergencyEventResponse(
val id: Int,
val uuid: String,
val user_id: Int,
val latitude: Double,
val longitude: Double,
val address: String?,
val alert_type: String,
val message: String?,
val status: String,
val is_resolved: Boolean,
val created_at: String,
val updated_at: String?,
val resolved_at: String?,
val user: ApiUserInfo?
)
data class NearbyEventsResponse(
val events: List<NearbyEmergencyEvent>,
val total: Int,
val radius_km: Double
)
data class NearbyEmergencyEvent(
val id: Int,
val uuid: String,
val latitude: Double,
val longitude: Double,
val address: String?,
val alert_type: String,
val message: String?,
val distance_km: Double,
val created_at: String,
val user: ApiUserInfo?
)
data class EmergencyEventDetailResponse(
val id: Int,
val uuid: String,
val user_id: Int,
val latitude: Double,
val longitude: Double,
val address: String?,
val alert_type: String,
val message: String?,
val status: String,
val is_resolved: Boolean,
val created_at: String,
val updated_at: String?,
val resolved_at: String?,
val user: ApiUserInfo,
val responses: List<EventResponse>
)
data class EventResponseRequest(
val response_type: String,
val message: String? = null,
val eta_minutes: Int? = null,
val location_latitude: Double? = null,
val location_longitude: Double? = null
)
data class EventResponseResponse(
val id: Int,
val event_id: Int,
val user_id: Int,
val response_type: String,
val message: String?,
val eta_minutes: Int?,
val location_latitude: Double?,
val location_longitude: Double?,
val created_at: String,
val user: ApiUserInfo
)
data class EventHistoryResponse(
val events: List<EmergencyEventResponse>,
val total: Int,
val page: Int,
val per_page: Int
)
data class NearbyUsersResponse(
val users: List<ApiUserInfo>,
val total: Int,
val radius_km: Double
)
data class EventsStatsResponse(
val total_events: Int,
val active_events: Int,
val resolved_events: Int,
val my_events: Int,
val my_responses: Int,
val average_response_time_minutes: Double?
)
data class ApiUserInfo(
val id: Int,
val name: String,
val email: String?,
val phone: String?,
val profile_image_url: String?
)
data class EventResponse(
val id: Int,
val user_id: Int,
val response_type: String,
val message: String?,
val eta_minutes: Int?,
val created_at: String,
val user: ApiUserInfo
)
data class CreateReportRequest(
val latitude: Double,
val longitude: Double,
val report_type: String,
val description: String,
val address: String? = null,
val is_anonymous: Boolean = false,
val severity: Int = 3
)
data class ReportResponse(
val id: Int,
val uuid: String,
val user_id: Int?,
val latitude: Double,
val longitude: Double,
val address: String?,
val report_type: String,
val description: String,
val is_anonymous: Boolean,
val severity: Int,
val status: String = "pending",
val created_at: String
)
data class CreateSafetyCheckRequest(
val message: String? = null,
val location_latitude: Double? = null,
val location_longitude: Double? = null
)
data class SafetyCheckResponse(
val id: Int,
val uuid: String,
val user_id: Int,
val message: String?,
val location_latitude: Double?,
val location_longitude: Double?,
val created_at: String
)
data class CreateAlertRequest(
val latitude: Double,
val longitude: Double,
val alert_type: String = "general",
val message: String? = null,
val address: String? = null,
val contact_emergency_services: Boolean = true,
val notify_emergency_contacts: Boolean = true
)
data class UpdateAlertRequest(
val message: String? = null,
val is_resolved: Boolean? = null
)
data class WebSocketConnectionsResponse(
val total_connections: Int,
val active_connections: Int,
val connections: List<ConnectionInfo>
)
data class ConnectionInfo(
val user_id: Int,
val connected_at: String,
val last_ping: String?
)
data class UserWebSocketInfoResponse(
val user_id: Int,
val is_connected: Boolean,
val connected_at: String?,
val last_ping: String?
)
data class WebSocketPingResponse(
val total_pinged: Int,
val responses: Int,
val disconnected: Int
)
data class WebSocketStatsResponse(
val total_connections: Int,
val active_connections: Int,
val total_messages_sent: Int,
val uptime_seconds: Int
)
data class BroadcastResponse(
val message_id: String,
val recipients: Int,
val delivered: Int
)
// ========== API ИНТЕРФЕЙС ==========
interface EmergencyApiService {
// Основные операции с alerts/events (соответствуют реальному API)
@POST("api/v1/emergency/events")
suspend fun createEmergencyEvent(
@Header("Authorization") token: String,
@Body request: CreateEmergencyRequest
): Response<EmergencyEventResponse>
@GET("api/v1/emergency/events/nearby")
suspend fun getNearbyEvents(
@Header("Authorization") token: String,
@Query("latitude") latitude: Double,
@Query("longitude") longitude: Double,
@Query("radius") radius: Double = 5.0
): Response<NearbyEventsResponse>
@GET("api/v1/emergency/events/{eventId}")
suspend fun getEventDetails(
@Header("Authorization") token: String,
@Path("eventId") eventId: Int
): Response<EmergencyEventDetailResponse>
@POST("api/v1/emergency/events/{eventId}/respond")
suspend fun respondToEvent(
@Header("Authorization") token: String,
@Path("eventId") eventId: Int,
@Body response: EventResponseRequest
): Response<EventResponseResponse>
@PUT("api/v1/emergency/events/{eventId}/resolve")
suspend fun updateEventStatus(
@Header("Authorization") token: String,
@Path("eventId") eventId: Int
): Response<Unit>
@GET("api/v1/emergency/events/my")
suspend fun getEventHistory(
@Header("Authorization") token: String
): Response<EventHistoryResponse>
@GET("api/v1/alerts/nearby")
suspend fun getNearbyUsers(
@Header("Authorization") token: String,
@Query("latitude") latitude: Double,
@Query("longitude") longitude: Double,
@Query("radius_km") radiusKm: Double = 5.0
): Response<NearbyUsersResponse>
@GET("api/v1/stats")
suspend fun getEmergencyStats(
@Header("Authorization") token: String
): Response<EventsStatsResponse>
// ========== ДОПОЛНИТЕЛЬНЫЕ ЭНДПОИНТЫ ДЛЯ 100% ПОКРЫТИЯ ==========
// Reports API
@POST("api/v1/report")
suspend fun createEmergencyReport(
@Header("Authorization") token: String,
@Body request: CreateReportRequest
): Response<ReportResponse>
@GET("api/v1/reports")
suspend fun getEmergencyReports(
@Header("Authorization") token: String
): Response<List<ReportResponse>>
@GET("api/v1/emergency/reports")
suspend fun getEmergencyReportsAdmin(
@Header("Authorization") token: String
): Response<List<EmergencyEventResponse>>
// Safety Check API
@POST("api/v1/safety-check")
suspend fun createSafetyCheck(
@Header("Authorization") token: String,
@Body request: CreateSafetyCheckRequest
): Response<SafetyCheckResponse>
@GET("api/v1/safety-checks")
suspend fun getSafetyChecks(
@Header("Authorization") token: String
): Response<List<SafetyCheckResponse>>
// Alert Management API
@POST("api/v1/alert")
suspend fun createAlert(
@Header("Authorization") token: String,
@Body request: CreateAlertRequest
): Response<EmergencyEventResponse>
@PUT("api/v1/alert/{alert_id}")
suspend fun updateAlert(
@Header("Authorization") token: String,
@Path("alert_id") alertId: Int,
@Body request: UpdateAlertRequest
): Response<EmergencyEventResponse>
@PUT("api/v1/alert/{alert_id}/resolve")
suspend fun resolveAlert(
@Header("Authorization") token: String,
@Path("alert_id") alertId: Int
): Response<Unit>
@POST("api/v1/alert/{alert_id}/respond")
suspend fun respondToAlert(
@Header("Authorization") token: String,
@Path("alert_id") alertId: Int,
@Body request: EventResponseRequest
): Response<EventResponseResponse>
@GET("api/v1/alert/{alert_id}/responses")
suspend fun getAlertResponses(
@Header("Authorization") token: String,
@Path("alert_id") alertId: Int
): Response<List<EventResponseResponse>>
@GET("api/v1/alerts/my")
suspend fun getMyAlerts(
@Header("Authorization") token: String
): Response<List<EmergencyEventResponse>>
@GET("api/v1/alerts/active")
suspend fun getActiveAlerts(
@Header("Authorization") token: String
): Response<List<EmergencyEventResponse>>
// WebSocket Management API
@GET("api/v1/websocket/connections")
suspend fun getWebsocketConnections(
@Header("Authorization") token: String
): Response<WebSocketConnectionsResponse>
@GET("api/v1/websocket/connections/{user_id}")
suspend fun getUserWebsocketInfo(
@Header("Authorization") token: String,
@Path("user_id") userId: Int
): Response<UserWebSocketInfoResponse>
@POST("api/v1/websocket/ping")
suspend fun pingWebsocketConnections(
@Header("Authorization") token: String
): Response<WebSocketPingResponse>
@GET("api/v1/websocket/stats")
suspend fun getWebsocketStats(
@Header("Authorization") token: String
): Response<WebSocketStatsResponse>
@POST("api/v1/websocket/broadcast")
suspend fun broadcastTestMessage(
@Header("Authorization") token: String,
@Query("message") message: String
): Response<BroadcastResponse>
}

View File

@@ -0,0 +1,273 @@
package kr.smartsoltech.wellshe.emergency.data.api
import retrofit2.Response
import retrofit2.http.*
/**
* API Service соответствующий реальному Emergency Service
* Основан на OpenAPI спецификации http://localhost:8002/openapi.json
*/
interface RealEmergencyApiService {
// ========== ОСНОВНЫЕ ALERT ОПЕРАЦИИ ==========
@POST("api/v1/alert")
suspend fun createAlert(
@Header("Authorization") token: String,
@Body request: CreateAlertRequest
): Response<AlertResponse>
@PUT("api/v1/alert/{alert_id}")
suspend fun updateAlert(
@Header("Authorization") token: String,
@Path("alert_id") alertId: Int,
@Body request: UpdateAlertRequest
): Response<AlertResponse>
@PUT("api/v1/alert/{alert_id}/resolve")
suspend fun resolveAlert(
@Header("Authorization") token: String,
@Path("alert_id") alertId: Int
): Response<Unit>
// ========== ПОЛУЧЕНИЕ ALERTS ==========
@GET("api/v1/alerts/my")
suspend fun getMyAlerts(
@Header("Authorization") token: String
): Response<List<AlertResponse>>
@GET("api/v1/alerts/active")
suspend fun getActiveAlerts(
@Header("Authorization") token: String
): Response<List<AlertResponse>>
@GET("api/v1/alerts/nearby")
suspend fun getNearbyAlerts(
@Header("Authorization") token: String,
@Query("latitude") latitude: Double,
@Query("longitude") longitude: Double,
@Query("radius_km") radiusKm: Double = 10.0
): Response<List<NearbyAlertResponse>>
// ========== EMERGENCY EVENTS (Mobile Compatibility) ==========
@POST("api/v1/emergency/events")
suspend fun createEmergencyEvent(
@Header("Authorization") token: String,
@Body request: CreateAlertRequest
): Response<AlertResponse>
@GET("api/v1/emergency/events")
suspend fun getEmergencyEvents(
@Header("Authorization") token: String
): Response<List<AlertResponse>>
@GET("api/v1/emergency/events/my")
suspend fun getMyEmergencyEvents(
@Header("Authorization") token: String
): Response<List<AlertResponse>>
@GET("api/v1/emergency/events/nearby")
suspend fun getNearbyEmergencyEvents(
@Header("Authorization") token: String,
@Query("latitude") latitude: Double,
@Query("longitude") longitude: Double,
@Query("radius") radius: Double = 5.0
): Response<List<NearbyAlertResponse>>
@GET("api/v1/emergency/events/{event_id}")
suspend fun getEmergencyEventDetails(
@Header("Authorization") token: String,
@Path("event_id") eventId: Int
): Response<EmergencyEventDetailsResponse>
@GET("api/v1/emergency/events/{event_id}/brief")
suspend fun getEmergencyEventBrief(
@Header("Authorization") token: String,
@Path("event_id") eventId: Int
): Response<AlertResponse>
@PUT("api/v1/emergency/events/{event_id}/resolve")
suspend fun resolveEmergencyEvent(
@Header("Authorization") token: String,
@Path("event_id") eventId: Int
): Response<Unit>
// ========== RESPONSES ==========
@POST("api/v1/alert/{alert_id}/respond")
suspend fun respondToAlert(
@Header("Authorization") token: String,
@Path("alert_id") alertId: Int,
@Body request: ResponseCreateRequest
): Response<ResponseResponse>
@POST("api/v1/emergency/events/{event_id}/respond")
suspend fun respondToEmergencyEvent(
@Header("Authorization") token: String,
@Path("event_id") eventId: Int,
@Body request: ResponseCreateRequest
): Response<ResponseResponse>
@GET("api/v1/alert/{alert_id}/responses")
suspend fun getAlertResponses(
@Header("Authorization") token: String,
@Path("alert_id") alertId: Int
): Response<List<ResponseResponse>>
// ========== REPORTS ==========
@POST("api/v1/report")
suspend fun createEmergencyReport(
@Header("Authorization") token: String,
@Body request: CreateReportRequest
): Response<ReportResponse>
@GET("api/v1/reports")
suspend fun getEmergencyReports(
@Header("Authorization") token: String
): Response<List<ReportResponse>>
@GET("api/v1/emergency/reports")
suspend fun getEmergencyReportsAdmin(
@Header("Authorization") token: String
): Response<List<AlertResponse>>
// ========== SAFETY CHECK ==========
@POST("api/v1/safety-check")
suspend fun createSafetyCheck(
@Header("Authorization") token: String,
@Body request: CreateSafetyCheckRequest
): Response<SafetyCheckResponse>
@GET("api/v1/safety-checks")
suspend fun getSafetyChecks(
@Header("Authorization") token: String
): Response<List<SafetyCheckResponse>>
// ========== STATISTICS ==========
@GET("api/v1/stats")
suspend fun getEmergencyStats(
@Header("Authorization") token: String
): Response<EmergencyStatsResponse>
// ========== WEBSOCKET INFO ==========
@GET("api/v1/websocket/connections")
suspend fun getWebsocketConnections(
@Header("Authorization") token: String
): Response<WebSocketConnectionsResponse>
@GET("api/v1/websocket/connections/{user_id}")
suspend fun getUserWebsocketInfo(
@Header("Authorization") token: String,
@Path("user_id") userId: Int
): Response<UserWebSocketInfoResponse>
@POST("api/v1/websocket/ping")
suspend fun pingWebsocketConnections(
@Header("Authorization") token: String
): Response<WebSocketPingResponse>
@GET("api/v1/websocket/stats")
suspend fun getWebsocketStats(
@Header("Authorization") token: String
): Response<WebSocketStatsResponse>
@POST("api/v1/websocket/broadcast")
suspend fun broadcastTestMessage(
@Header("Authorization") token: String,
@Query("message") message: String
): Response<BroadcastResponse>
}
// ========== ДОПОЛНИТЕЛЬНЫЕ МОДЕЛИ ДЛЯ RealEmergencyApiService ==========
data class AlertResponse(
val id: Int,
val uuid: String,
val user_id: Int,
val latitude: Double,
val longitude: Double,
val address: String?,
val alert_type: String,
val message: String?,
val is_resolved: Boolean = false,
val resolved_at: String?,
val resolved_notes: String?,
val notified_users_count: Int = 0,
val responded_users_count: Int = 0,
val created_at: String,
val updated_at: String?,
val user_name: String?,
val user_phone: String?
)
data class NearbyAlertResponse(
val id: Int,
val alert_type: String,
val latitude: Double,
val longitude: Double,
val address: String?,
val distance_km: Double,
val created_at: String,
val responded_users_count: Int = 0
)
data class EmergencyEventDetailsResponse(
val id: Int,
val uuid: String,
val user_id: Int,
val latitude: Double,
val longitude: Double,
val address: String?,
val alert_type: String,
val message: String?,
val status: String,
val created_at: String,
val updated_at: String?,
val resolved_at: String?,
val user: UserInfoResponse,
val responses: List<ResponseResponse> = emptyList(),
val notifications_sent: Int = 0,
val websocket_notifications_sent: Int = 0,
val push_notifications_sent: Int = 0,
val contact_emergency_services: Boolean = true,
val notify_emergency_contacts: Boolean = true
)
data class ResponseCreateRequest(
val response_type: String,
val message: String? = null,
val eta_minutes: Int? = null
)
data class ResponseResponse(
val id: Int,
val alert_id: Int,
val responder_id: Int,
val response_type: String,
val message: String?,
val eta_minutes: Int?,
val created_at: String,
val responder_name: String?,
val responder_phone: String?
)
data class EmergencyStatsResponse(
val total_alerts: Int = 0,
val active_alerts: Int = 0,
val resolved_alerts: Int = 0,
val total_responders: Int = 0,
val avg_response_time_minutes: Double = 0.0
)
data class UserInfoResponse(
val id: Int,
val username: String,
val full_name: String?,
val phone: String?
)

View File

@@ -0,0 +1,317 @@
package kr.smartsoltech.wellshe.emergency.data.api.models
import com.google.gson.annotations.SerializedName
// ========== ОСНОВНЫЕ API МОДЕЛИ ==========
data class CreateEmergencyRequest(
val latitude: Double,
val longitude: Double,
val alert_type: String = "general",
val message: String? = null,
val address: String? = null,
val contact_emergency_services: Boolean = true,
val notify_emergency_contacts: Boolean = true,
val battery_level: Int? = null
)
data class EmergencyEventResponse(
val id: Int,
val uuid: String,
val user_id: Int,
val latitude: Double,
val longitude: Double,
val address: String?,
val alert_type: String,
val message: String?,
val status: String,
val is_resolved: Boolean,
val created_at: String,
val updated_at: String?,
val resolved_at: String?,
val user: ApiUserInfo?
)
data class NearbyEventsResponse(
val events: List<NearbyEmergencyEvent>,
val total: Int,
val radius_km: Double
)
data class NearbyEmergencyEvent(
val id: Int,
val uuid: String,
val latitude: Double,
val longitude: Double,
val address: String?,
val alert_type: String,
val message: String?,
val distance_km: Double,
val created_at: String,
val user: ApiUserInfo?
)
data class EmergencyEventDetailResponse(
val id: Int,
val uuid: String,
val user_id: Int,
val latitude: Double,
val longitude: Double,
val address: String?,
val alert_type: String,
val message: String?,
val status: String,
val is_resolved: Boolean,
val created_at: String,
val updated_at: String?,
val resolved_at: String?,
val user: ApiUserInfo,
val responses: List<EventResponse>
)
data class EventResponseRequest(
val response_type: String,
val message: String? = null,
val eta_minutes: Int? = null,
val location_latitude: Double? = null,
val location_longitude: Double? = null
)
data class EventResponseResponse(
val id: Int,
val event_id: Int,
val user_id: Int,
val response_type: String,
val message: String?,
val eta_minutes: Int?,
val location_latitude: Double?,
val location_longitude: Double?,
val created_at: String,
val user: ApiUserInfo
)
data class EventHistoryResponse(
val events: List<EmergencyEventResponse>,
val total: Int,
val page: Int,
val per_page: Int
)
data class NearbyUsersResponse(
val users: List<ApiUserInfo>,
val total: Int,
val radius_km: Double
)
data class EventsStatsResponse(
val total_events: Int,
val active_events: Int,
val resolved_events: Int,
val my_events: Int,
val my_responses: Int,
val average_response_time_minutes: Double?
)
// ========== ВСПОМОГАТЕЛЬНЫЕ МОДЕЛИ ==========
data class ApiUserInfo(
val id: Int,
val name: String,
val email: String?,
val phone: String?,
val profile_image_url: String?
)
data class EventResponse(
val id: Int,
val user_id: Int,
val response_type: String,
val message: String?,
val eta_minutes: Int?,
val created_at: String,
val user: ApiUserInfo
)
// ========== ДОПОЛНИТЕЛЬНЫЕ МОДЕЛИ (уже определены в EmergencyApiService.kt) ==========
data class CreateReportRequest(
val latitude: Double,
val longitude: Double,
val report_type: String,
val description: String,
val address: String? = null,
val is_anonymous: Boolean = false,
val severity: Int = 3
)
data class ReportResponse(
val id: Int,
val uuid: String,
val user_id: Int?,
val latitude: Double,
val longitude: Double,
val address: String?,
val report_type: String,
val description: String,
val is_anonymous: Boolean,
val severity: Int,
val status: String = "pending",
val created_at: String
)
data class CreateSafetyCheckRequest(
val message: String? = null,
val location_latitude: Double? = null,
val location_longitude: Double? = null
)
data class SafetyCheckResponse(
val id: Int,
val uuid: String,
val user_id: Int,
val message: String?,
val location_latitude: Double?,
val location_longitude: Double?,
val created_at: String
)
data class CreateAlertRequest(
val latitude: Double,
val longitude: Double,
val alert_type: String = "general",
val message: String? = null,
val address: String? = null,
val contact_emergency_services: Boolean = true,
val notify_emergency_contacts: Boolean = true
)
data class UpdateAlertRequest(
val message: String? = null,
val is_resolved: Boolean? = null
)
data class WebSocketConnectionsResponse(
val total_connections: Int,
val active_connections: Int,
val connections: List<ConnectionInfo>
)
data class ConnectionInfo(
val user_id: Int,
val connected_at: String,
val last_ping: String?
)
data class UserWebSocketInfoResponse(
val user_id: Int,
val is_connected: Boolean,
val connected_at: String?,
val last_ping: String?
)
data class WebSocketPingResponse(
val total_pinged: Int,
val responses: Int,
val disconnected: Int
)
data class WebSocketStatsResponse(
val total_connections: Int,
val active_connections: Int,
val total_messages_sent: Int,
val uptime_seconds: Int
)
data class BroadcastResponse(
val message_id: String,
val recipients: Int,
val delivered: Int
)
// ========== ДОПОЛНИТЕЛЬНЫЕ МОДЕЛИ ДЛЯ RealEmergencyApiService ==========
data class AlertResponse(
val id: Int,
val uuid: String,
val user_id: Int,
val latitude: Double,
val longitude: Double,
val address: String?,
val alert_type: String,
val message: String?,
val is_resolved: Boolean = false,
val resolved_at: String?,
val resolved_notes: String?,
val notified_users_count: Int = 0,
val responded_users_count: Int = 0,
val created_at: String,
val updated_at: String?,
val user_name: String?,
val user_phone: String?
)
data class NearbyAlertResponse(
val id: Int,
val alert_type: String,
val latitude: Double,
val longitude: Double,
val address: String?,
val distance_km: Double,
val created_at: String,
val responded_users_count: Int = 0
)
data class EmergencyEventDetailsResponse(
val id: Int,
val uuid: String,
val user_id: Int,
val latitude: Double,
val longitude: Double,
val address: String?,
val alert_type: String,
val message: String?,
val status: String,
val created_at: String,
val updated_at: String?,
val resolved_at: String?,
val user: UserInfoResponse,
val responses: List<ResponseResponse> = emptyList(),
val notifications_sent: Int = 0,
val websocket_notifications_sent: Int = 0,
val push_notifications_sent: Int = 0,
val contact_emergency_services: Boolean = true,
val notify_emergency_contacts: Boolean = true
)
data class ResponseCreateRequest(
val response_type: String,
val message: String? = null,
val eta_minutes: Int? = null
)
data class ResponseResponse(
val id: Int,
val alert_id: Int,
val responder_id: Int,
val response_type: String,
val message: String?,
val eta_minutes: Int?,
val created_at: String,
val responder_name: String?,
val responder_phone: String?
)
data class EmergencyStatsResponse(
val total_alerts: Int = 0,
val active_alerts: Int = 0,
val resolved_alerts: Int = 0,
val total_responders: Int = 0,
val avg_response_time_minutes: Double = 0.0
)
data class UserInfoResponse(
val id: Int,
val username: String,
val full_name: String?,
val phone: String?
)

View File

@@ -0,0 +1,23 @@
package kr.smartsoltech.wellshe.emergency.data.auth
interface AuthManager {
suspend fun getToken(): String?
suspend fun getCurrentUserId(): String?
suspend fun isAuthenticated(): Boolean
}
class AuthManagerImpl : AuthManager {
override suspend fun getToken(): String? {
// TODO: Implement proper token retrieval
return "demo_token"
}
override suspend fun getCurrentUserId(): String? {
// TODO: Implement proper user ID retrieval
return "demo_user_id"
}
override suspend fun isAuthenticated(): Boolean {
return getToken() != null
}
}

View File

@@ -0,0 +1,51 @@
package kr.smartsoltech.wellshe.emergency.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.emergency.data.entities.EmergencyEventEntity
import kr.smartsoltech.wellshe.emergency.data.entities.EmergencyResponseEntity
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus
@Dao
interface EmergencyDao {
@Query("SELECT * FROM emergency_events WHERE status = 'ACTIVE' ORDER BY createdAt DESC")
fun getActiveEvents(): Flow<List<EmergencyEventEntity>>
@Query("SELECT * FROM emergency_events WHERE status = 'ACTIVE' ORDER BY createdAt DESC")
fun observeActiveEvents(): Flow<List<EmergencyEventEntity>>
@Query("SELECT * FROM emergency_events ORDER BY createdAt DESC")
fun observeEvents(): Flow<List<EmergencyEventEntity>>
@Query("SELECT * FROM emergency_events WHERE userId = :userId ORDER BY createdAt DESC LIMIT :limit")
suspend fun getUserEvents(userId: String, limit: Int = 20): List<EmergencyEventEntity>
@Query("SELECT * FROM emergency_events WHERE id = :eventId")
suspend fun getEventById(eventId: String): EmergencyEventEntity?
@Query("SELECT * FROM emergency_events WHERE isLocal = 1 AND syncedAt IS NULL")
suspend fun getUnsyncedEvents(): List<EmergencyEventEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertEvent(event: EmergencyEventEntity)
@Update
suspend fun updateEvent(event: EmergencyEventEntity)
@Query("UPDATE emergency_events SET syncedAt = :syncedAt, isLocal = 0 WHERE id = :localId")
suspend fun markEventAsSynced(localId: String, syncedAt: Long)
@Query("UPDATE emergency_events SET status = :status WHERE id = :eventId")
suspend fun updateEventStatus(eventId: String, status: EmergencyStatus)
@Query("DELETE FROM emergency_events WHERE status = 'EXPIRED' AND expiresAt < :currentTime")
suspend fun deleteExpiredEvents(currentTime: Long = System.currentTimeMillis())
// Emergency Responses
@Query("SELECT * FROM emergency_responses WHERE eventId = :eventId ORDER BY createdAt DESC")
suspend fun getEventResponses(eventId: String): List<EmergencyResponseEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertResponse(response: EmergencyResponseEntity)
}

View File

@@ -0,0 +1,51 @@
package kr.smartsoltech.wellshe.emergency.data.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Embedded
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyType
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus
import java.util.UUID
@Entity(tableName = "emergency_events")
data class EmergencyEventEntity(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
val userId: String,
val latitude: Double,
val longitude: Double,
val eventType: EmergencyType,
val message: String?,
val status: EmergencyStatus,
val severity: Int,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val expiresAt: Long = System.currentTimeMillis() + (30 * 60 * 1000), // 30 минут
val syncedAt: Long? = null,
val isLocal: Boolean = true,
val distanceMeters: Double? = null,
@Embedded(prefix = "user_")
val userInfo: UserInfo? = null,
val nearbyUsersNotified: Int? = null,
val responseCount: Int = 0
)
data class UserInfo(
val firstName: String,
val age: Int?,
val avatarUrl: String?
)
@Entity(tableName = "emergency_responses")
data class EmergencyResponseEntity(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
val eventId: String,
val responderId: String,
val responseType: String,
val message: String?,
val estimatedArrival: Long?,
val createdAt: Long = System.currentTimeMillis(),
@Embedded(prefix = "responder_")
val responderInfo: UserInfo? = null
)

View File

@@ -0,0 +1,260 @@
package kr.smartsoltech.wellshe.emergency.data.mappers
import kr.smartsoltech.wellshe.emergency.data.api.*
import kr.smartsoltech.wellshe.emergency.data.entities.*
import kr.smartsoltech.wellshe.emergency.domain.models.*
import kr.smartsoltech.wellshe.emergency.data.models.*
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class EmergencyMapper @Inject constructor() {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault())
// API mappings
fun toApiRequest(request: kr.smartsoltech.wellshe.emergency.domain.models.CreateEmergencyRequest): kr.smartsoltech.wellshe.emergency.data.api.CreateEmergencyRequest {
return kr.smartsoltech.wellshe.emergency.data.api.CreateEmergencyRequest(
latitude = request.latitude,
longitude = request.longitude,
alert_type = when(request.eventType) {
EmergencyType.SOS -> "general"
EmergencyType.MEDICAL -> "medical"
EmergencyType.FIRE -> "fire"
EmergencyType.ACCIDENT -> "accident"
else -> "general"
},
message = request.message
)
}
fun toEmergencyEventResponse(response: kr.smartsoltech.wellshe.emergency.data.api.EmergencyEventResponse): kr.smartsoltech.wellshe.emergency.domain.models.EmergencyEventResponse {
android.util.Log.d("EmergencyMapper", "Raw server response: id=${response.id}, uuid=${response.uuid}")
return kr.smartsoltech.wellshe.emergency.domain.models.EmergencyEventResponse(
eventId = response.uuid, // Используем UUID как eventId
status = if (response.is_resolved) "resolved" else "active",
nearbyUsersNotified = 0, // TODO: добавить в API модель
estimatedResponseTime = null,
createdAt = response.created_at,
expiresAt = (System.currentTimeMillis() + 1800000).toString() // 30 минут
)
}
fun toEmergencyEvent(apiEvent: kr.smartsoltech.wellshe.emergency.data.api.NearbyEmergencyEvent): EmergencyEvent {
return EmergencyEvent(
id = apiEvent.id.toString(),
userId = "",
latitude = apiEvent.latitude,
longitude = apiEvent.longitude,
eventType = mapAlertTypeToEmergencyType(apiEvent.alert_type),
message = apiEvent.message ?: "",
status = EmergencyStatus.ACTIVE,
severity = 5,
createdAt = parseDate(apiEvent.created_at),
updatedAt = parseDate(apiEvent.created_at),
expiresAt = parseDate(apiEvent.created_at) + (30 * 60 * 1000),
distanceMeters = apiEvent.distance_km * 1000,
responseCount = 0
)
}
fun toEmergencyEventDetail(response: kr.smartsoltech.wellshe.emergency.data.api.EmergencyEventDetailResponse): EmergencyEventDetail {
return EmergencyEventDetail(
id = response.uuid,
userId = response.user_id.toString(),
latitude = response.latitude,
longitude = response.longitude,
eventType = mapAlertTypeToEmergencyType(response.alert_type),
message = response.message ?: "",
status = mapAlertStatusToEmergencyStatus(response.status),
severity = 5,
createdAt = parseDate(response.created_at),
updatedAt = parseDate(response.updated_at ?: response.created_at),
expiresAt = parseDate(response.created_at) + (30 * 60 * 1000),
responses = response.responses.map { toEventResponse(it) },
metadata = emptyMap()
)
}
// Entity mappings
fun toEmergencyEventEntity(request: kr.smartsoltech.wellshe.emergency.domain.models.CreateEmergencyRequest, userId: String): EmergencyEventEntity {
return EmergencyEventEntity(
userId = userId,
latitude = request.latitude,
longitude = request.longitude,
eventType = request.eventType,
message = request.message,
status = EmergencyStatus.ACTIVE,
severity = request.severity,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
expiresAt = System.currentTimeMillis() + (30 * 60 * 1000), // 30 minutes
isLocal = true
)
}
fun toEmergencyEvent(entity: EmergencyEventEntity): EmergencyEvent {
return EmergencyEvent(
id = entity.id,
userId = entity.userId,
latitude = entity.latitude,
longitude = entity.longitude,
eventType = entity.eventType,
message = entity.message,
status = entity.status,
severity = entity.severity,
createdAt = entity.createdAt,
updatedAt = entity.updatedAt,
expiresAt = entity.expiresAt,
distanceMeters = entity.distanceMeters,
userInfo = entity.userInfo?.let {
kr.smartsoltech.wellshe.emergency.domain.models.UserInfo(
firstName = it.firstName,
age = it.age,
avatarUrl = it.avatarUrl
)
},
responseCount = entity.responseCount,
nearbyUsersNotified = entity.nearbyUsersNotified,
isLocal = entity.isLocal
)
}
fun toCreateRequest(event: EmergencyEvent): kr.smartsoltech.wellshe.emergency.domain.models.CreateEmergencyRequest {
return kr.smartsoltech.wellshe.emergency.domain.models.CreateEmergencyRequest(
latitude = event.latitude,
longitude = event.longitude,
eventType = event.eventType,
message = event.message,
severity = event.severity
)
}
fun toEventResponseRequest(response: kr.smartsoltech.wellshe.emergency.domain.models.EventResponse): kr.smartsoltech.wellshe.emergency.data.api.EventResponseRequest {
return kr.smartsoltech.wellshe.emergency.data.api.EventResponseRequest(
response_type = response.responseType.name.lowercase(),
message = response.message,
eta_minutes = response.estimatedArrival?.let { (it / 60000).toInt() }
)
}
fun toEmergencyResponseEntity(response: kr.smartsoltech.wellshe.emergency.domain.models.EventResponse, eventId: String): EmergencyResponseEntity {
return EmergencyResponseEntity(
eventId = eventId,
responderId = response.responderId,
responseType = response.responseType.name,
message = response.message,
estimatedArrival = response.estimatedArrival,
responderInfo = response.responderInfo?.let { domainUserInfo ->
kr.smartsoltech.wellshe.emergency.data.entities.UserInfo(
firstName = domainUserInfo.firstName,
age = domainUserInfo.age,
avatarUrl = domainUserInfo.avatarUrl
)
}
)
}
fun toEventResponse(entity: EmergencyResponseEntity): kr.smartsoltech.wellshe.emergency.domain.models.EventResponse {
return kr.smartsoltech.wellshe.emergency.domain.models.EventResponse(
id = entity.id,
eventId = entity.eventId,
responderId = entity.responderId,
responseType = ResponseType.valueOf(entity.responseType),
message = entity.message,
estimatedArrival = entity.estimatedArrival,
createdAt = entity.createdAt,
responderInfo = entity.responderInfo?.let { entityUserInfo ->
kr.smartsoltech.wellshe.emergency.domain.models.UserInfo(
firstName = entityUserInfo.firstName,
age = entityUserInfo.age,
avatarUrl = entityUserInfo.avatarUrl
)
}
)
}
private fun toUserInfo(apiUserInfo: kr.smartsoltech.wellshe.emergency.data.api.ApiUserInfo): kr.smartsoltech.wellshe.emergency.domain.models.UserInfo {
return kr.smartsoltech.wellshe.emergency.domain.models.UserInfo(
firstName = apiUserInfo.name,
age = null, // API не предоставляет возраст
avatarUrl = apiUserInfo.profile_image_url
)
}
fun toNearbyUsers(response: kr.smartsoltech.wellshe.emergency.data.api.NearbyUsersResponse): List<kr.smartsoltech.wellshe.emergency.domain.models.NearbyUser> {
return response.users.map { nearbyUser ->
kr.smartsoltech.wellshe.emergency.domain.models.NearbyUser(
userId = nearbyUser.id.toString(),
firstName = nearbyUser.name,
age = null,
avatarUrl = nearbyUser.profile_image_url,
distanceMeters = 0.0, // TODO: вычислить расстояние
isOnline = true
)
}
}
fun toEventResponse(apiResponse: kr.smartsoltech.wellshe.emergency.data.api.EventResponse): kr.smartsoltech.wellshe.emergency.domain.models.EventResponse {
return kr.smartsoltech.wellshe.emergency.domain.models.EventResponse(
id = apiResponse.id.toString(),
eventId = "",
responderId = apiResponse.user_id.toString(),
responseType = mapResponseType(apiResponse.response_type),
message = apiResponse.message,
estimatedArrival = apiResponse.eta_minutes?.let { it * 60 * 1000L },
createdAt = parseDate(apiResponse.created_at),
responderInfo = kr.smartsoltech.wellshe.emergency.domain.models.UserInfo(
firstName = apiResponse.user.name,
age = null,
avatarUrl = apiResponse.user.profile_image_url
)
)
}
private fun formatDate(timestamp: Long): String {
return dateFormat.format(Date(timestamp))
}
// Делаем методы публичными для использования в репозитории
fun mapAlertTypeToEmergencyType(alertType: String): EmergencyType {
return when(alertType) {
"medical" -> EmergencyType.MEDICAL
"fire" -> EmergencyType.FIRE
"accident" -> EmergencyType.ACCIDENT
"violence" -> EmergencyType.SOS
"harassment" -> EmergencyType.SOS
else -> EmergencyType.SOS
}
}
fun parseDate(dateString: String): Long {
return try {
dateFormat.parse(dateString)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
System.currentTimeMillis()
}
}
private fun mapAlertStatusToEmergencyStatus(status: String): EmergencyStatus {
return when(status) {
"active" -> EmergencyStatus.ACTIVE
"resolved" -> EmergencyStatus.RESOLVED
"cancelled" -> EmergencyStatus.CANCELLED
else -> EmergencyStatus.ACTIVE
}
}
private fun mapResponseType(responseType: String): ResponseType {
return when(responseType) {
"help_on_way" -> ResponseType.ON_WAY
"calling_emergency" -> ResponseType.CALLED_POLICE
"safe_location" -> ResponseType.SAFE_NOW
"cant_help" -> ResponseType.CANNOT_HELP
else -> ResponseType.ON_WAY
}
}
}

View File

@@ -0,0 +1,37 @@
package kr.smartsoltech.wellshe.emergency.data.models
enum class EmergencyType {
SOS,
HARASSMENT,
STALKING,
MEDICAL,
FIRE,
ACCIDENT,
OTHER
}
enum class EmergencyStatus {
ACTIVE,
HANDLED,
CLOSED,
EXPIRED,
RESOLVED,
CANCELLED
}
enum class ResponseType {
ON_WAY,
CALLED_POLICE,
SAFE_NOW,
CANNOT_HELP,
HELP_ON_WAY,
CONTACTED_AUTHORITIES,
FALSE_ALARM
}
enum class ConnectionStatus {
CONNECTED,
CONNECTING,
DISCONNECTED,
RECONNECTING
}

View File

@@ -0,0 +1,361 @@
package kr.smartsoltech.wellshe.emergency.data.repository
import android.util.Log
import kotlinx.coroutines.flow.*
import kr.smartsoltech.wellshe.emergency.data.api.EmergencyApiService
import kr.smartsoltech.wellshe.emergency.data.api.EventResponseRequest
import kr.smartsoltech.wellshe.emergency.data.dao.EmergencyDao
import kr.smartsoltech.wellshe.emergency.data.websocket.EmergencyWebSocketManager
import kr.smartsoltech.wellshe.emergency.data.mappers.EmergencyMapper
import kr.smartsoltech.wellshe.emergency.domain.models.*
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
import kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus
import kr.smartsoltech.wellshe.emergency.data.auth.AuthManager
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class EmergencyRepositoryImpl @Inject constructor(
private val apiService: EmergencyApiService,
private val dao: EmergencyDao,
private val webSocketManager: EmergencyWebSocketManager,
private val mapper: EmergencyMapper,
private val authManager: AuthManager
) : EmergencyRepository {
companion object {
private const val TAG = "EmergencyRepository"
}
override suspend fun createEmergencyEvent(request: CreateEmergencyRequest): Result<EmergencyEventResponse> {
return try {
Log.d(TAG, "Creating emergency event: ${request.eventType} at lat=${request.latitude}, lng=${request.longitude}")
val token = authManager.getToken()
if (token.isNullOrEmpty()) {
Log.e(TAG, "No auth token available for emergency request")
return Result.failure(Exception("No auth token available"))
}
Log.d(TAG, "Using auth token for emergency request: ${token.take(6)}...")
val apiRequest = mapper.toApiRequest(request)
Log.d(TAG, "Mapped API request: $apiRequest")
Log.d(TAG, "Sending emergency event to server...")
val response = apiService.createEmergencyEvent("Bearer $token", apiRequest)
Log.d(TAG, "Server response: code=${response.code()}, isSuccessful=${response.isSuccessful}")
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
Log.i(TAG, "Emergency event created successfully: ${body}")
val mappedResponse = mapper.toEmergencyEventResponse(body)
Log.d(TAG, "Mapped response: $mappedResponse")
Result.success(mappedResponse)
} else {
Log.e(TAG, "Server response body is null")
Result.failure(Exception("Server response body is null"))
}
} else {
val errorBody = response.errorBody()?.string()
Log.e(TAG, "API Error: ${response.code()}, message: ${response.message()}, body: $errorBody")
Result.failure(Exception("API Error: ${response.code()} - ${response.message()}${if (errorBody != null) ": $errorBody" else ""}"))
}
} catch (e: Exception) {
Log.e(TAG, "Exception creating emergency event: ${e.message}", e)
Result.failure(e)
}
}
override suspend fun getNearbyEvents(latitude: Double, longitude: Double, radius: Int): Result<List<EmergencyEvent>> {
return try {
val token = authManager.getToken() ?: return Result.failure(Exception("No auth token"))
val response = apiService.getNearbyEvents("Bearer $token", latitude, longitude, radius.toDouble())
if (response.isSuccessful) {
val body = response.body()!!
val events = body.events.map { mapper.toEmergencyEvent(it) }
Result.success(events)
} else {
Result.failure(Exception("API Error: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getNearbyUsers(latitude: Double, longitude: Double, radius: Int): Result<List<NearbyUser>> {
return try {
Log.d(TAG, "Getting nearby users at lat=$latitude, lng=$longitude, radius=${radius}m")
val token = authManager.getToken()
if (token.isNullOrEmpty()) {
Log.e(TAG, "No auth token available for nearby users request")
return Result.failure(Exception("No auth token available"))
}
val radiusKm = radius / 1000.0
Log.d(TAG, "Sending nearby users request to server with radius_km=$radiusKm...")
val response = apiService.getNearbyUsers("Bearer $token", latitude, longitude, radiusKm)
Log.d(TAG, "Nearby users response: code=${response.code()}, isSuccessful=${response.isSuccessful}")
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
Log.i(TAG, "Found ${body.users.size} nearby users")
val nearbyUsers = mapper.toNearbyUsers(body)
Result.success(nearbyUsers)
} else {
Log.e(TAG, "Nearby users response body is null")
Result.success(emptyList())
}
} else {
val errorBody = response.errorBody()?.string()
Log.e(TAG, "Nearby users API Error: ${response.code()}, message: ${response.message()}, body: $errorBody")
Result.success(emptyList())
}
} catch (e: Exception) {
Log.e(TAG, "Exception getting nearby users: ${e.message}", e)
Result.success(emptyList())
}
}
override suspend fun getEventDetails(eventId: String): Result<EmergencyEventDetail> {
return try {
val token = authManager.getToken() ?: return Result.failure(Exception("No auth token"))
val response = apiService.getEventDetails("Bearer $token", eventId.toInt())
if (response.isSuccessful) {
val body = response.body()!!
Result.success(mapper.toEmergencyEventDetail(body))
} else {
Result.failure(Exception("API Error: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun saveLocalEmergencyEvent(request: CreateEmergencyRequest): EmergencyEvent {
val entity = mapper.toEmergencyEventEntity(request, getCurrentUserId())
dao.insertEvent(entity)
return mapper.toEmergencyEvent(entity)
}
override suspend fun markEventAsSynced(localId: String, remoteId: String) {
dao.markEventAsSynced(localId, System.currentTimeMillis())
}
override suspend fun getUnsyncedEvents(): List<EmergencyEvent> {
return dao.getUnsyncedEvents().map { mapper.toEmergencyEvent(it) }
}
override suspend fun syncLocalEvents(): Result<Unit> {
return try {
val unsyncedEvents = getUnsyncedEvents()
unsyncedEvents.forEach { event ->
val request = mapper.toCreateRequest(event)
createEmergencyEvent(request).fold(
onSuccess = { response ->
markEventAsSynced(event.id, response.eventId)
},
onFailure = { /* Keep local event for next sync attempt */ }
)
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun updateEventStatus(eventId: String, status: EmergencyStatus, note: String?): Result<Unit> {
return try {
val token = authManager.getToken() ?: return Result.failure(Exception("No auth token"))
val response = apiService.updateEventStatus("Bearer $token", eventId.toInt())
if (response.isSuccessful) {
dao.updateEventStatus(eventId, status)
Result.success(Unit)
} else {
Result.failure(Exception("API Error: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun respondToEvent(eventId: String, response: EventResponse): Result<Unit> {
return try {
val token = authManager.getToken() ?: return Result.failure(Exception("No auth token"))
val request = mapper.toEventResponseRequest(response)
val apiResponse = apiService.respondToEvent("Bearer $token", eventId.toInt(), request)
if (apiResponse.isSuccessful) {
val responseEntity = mapper.toEmergencyResponseEntity(response, eventId)
dao.insertResponse(responseEntity)
Result.success(Unit)
} else {
Result.failure(Exception("API Error: ${apiResponse.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getEventResponses(eventId: String): Result<List<EventResponse>> {
return try {
val responses = dao.getEventResponses(eventId)
Result.success(responses.map { mapper.toEventResponse(it) })
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getUserEventHistory(limit: Int, offset: Int): Result<List<EmergencyEvent>> {
return try {
val token = authManager.getToken() ?: return Result.failure(Exception("No auth token"))
val response = apiService.getEventHistory("Bearer $token")
if (response.isSuccessful) {
val body = response.body()!!
val events = body.events.map { historyEvent ->
EmergencyEvent(
id = historyEvent.uuid,
userId = historyEvent.user_id.toString(),
latitude = historyEvent.latitude,
longitude = historyEvent.longitude,
eventType = mapper.mapAlertTypeToEmergencyType(historyEvent.alert_type),
message = historyEvent.message,
status = if (historyEvent.is_resolved) EmergencyStatus.RESOLVED else EmergencyStatus.ACTIVE,
severity = 5,
createdAt = mapper.parseDate(historyEvent.created_at),
updatedAt = mapper.parseDate(historyEvent.updated_at ?: historyEvent.created_at),
expiresAt = mapper.parseDate(historyEvent.created_at) + (30 * 60 * 1000),
responseCount = 0
)
}
Result.success(events)
} else {
Result.failure(Exception("API Error: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override fun getActiveEvents(): Flow<List<EmergencyEvent>> {
return dao.observeActiveEvents().map { entities ->
entities.map { mapper.toEmergencyEvent(it) }
}
}
override fun getConnectionStatus(): Flow<ConnectionStatus> {
return webSocketManager.connectionStatus
}
override fun connectWebSocket(userId: String, token: String) {
webSocketManager.connect(userId, token)
}
override fun disconnectWebSocket() {
webSocketManager.disconnect()
}
override fun getEventResponses(): Flow<EventResponseNotification> {
return webSocketManager.eventResponses
}
override suspend fun getEmergencyStats(): Result<EventStats> {
return try {
Result.success(EventStats(
totalEvents = 0,
activeEvents = 0,
resolvedEvents = 0,
escalatedEvents = 0,
cancelledEvents = 0,
averageResponseTime = 0.0,
period = "last_30_days"
))
} catch (e: Exception) {
Result.failure(e)
}
}
// Stub implementations for new methods
override suspend fun createEmergencyReport(request: CreateReportRequest): Result<EmergencyReport> {
return Result.failure(Exception("Not implemented"))
}
override suspend fun getEmergencyReports(): Result<List<EmergencyReport>> {
return Result.success(emptyList())
}
override suspend fun getEmergencyReportsAdmin(): Result<List<EmergencyEvent>> {
return Result.success(emptyList())
}
override suspend fun createSafetyCheck(request: CreateSafetyCheckRequest): Result<SafetyCheck> {
return Result.failure(Exception("Not implemented"))
}
override suspend fun getSafetyChecks(): Result<List<SafetyCheck>> {
return Result.success(emptyList())
}
override suspend fun createAlert(latitude: Double, longitude: Double, alertType: String, message: String?): Result<EmergencyEventResponse> {
return Result.failure(Exception("Not implemented"))
}
override suspend fun updateAlert(alertId: String, message: String?, isResolved: Boolean?): Result<EmergencyEventResponse> {
return Result.failure(Exception("Not implemented"))
}
override suspend fun resolveAlert(alertId: String): Result<Unit> {
return Result.failure(Exception("Not implemented"))
}
override suspend fun getMyAlerts(): Result<List<EmergencyEvent>> {
return Result.success(emptyList())
}
override suspend fun getActiveAlerts(): Result<List<EmergencyEvent>> {
return Result.success(emptyList())
}
override suspend fun getAlertResponses(alertId: String): Result<List<EventResponse>> {
return Result.success(emptyList())
}
override suspend fun getWebSocketStats(): Result<WebSocketStats> {
return Result.success(WebSocketStats(0, 0, 0, 0))
}
override suspend fun getConnectionStats(): Result<ConnectionStats> {
return Result.success(ConnectionStats(0, 0, emptyList()))
}
override suspend fun pingWebSocketConnections(): Result<Unit> {
return Result.success(Unit)
}
override suspend fun broadcastTestMessage(message: String): Result<Unit> {
return Result.success(Unit)
}
override suspend fun getNearbyEventsForMap(latitude: Double, longitude: Double, radiusKm: Double): Result<List<MapEvent>> {
return Result.success(emptyList())
}
override suspend fun getEventsInRadius(latitude: Double, longitude: Double, radiusKm: Double): Result<List<EmergencyEvent>> {
return Result.success(emptyList())
}
private suspend fun getCurrentUserId(): String {
return authManager.getCurrentUserId() ?: "anonymous"
}
}

View File

@@ -0,0 +1,240 @@
package kr.smartsoltech.wellshe.emergency.data.websocket
import android.util.Log
import com.google.gson.Gson
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.channels.BufferOverflow
import okhttp3.*
import org.json.JSONObject
import kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus
import javax.inject.Inject
import javax.inject.Singleton
import kr.smartsoltech.wellshe.BuildConfig
@Singleton
class EmergencyWebSocketManager @Inject constructor(
private val okHttpClient: OkHttpClient,
private val gson: Gson
) {
private var webSocket: WebSocket? = null
private var reconnectJob: Job? = null
private var currentUserId: String? = null
private var authToken: String? = null
private var triedWithTokenQuery: Boolean = false
private val _emergencyAlerts = MutableSharedFlow<EmergencyAlert>(
replay = 0,
extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val emergencyAlerts: SharedFlow<EmergencyAlert> = _emergencyAlerts.asSharedFlow()
private val _eventUpdates = MutableSharedFlow<EventUpdate>(
replay = 0,
extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val eventUpdates: SharedFlow<EventUpdate> = _eventUpdates.asSharedFlow()
private val _eventResponses = MutableSharedFlow<EventResponseNotification>(
replay = 0,
extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val eventResponses: SharedFlow<EventResponseNotification> = _eventResponses.asSharedFlow()
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow()
fun connect(userId: String, token: String, useQueryToken: Boolean = false) {
disconnect()
currentUserId = userId
authToken = token
triedWithTokenQuery = useQueryToken
// Safety: do not attempt to connect with placeholder/test tokens
if (token.startsWith("temp_token_for_")) {
Log.w("EmergencyWS", "Refusing to connect to WebSocket: placeholder token detected for user=$userId. Token: $token")
_connectionStatus.value = ConnectionStatus.DISCONNECTED
return
}
val wsUrl = if (useQueryToken) {
// token in query param (less secure) — used only as a debug fallback
BuildConfig.EMERGENCY_WS_BASE + "emergency/ws/$userId?token=${token}"
} else {
BuildConfig.EMERGENCY_WS_BASE + "emergency/ws/$userId"
}
// Log the websocket URL and a masked token for debugging 403 errors
try {
val maskedToken = if (token.length > 6) token.substring(0, 6) + "..." else "***"
Log.d("EmergencyWS", "Connecting to WebSocket: $wsUrl , token=$maskedToken , useQueryToken=$useQueryToken")
} catch (e: Exception) {
Log.d("EmergencyWS", "Connecting to WebSocket (unable to mask token)")
}
val requestBuilder = Request.Builder()
.url(wsUrl)
if (!useQueryToken) {
// prefer Authorization header; add Origin because some servers check it
requestBuilder
.addHeader("Authorization", "Bearer $token")
.addHeader("Origin", BuildConfig.EMERGENCY_API_BASE.removeSuffix("/"))
Log.d("EmergencyWS", "Using Authorization header for token (masked) and Origin header")
} else {
Log.d("EmergencyWS", "Using query parameter for token (masked)")
}
val request = requestBuilder.build()
_connectionStatus.value = ConnectionStatus.CONNECTING
webSocket = okHttpClient.newWebSocket(request, EmergencyWebSocketListener())
}
fun disconnect() {
reconnectJob?.cancel()
webSocket?.close(1000, "Manual disconnect")
webSocket = null
_connectionStatus.value = ConnectionStatus.DISCONNECTED
}
private fun scheduleReconnect() {
if (currentUserId == null || authToken == null) return
reconnectJob?.cancel()
reconnectJob = CoroutineScope(Dispatchers.IO).launch {
var delay = 5000L
while (_connectionStatus.value == ConnectionStatus.DISCONNECTED && isActive) {
delay(delay)
if (_connectionStatus.value == ConnectionStatus.DISCONNECTED) {
Log.d("EmergencyWS", "Attempting to reconnect...")
_connectionStatus.value = ConnectionStatus.RECONNECTING
connect(currentUserId!!, authToken!!)
delay = minOf(delay * 2, 30000L) // Exponential backoff, max 30s
}
}
}
}
private inner class EmergencyWebSocketListener : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
_connectionStatus.value = ConnectionStatus.CONNECTED
Log.d("EmergencyWS", "WebSocket connected successfully")
// Reset reconnect delay on successful connection
reconnectJob?.cancel()
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val json = JSONObject(text)
val messageType = json.getString("type")
when (messageType) {
"emergency_alert" -> {
val alert = gson.fromJson(text, EmergencyAlert::class.java)
_emergencyAlerts.tryEmit(alert)
Log.d("EmergencyWS", "Emergency alert received: ${'$'}{alert.eventId}")
}
"event_update" -> {
val update = gson.fromJson(text, EventUpdate::class.java)
_eventUpdates.tryEmit(update)
Log.d("EmergencyWS", "Event update received: ${'$'}{update.eventId}")
}
"event_response" -> {
val response = gson.fromJson(text, EventResponseNotification::class.java)
_eventResponses.tryEmit(response)
Log.d("EmergencyWS", "Event response received for: ${'$'}{response.eventId}")
}
"ping" -> {
webSocket.send("pong")
}
else -> {
Log.w("EmergencyWS", "Unknown message type: $messageType")
}
}
} catch (e: Exception) {
Log.e("EmergencyWS", "Error parsing WebSocket message: $text", e)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e("EmergencyWS", "WebSocket connection failed", t)
response?.let {
try {
Log.e("EmergencyWS", "WebSocket HTTP response: code=${it.code}, message=${it.message}")
Log.e("EmergencyWS", "WebSocket response headers: ${it.headers}")
} catch (e: Exception) {
Log.e("EmergencyWS", "Failed to log response details", e)
}
}
_connectionStatus.value = ConnectionStatus.DISCONNECTED
// If server refused with 403 and we haven't tried token-in-query yet, try once (debug-only)
try {
if (response?.code == 403 && !triedWithTokenQuery && BuildConfig.DEBUG && currentUserId != null && authToken != null) {
Log.d("EmergencyWS", "403 received — retrying WebSocket with token in query (debug-only)")
// slight delay before retry
CoroutineScope(Dispatchers.IO).launch {
delay(500)
connect(currentUserId!!, authToken!!, useQueryToken = true)
}
return
}
} catch (e: Exception) {
Log.e("EmergencyWS", "Error during 403 retry logic", e)
}
scheduleReconnect()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d("EmergencyWS", "WebSocket closed: code=$code, reason=$reason")
_connectionStatus.value = ConnectionStatus.DISCONNECTED
// Only attempt reconnect if it wasn't a manual disconnect
if (code != 1000) {
scheduleReconnect()
}
}
}
}
// WebSocket message models
data class EmergencyAlert(
val type: String = "emergency_alert",
val eventId: String,
val distanceMeters: Int,
val eventType: String,
val severity: Int,
val message: String,
val userInfo: WsUserInfo,
val createdAt: String
)
data class EventUpdate(
val type: String = "event_update",
val eventId: String,
val status: String,
val updateMessage: String?
)
data class EventResponseNotification(
val type: String = "event_response",
val eventId: String,
val responder: WsUserInfo,
val responseType: String,
val message: String?,
val estimatedArrival: String?
)
data class WsUserInfo(
val userId: String,
val firstName: String,
val age: Int?,
val avatarUrl: String?
)

View File

@@ -0,0 +1,9 @@
package kr.smartsoltech.wellshe.emergency.debug.stub
// Заглушка в main-источнике, чтобы не конфликтовать с реализацией в src/debug
object AuthTesterStub {
fun runFullTest(): Boolean {
// no-op stub in a different package/name to avoid redeclaration in debug builds
return false
}
}

View File

@@ -0,0 +1,155 @@
package kr.smartsoltech.wellshe.emergency.di
import android.content.Context
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import kr.smartsoltech.wellshe.emergency.data.api.EmergencyApiService
import kr.smartsoltech.wellshe.emergency.data.dao.EmergencyDao
import kr.smartsoltech.wellshe.emergency.data.mappers.EmergencyMapper
import kr.smartsoltech.wellshe.emergency.data.repository.EmergencyRepositoryImpl
import kr.smartsoltech.wellshe.emergency.data.auth.AuthManager
import kr.smartsoltech.wellshe.emergency.data.auth.AuthManagerImpl
import kr.smartsoltech.wellshe.emergency.data.websocket.EmergencyWebSocketManager
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
import kr.smartsoltech.wellshe.emergency.domain.usecases.*
import kr.smartsoltech.wellshe.emergency.utils.*
import kr.smartsoltech.wellshe.data.AppDatabase
import kr.smartsoltech.wellshe.data.storage.TokenManager
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import kr.smartsoltech.wellshe.BuildConfig
@Module
@InstallIn(SingletonComponent::class)
object EmergencyModule {
@Provides
@Singleton
fun provideEmergencyDao(database: AppDatabase): EmergencyDao = database.emergencyDao()
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideEmergencyApiService(okHttpClient: OkHttpClient, gson: Gson): EmergencyApiService {
return Retrofit.Builder()
.baseUrl(BuildConfig.EMERGENCY_API_BASE) // Emergency Service URL from BuildConfig
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(EmergencyApiService::class.java)
}
@Provides
@Singleton
fun provideEmergencyWebSocketManager(
okHttpClient: OkHttpClient,
gson: Gson
): EmergencyWebSocketManager {
return EmergencyWebSocketManager(okHttpClient, gson)
}
@Provides
@Singleton
fun provideEmergencyMapper(): EmergencyMapper = EmergencyMapper()
@Provides
@Singleton
fun provideAuthManager(): AuthManager {
return AuthManagerImpl()
}
@Provides
@Singleton
fun provideEmergencyRepository(
apiService: EmergencyApiService,
dao: EmergencyDao,
webSocketManager: EmergencyWebSocketManager,
mapper: EmergencyMapper,
authManager: AuthManager
): EmergencyRepository {
return EmergencyRepositoryImpl(apiService, dao, webSocketManager, mapper, authManager)
}
// Use Cases
@Provides
fun provideCreateEmergencyEventUseCase(
repository: EmergencyRepository,
connectivityManager: ConnectivityManager,
deviceInfoProvider: DeviceInfoProvider
): CreateEmergencyEventUseCase {
return CreateEmergencyEventUseCase(repository, connectivityManager, deviceInfoProvider)
}
@Provides
fun provideGetNearbyEventsUseCase(repository: EmergencyRepository): GetNearbyEventsUseCase {
return GetNearbyEventsUseCase(repository)
}
@Provides
fun provideGetNearbyUsersUseCase(repository: EmergencyRepository): GetNearbyUsersUseCase {
return GetNearbyUsersUseCase(repository)
}
@Provides
fun provideRespondToEventUseCase(repository: EmergencyRepository): RespondToEventUseCase {
return RespondToEventUseCase(repository)
}
@Provides
fun provideUpdateEventStatusUseCase(repository: EmergencyRepository): UpdateEventStatusUseCase {
return UpdateEventStatusUseCase(repository)
}
@Provides
fun provideSyncLocalEventsUseCase(repository: EmergencyRepository): SyncLocalEventsUseCase {
return SyncLocalEventsUseCase(repository)
}
// Utilities
@Provides
@Singleton
fun provideLocationManager(@ApplicationContext context: Context): LocationManager {
return LocationManager(context)
}
@Provides
@Singleton
fun provideConnectivityManager(@ApplicationContext context: Context): ConnectivityManager {
return ConnectivityManager(context)
}
@Provides
@Singleton
fun provideDeviceInfoProvider(@ApplicationContext context: Context): DeviceInfoProvider {
return DeviceInfoProvider(context)
}
@Provides
@Singleton
fun providePermissionManager(@ApplicationContext context: Context): PermissionManager {
return PermissionManager(context)
}
@Provides
@Singleton
fun provideEmergencyNotificationManager(@ApplicationContext context: Context): EmergencyNotificationManager {
return EmergencyNotificationManager(context)
}
}

View File

@@ -0,0 +1,334 @@
package kr.smartsoltech.wellshe.emergency.domain.models
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyType
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus
import kr.smartsoltech.wellshe.emergency.data.models.ResponseType
import kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus
// Domain models
data class EmergencyEvent(
val id: String,
val userId: String,
val latitude: Double,
val longitude: Double,
val eventType: EmergencyType,
val message: String?,
val status: EmergencyStatus,
val severity: Int,
val createdAt: Long,
val updatedAt: Long,
val expiresAt: Long,
val distanceMeters: Double? = null,
val userInfo: UserInfo? = null,
val responseCount: Int = 0,
val nearbyUsersNotified: Int? = null,
val isLocal: Boolean = false
)
data class EmergencyEventDetail(
val id: String,
val userId: String,
val latitude: Double,
val longitude: Double,
val eventType: EmergencyType,
val message: String,
val status: EmergencyStatus,
val severity: Int,
val createdAt: Long,
val updatedAt: Long,
val expiresAt: Long,
val responses: List<EventResponse>,
val metadata: Map<String, Any>
)
data class EventResponse(
val id: String,
val eventId: String,
val responderId: String,
val responseType: ResponseType,
val message: String?,
val estimatedArrival: Long?,
val createdAt: Long,
val responderInfo: UserInfo?
)
data class UserInfo(
val firstName: String,
val age: Int?,
val avatarUrl: String?
)
data class NearbyUser(
val userId: String,
val firstName: String,
val age: Int?,
val avatarUrl: String?,
val distanceMeters: Double,
val isOnline: Boolean = true
)
data class CreateEmergencyRequest(
val latitude: Double,
val longitude: Double,
val eventType: EmergencyType,
val message: String?,
val severity: Int,
val metadata: Map<String, Any> = emptyMap()
)
data class EmergencyEventResponse(
val eventId: String,
val status: String,
val nearbyUsersNotified: Int,
val estimatedResponseTime: String?,
val createdAt: String,
val expiresAt: String
) {
companion object {
fun fromLocal(localEvent: EmergencyEvent): EmergencyEventResponse {
return EmergencyEventResponse(
eventId = localEvent.id,
status = "pending_sync",
nearbyUsersNotified = 0,
estimatedResponseTime = null,
createdAt = localEvent.createdAt.toString(),
expiresAt = localEvent.expiresAt.toString()
)
}
}
}
// Re-export WebSocket models for domain layer
typealias EventResponseNotification = kr.smartsoltech.wellshe.emergency.data.websocket.EventResponseNotification
// Расширенные domain модели для новой функциональности
// 1. Управление жизненным циклом событий
data class EventUpdateDomain(
val message: String?,
val priority: String?, // low, medium, high, critical
val additionalInfo: Map<String, Any>? = null
)
data class EventNote(
val id: String,
val eventId: String,
val authorId: String,
val content: String,
val noteType: String, // public, private, system
val createdAt: Long,
val updatedAt: Long
)
data class EventTimeline(
val eventId: String,
val timeline: List<TimelineEntry>
)
data class TimelineEntry(
val id: String,
val timestamp: Long,
val status: String,
val message: String?,
val userInfo: UserInfo?,
val eventType: String = "status_change"
)
// 2. Система взаимодействия участников
data class EventParticipant(
val eventId: String,
val userId: String,
val userInfo: UserInfo,
val role: ParticipantRole, // creator, responder, observer, invited
val joinedAt: Long,
val status: ParticipantStatus // active, left, removed
)
enum class ParticipantRole {
CREATOR, RESPONDER, OBSERVER, INVITED
}
enum class ParticipantStatus {
ACTIVE, LEFT, REMOVED, PENDING
}
data class LocationShare(
val id: String,
val eventId: String,
val userId: String,
val latitude: Double,
val longitude: Double,
val timestamp: Long,
val accuracy: Float?,
val isRealTime: Boolean = false
)
// 3. Система коммуникации
data class EventMessageDomain(
val id: String,
val eventId: String,
val senderId: String,
val content: String,
val messageType: MessageType, // text, location, image, system
val timestamp: Long,
val senderInfo: UserInfo?,
val metadata: Map<String, Any> = emptyMap()
)
enum class MessageType {
TEXT, LOCATION, IMAGE, SYSTEM, BROADCAST
}
data class BroadcastMessage(
val id: String,
val eventId: String,
val senderId: String,
val message: String,
val timestamp: Long,
val recipientCount: Int,
val deliveredCount: Int,
val payload: Map<String, Any> = emptyMap()
)
// 4. Мониторинг и аналитика
data class EventStats(
val totalEvents: Int,
val activeEvents: Int,
val resolvedEvents: Int,
val escalatedEvents: Int,
val cancelledEvents: Int,
val averageResponseTime: Double,
val period: String
)
data class EventHeatmap(
val center: LocationData,
val radiusKm: Double,
val events: List<HeatmapEventPoint>,
val period: String
)
data class LocationData(
val latitude: Double,
val longitude: Double
)
data class HeatmapEventPoint(
val eventId: String,
val latitude: Double,
val longitude: Double,
val intensity: Double,
val eventType: EmergencyType,
val timestamp: Long
)
data class ResponseTimeAnalytics(
val averageResponseTime: Double,
val medianResponseTime: Double,
val distribution: List<ResponseTimeBucket>,
val period: String,
val eventType: EmergencyType?
)
data class ResponseTimeBucket(
val timeRangeMinutes: String,
val eventCount: Int,
val percentage: Double
)
data class EventFeedback(
val id: String,
val eventId: String,
val userId: String,
val rating: Int, // 1-5
val comments: String?,
val createdAt: Long,
val categories: List<String> = emptyList() // helpful, fast, professional, etc.
)
// Расширенные типы приоритетов
enum class EventPriority {
LOW, MEDIUM, HIGH, CRITICAL
}
// Расширенные статусы событий
enum class ExtendedEventStatus {
CREATED, ACTIVE, ESCALATED, RESOLVED, CANCELLED, EXPIRED
}
// ========== НОВЫЕ DOMAIN МОДЕЛИ ДЛЯ 100% ФУНКЦИОНАЛЬНОСТИ ==========
// Reports
data class EmergencyReport(
val id: String,
val userId: String?,
val latitude: Double,
val longitude: Double,
val address: String?,
val reportType: String,
val description: String,
val isAnonymous: Boolean,
val severity: Int,
val status: String,
val createdAt: Long
)
data class CreateReportRequest(
val latitude: Double,
val longitude: Double,
val reportType: String,
val description: String,
val address: String? = null,
val isAnonymous: Boolean = false,
val severity: Int = 3
)
// Safety Check
data class SafetyCheck(
val id: String,
val userId: String,
val message: String?,
val latitude: Double?,
val longitude: Double?,
val createdAt: Long
)
data class CreateSafetyCheckRequest(
val message: String? = null,
val latitude: Double? = null,
val longitude: Double? = null
)
// WebSocket Management
data class WebSocketStats(
val totalConnections: Int,
val activeConnections: Int,
val totalMessagesSent: Int,
val uptimeSeconds: Int
)
data class ConnectionStats(
val totalConnections: Int,
val activeConnections: Int,
val connections: List<UserConnection>
)
data class UserConnection(
val userId: String,
val connectedAt: Long,
val lastPing: Long?
)
// Map-related models
data class MapEvent(
val id: String,
val latitude: Double,
val longitude: Double,
val eventType: EmergencyType,
val severity: Int,
val createdAt: Long,
val status: EmergencyStatus,
val distance: Double? = null,
val title: String,
val description: String?
)

View File

@@ -0,0 +1,68 @@
package kr.smartsoltech.wellshe.emergency.domain.repository
import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.emergency.domain.models.*
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus
import kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus
interface EmergencyRepository {
// Emergency Events
suspend fun createEmergencyEvent(request: CreateEmergencyRequest): Result<EmergencyEventResponse>
suspend fun getNearbyEvents(latitude: Double, longitude: Double, radius: Int): Result<List<EmergencyEvent>>
suspend fun getNearbyUsers(latitude: Double, longitude: Double, radius: Int): Result<List<NearbyUser>>
suspend fun getEventDetails(eventId: String): Result<EmergencyEventDetail>
suspend fun updateEventStatus(eventId: String, status: EmergencyStatus, note: String?): Result<Unit>
suspend fun getUserEventHistory(limit: Int, offset: Int): Result<List<EmergencyEvent>>
// Local Events
suspend fun saveLocalEmergencyEvent(request: CreateEmergencyRequest): EmergencyEvent
suspend fun markEventAsSynced(localId: String, remoteId: String)
suspend fun getUnsyncedEvents(): List<EmergencyEvent>
suspend fun syncLocalEvents(): Result<Unit>
// Event Responses
suspend fun respondToEvent(eventId: String, response: EventResponse): Result<Unit>
suspend fun getEventResponses(eventId: String): Result<List<EventResponse>>
// Real-time streams
fun getActiveEvents(): Flow<List<EmergencyEvent>>
fun getEventResponses(): Flow<EventResponseNotification>
// Connection management
fun connectWebSocket(userId: String, token: String)
fun disconnectWebSocket()
fun getConnectionStatus(): Flow<ConnectionStatus>
// Упрощенный набор методов, соответствующих реальному API
suspend fun getEmergencyStats(): Result<EventStats>
// ========== НОВЫЕ МЕТОДЫ ДЛЯ 100% ФУНКЦИОНАЛЬНОСТИ ==========
// Reports Management
suspend fun createEmergencyReport(request: CreateReportRequest): Result<EmergencyReport>
suspend fun getEmergencyReports(): Result<List<EmergencyReport>>
suspend fun getEmergencyReportsAdmin(): Result<List<EmergencyEvent>>
// Safety Check
suspend fun createSafetyCheck(request: CreateSafetyCheckRequest): Result<SafetyCheck>
suspend fun getSafetyChecks(): Result<List<SafetyCheck>>
// Enhanced Alert Management
suspend fun createAlert(latitude: Double, longitude: Double, alertType: String, message: String?): Result<EmergencyEventResponse>
suspend fun updateAlert(alertId: String, message: String?, isResolved: Boolean?): Result<EmergencyEventResponse>
suspend fun resolveAlert(alertId: String): Result<Unit>
suspend fun getMyAlerts(): Result<List<EmergencyEvent>>
suspend fun getActiveAlerts(): Result<List<EmergencyEvent>>
suspend fun getAlertResponses(alertId: String): Result<List<EventResponse>>
// WebSocket Management
suspend fun getWebSocketStats(): Result<WebSocketStats>
suspend fun getConnectionStats(): Result<ConnectionStats>
suspend fun pingWebSocketConnections(): Result<Unit>
suspend fun broadcastTestMessage(message: String): Result<Unit>
// Map and Location
suspend fun getNearbyEventsForMap(latitude: Double, longitude: Double, radiusKm: Double = 10.0): Result<List<MapEvent>>
suspend fun getEventsInRadius(latitude: Double, longitude: Double, radiusKm: Double = 1.0): Result<List<EmergencyEvent>>
}

View File

@@ -0,0 +1,31 @@
package kr.smartsoltech.wellshe.emergency.domain.usecases
import kr.smartsoltech.wellshe.emergency.domain.models.*
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
import kr.smartsoltech.wellshe.emergency.utils.ConnectivityManager
import kr.smartsoltech.wellshe.emergency.utils.DeviceInfoProvider
import javax.inject.Inject
class CreateEmergencyEventUseCase @Inject constructor(
private val repository: EmergencyRepository,
private val connectivityManager: ConnectivityManager,
private val deviceInfoProvider: DeviceInfoProvider
) {
suspend operator fun invoke(request: CreateEmergencyRequest): Result<EmergencyEventResponse> {
return try {
// Check connectivity
if (!connectivityManager.isNetworkAvailable()) {
// Save locally if no connection
val localEvent = repository.saveLocalEmergencyEvent(request)
return Result.success(EmergencyEventResponse.fromLocal(localEvent))
}
// Try to create remote event
repository.createEmergencyEvent(request)
} catch (e: Exception) {
// Fallback to local storage
val localEvent = repository.saveLocalEmergencyEvent(request)
Result.success(EmergencyEventResponse.fromLocal(localEvent))
}
}
}

View File

@@ -0,0 +1,60 @@
package kr.smartsoltech.wellshe.emergency.domain.usecases
import kr.smartsoltech.wellshe.emergency.domain.models.*
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
import javax.inject.Inject
class GetNearbyEventsUseCase @Inject constructor(
private val repository: EmergencyRepository
) {
suspend operator fun invoke(
latitude: Double,
longitude: Double,
radius: Int
): Result<List<EmergencyEvent>> {
return repository.getNearbyEvents(latitude, longitude, radius)
}
}
class GetNearbyUsersUseCase @Inject constructor(
private val repository: EmergencyRepository
) {
suspend operator fun invoke(
latitude: Double,
longitude: Double,
radius: Int
): Result<List<NearbyUser>> {
return repository.getNearbyUsers(latitude, longitude, radius)
}
}
class RespondToEventUseCase @Inject constructor(
private val repository: EmergencyRepository
) {
suspend operator fun invoke(
eventId: String,
response: EventResponse
): Result<Unit> {
return repository.respondToEvent(eventId, response)
}
}
class UpdateEventStatusUseCase @Inject constructor(
private val repository: EmergencyRepository
) {
suspend operator fun invoke(
eventId: String,
status: kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus,
note: String?
): Result<Unit> {
return repository.updateEventStatus(eventId, status, note)
}
}
class SyncLocalEventsUseCase @Inject constructor(
private val repository: EmergencyRepository
) {
suspend operator fun invoke(): Result<Unit> {
return repository.syncLocalEvents()
}
}

View File

@@ -0,0 +1,14 @@
package kr.smartsoltech.wellshe.emergency.domain.usecases
import kotlinx.coroutines.flow.Flow
import kr.smartsoltech.wellshe.emergency.domain.models.*
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
import javax.inject.Inject
class ObserveEmergencyAlertsUseCase @Inject constructor(
private val repository: EmergencyRepository
) {
operator fun invoke(): Flow<List<EmergencyEvent>> {
return repository.getActiveEvents()
}
}

View File

@@ -0,0 +1,824 @@
package kr.smartsoltech.wellshe.emergency.presentation.screens
import android.content.Intent
import android.net.Uri
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.automirrored.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.BuildConfig
import kr.smartsoltech.wellshe.emergency.debug.AuthTester
import kr.smartsoltech.wellshe.emergency.domain.models.EmergencyEvent
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyType
import kr.smartsoltech.wellshe.emergency.data.models.ResponseType
import kr.smartsoltech.wellshe.emergency.presentation.viewmodels.EmergencyViewModel
import kr.smartsoltech.wellshe.emergency.presentation.viewmodels.EmergencyUiState
import com.google.accompanist.permissions.*
import java.text.SimpleDateFormat
import java.util.*
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.core.content.ContextCompat
import android.content.pm.PackageManager
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun EmergencyScreen(
modifier: Modifier = Modifier,
viewModel: EmergencyViewModel = hiltViewModel(),
onNavigateToMap: () -> Unit = {},
onNavigateToHistory: () -> Unit = {}
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState(initial = EmergencyUiState())
val emergencyEvents by viewModel.emergencyEvents.collectAsState(initial = emptyList())
val connectionStatus by viewModel.connectionStatus.collectAsState(initial = kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus.DISCONNECTED)
// Запрашиваем только foreground-пермишены здесь; background location требует отдельного UX/потока
val locationPermissions = rememberMultiplePermissionsState(
listOf(
android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_COARSE_LOCATION
)
)
val powerManager = context.getSystemService(PowerManager::class.java)
val isIgnoringBatteryOptimizations = powerManager?.isIgnoringBatteryOptimizations(context.packageName) == true
LaunchedEffect(locationPermissions.allPermissionsGranted) {
viewModel.onPermissionsResult(locationPermissions.allPermissionsGranted)
}
val openAppSettings: () -> Unit = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
}
// Sync permissions state on start and when returning to the screen (e.g., user changed them in system settings)
val lifecycleOwner = LocalLifecycleOwner.current
// Initial check
LaunchedEffect(Unit) {
val hasLocationNow = ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
viewModel.onPermissionsResult(hasLocationNow)
}
// Observe resume to re-check permissions if user returned from system settings
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
val hasLocationNow = ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
viewModel.onPermissionsResult(hasLocationNow)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
EmergencyScreenContent(
modifier = modifier,
uiState = uiState,
emergencyEvents = emergencyEvents,
connectionStatus = connectionStatus,
onSOSClick = { viewModel.createSOS() },
onCustomAlert = { type, message, severity ->
viewModel.createCustomAlert(type, message, severity)
},
onEventRespond = { eventId, responseType ->
viewModel.respondToEvent(eventId, responseType)
},
onRefresh = { viewModel.refreshNearbyEvents() },
onClearSOSNotifications = { viewModel.clearSOSNotifications() },
onNavigateToMap = onNavigateToMap,
onNavigateToHistory = onNavigateToHistory,
onRequestPermissions = {
if (locationPermissions.shouldShowRationale || !locationPermissions.allPermissionsGranted) {
locationPermissions.launchMultiplePermissionRequest()
} else {
openAppSettings()
}
},
onRequestBatteryOptimization = {
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
},
isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations,
onClearSuccess = { viewModel.clearSuccessMessage() },
onClearError = { viewModel.clearErrorMessage() }
)
}
@Composable
private fun EmergencyScreenContent(
modifier: Modifier = Modifier,
uiState: EmergencyUiState,
emergencyEvents: List<EmergencyEvent>,
connectionStatus: kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus,
onSOSClick: () -> Unit,
onCustomAlert: (EmergencyType, String?, Int) -> Unit,
onEventRespond: (String, ResponseType) -> Unit,
onRefresh: () -> Unit,
onClearSOSNotifications: () -> Unit,
onNavigateToMap: () -> Unit,
onNavigateToHistory: () -> Unit,
onRequestPermissions: () -> Unit,
onRequestBatteryOptimization: () -> Unit,
isIgnoringBatteryOptimizations: Boolean,
onClearSuccess: () -> Unit,
onClearError: () -> Unit
) {
// Вычисляем состояние разрешений и логируем ВНЕ LazyColumn — внутри лямбды контента LazyColumn
// нельзя вызывать composition locals, поэтому LocalContext.current нужно вызывать здесь.
val localContext = LocalContext.current
val hasLocationNow = ContextCompat.checkSelfPermission(
localContext,
android.Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(
localContext,
android.Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val effectivePermissionsGranted = uiState.permissionsGranted || hasLocationNow
// Debug log to show why UI may still require permissions
Log.d(
"EmergencyScreen",
"ui.permissionsGranted=${uiState.permissionsGranted}, hasLocationNow=$hasLocationNow, effectivePermissionsGranted=$effectivePermissionsGranted"
)
Log.i(
"EmergencyScreen",
"effectivePermissionsGranted=$effectivePermissionsGranted (uiState.permissionsGranted=${uiState.permissionsGranted}, hasLocationNow=$hasLocationNow)"
)
// Extra visibility in logs when permissions are missing
if (!effectivePermissionsGranted) {
Log.w(
"EmergencyScreen",
"Permissions not granted — PermissionRequestCard will be shown. Check app/system settings or request flow."
)
}
// Вынесем scope сюда — вызов rememberCoroutineScope() должен происходить в контексте @Composable, но не внутри LazyListScope (контента LazyColumn)
val scope = rememberCoroutineScope()
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Connection Status Indicator
item {
ConnectionStatusIndicator(connectionStatus)
}
if (!effectivePermissionsGranted) {
item {
PermissionRequestCard(onRequestPermissions)
}
}
// Battery Optimization Request
if (!isIgnoringBatteryOptimizations) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Для корректной работы SOS и фоновых событий отключите оптимизацию батареи для приложения",
color = MaterialTheme.colorScheme.onErrorContainer,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onRequestBatteryOptimization, modifier = Modifier.fillMaxWidth()) {
Text("Отключить оптимизацию батареи")
}
}
}
}
}
// SOS Button - главный элемент
item {
SOSButton(
isPressed = uiState.sosButtonPressed,
isLoading = uiState.isCreatingEmergency,
enabled = effectivePermissionsGranted,
onClick = onSOSClick
)
}
// Quick Actions
item {
QuickActionsRow(
onMapClick = onNavigateToMap,
onHistoryClick = onNavigateToHistory,
onRefresh = onRefresh,
onClearNotifications = onClearSOSNotifications,
isLoading = uiState.isLoadingNearbyEvents
)
}
// Custom Alert Options
item {
CustomAlertSection(
enabled = effectivePermissionsGranted && !uiState.isCreatingEmergency,
onCustomAlert = onCustomAlert
)
}
// Active Emergency Events
if (emergencyEvents.isNotEmpty()) {
item {
Text(
text = "🚨 Экстренные ситуации рядом (${emergencyEvents.size})",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold
)
}
items(emergencyEvents, key = { it.id }) { event ->
EmergencyEventCard(
event = event,
onRespond = { responseType -> onEventRespond(event.id, responseType) }
)
}
} else if (!uiState.isLoadingNearbyEvents) {
item {
EmptyStateCard()
}
}
// Loading indicator
if (uiState.isLoadingNearbyEvents) {
item {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
// Debug: кнопка запуска теста правильной аутентификации и WebSocket
if (BuildConfig.DEBUG) {
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(onClick = {
scope.launch {
try {
val result = AuthTester.runFullTest(localContext)
Log.i("EmergencyScreen", "AuthTester.runFullTest result=$result")
} catch (e: Exception) {
Log.e("EmergencyScreen", "AuthTester error: ${e.message}", e)
}
}
}, modifier = Modifier.fillMaxWidth()) {
Text("DEBUG: Run auth & WS test")
}
OutlinedButton(onClick = onClearSOSNotifications, modifier = Modifier.fillMaxWidth()) {
Text("DEBUG: Clear SOS notifications")
}
}
}
}
}
// Handle UI Effects
HandleUIEffects(uiState, onClearSuccess, onClearError)
}
@Composable
fun SOSButton(
isPressed: Boolean,
isLoading: Boolean,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "sosButtonScale"
)
val glowAnimation by rememberInfiniteTransition(label = "sosGlow").animateFloat(
initialValue = 0.8f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "sosGlowAlpha"
)
Button(
onClick = onClick,
enabled = enabled && !isLoading,
modifier = modifier
.fillMaxWidth()
.height(140.dp)
.scale(scale),
colors = ButtonDefaults.buttonColors(
containerColor = if (isPressed) Color(0xFFB71C1C) else Color.Red,
contentColor = Color.White,
disabledContainerColor = Color.Gray
),
shape = RoundedCornerShape(20.dp),
elevation = ButtonDefaults.buttonElevation(
defaultElevation = if (isPressed) 4.dp else 12.dp
)
) {
if (isLoading) {
CircularProgressIndicator(
color = Color.White,
modifier = Modifier.size(48.dp),
strokeWidth = 4.dp
)
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = Color.White.copy(alpha = if (enabled) glowAnimation else 0.5f)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "SOS",
fontSize = 36.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = 4.sp
)
Text(
text = if (enabled) "Нажмите для экстренного вызова" else "Необходимы разрешения",
fontSize = 14.sp,
textAlign = TextAlign.Center,
color = Color.White.copy(alpha = 0.9f)
)
}
}
}
}
@Composable
fun ConnectionStatusIndicator(
status: kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus,
modifier: Modifier = Modifier
) {
val (color, text, icon) = when (status) {
kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus.CONNECTED ->
Triple(Color.Green, "Подключено", Icons.Default.Wifi)
kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus.CONNECTING ->
Triple(Color(0xFFFF9800), "Подключение...", Icons.Default.WifiFind)
kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus.DISCONNECTED ->
Triple(Color.Red, "Отключено", Icons.Default.WifiOff)
kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus.RECONNECTING ->
Triple(Color(0xFFFF9800), "Переподключение...", Icons.Default.Refresh)
}
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = color.copy(alpha = 0.1f)
)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Real-time: $text",
color = color,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
}
@Composable
fun PermissionRequestCard(
onRequestPermissions: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.LocationOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Требуются разрешения",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Для работы экстренных сигналов необходим доступ к геолокации",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onRequestPermissions,
modifier = Modifier.fillMaxWidth()
) {
Text("Предоставить разрешения")
}
}
}
}
@Composable
fun QuickActionsRow(
onMapClick: () -> Unit,
onHistoryClick: () -> Unit,
onRefresh: () -> Unit,
onClearNotifications: () -> Unit = {},
isLoading: Boolean,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
QuickActionButton(
icon = Icons.Default.Map,
text = "Карта",
onClick = onMapClick,
modifier = Modifier.weight(1f)
)
QuickActionButton(
icon = Icons.Default.History,
text = "История",
onClick = onHistoryClick,
modifier = Modifier.weight(1f)
)
QuickActionButton(
icon = if (isLoading) Icons.Default.Refresh else Icons.Default.Refresh,
text = "Обновить",
onClick = onRefresh,
modifier = Modifier.weight(1f),
enabled = !isLoading
)
QuickActionButton(
icon = Icons.Default.Clear,
text = "Очистить SOS",
onClick = onClearNotifications,
modifier = Modifier.weight(1f)
)
}
}
@Composable
fun QuickActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.height(56.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Text(
text = text,
fontSize = 10.sp
)
}
}
}
@Composable
fun CustomAlertSection(
enabled: Boolean,
onCustomAlert: (EmergencyType, String?, Int) -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Другие типы сигналов",
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Spacer(modifier = Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CustomAlertButton(
text = "Преследование",
icon = Icons.AutoMirrored.Filled.DirectionsRun,
color = Color(0xFFFF5722),
enabled = enabled,
onClick = { onCustomAlert(EmergencyType.STALKING, "Меня преследуют", 4) },
modifier = Modifier.weight(1f)
)
CustomAlertButton(
text = "Домогательство",
icon = Icons.Default.Block,
color = Color(0xFFE91E63),
enabled = enabled,
onClick = { onCustomAlert(EmergencyType.HARASSMENT, "Домогательство", 3) },
modifier = Modifier.weight(1f)
)
}
}
}
}
@Composable
fun CustomAlertButton(
text: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
color: Color,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.height(72.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = color
)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = if (enabled) color else Color.Gray
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = text,
fontSize = 11.sp,
textAlign = TextAlign.Center,
color = if (enabled) color else Color.Gray
)
}
}
}
@Composable
fun EmergencyEventCard(
event: EmergencyEvent,
onRespond: (ResponseType) -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.1f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = when (event.eventType) {
EmergencyType.SOS -> Icons.Default.Warning
EmergencyType.HARASSMENT -> Icons.Default.Block
EmergencyType.STALKING -> Icons.AutoMirrored.Filled.DirectionsRun
EmergencyType.MEDICAL -> Icons.Default.LocalHospital
EmergencyType.FIRE -> Icons.Default.Whatshot
EmergencyType.ACCIDENT -> Icons.Default.CarCrash
EmergencyType.OTHER -> Icons.AutoMirrored.Filled.Help
},
contentDescription = null,
tint = Color.Red,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = event.eventType.name,
fontWeight = FontWeight.Bold,
color = Color.Red
)
}
Spacer(modifier = Modifier.height(4.dp))
event.userInfo?.let { userInfo ->
Text(
text = "${userInfo.firstName}${userInfo.age?.let { ", $it лет" } ?: ""}",
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}
if (!event.message.isNullOrBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = event.message,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
}
Column(horizontalAlignment = Alignment.End) {
event.distanceMeters?.let { distance ->
Text(
text = "${distance.toInt()}м",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(event.createdAt)),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { onRespond(ResponseType.ON_WAY) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.Directions,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Помочь", fontSize = 12.sp)
}
OutlinedButton(
onClick = { onRespond(ResponseType.CALLED_POLICE) },
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Phone,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("112", fontSize = 12.sp)
}
}
}
}
}
@Composable
fun EmptyStateCard(
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Security,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Все спокойно",
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "В данный момент нет активных экстренных ситуаций в вашем районе",
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
@Composable
fun HandleUIEffects(
uiState: EmergencyUiState,
onClearSuccess: () -> Unit,
onClearError: () -> Unit
) {
LaunchedEffect(uiState.showSuccessMessage) {
if (uiState.showSuccessMessage && !uiState.successMessage.isNullOrBlank()) {
// Show success message
onClearSuccess()
}
}
LaunchedEffect(uiState.errorMessage) {
if (!uiState.errorMessage.isNullOrBlank()) {
// Show error message
onClearError()
}
}
}

View File

@@ -0,0 +1,343 @@
package kr.smartsoltech.wellshe.emergency.presentation.viewmodels
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.emergency.domain.models.*
import kr.smartsoltech.wellshe.emergency.domain.usecases.*
import kr.smartsoltech.wellshe.emergency.data.models.*
import kr.smartsoltech.wellshe.emergency.utils.PermissionManager
import kr.smartsoltech.wellshe.emergency.utils.EmergencyNotificationManager
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
import kr.smartsoltech.wellshe.data.storage.TokenManager
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
import javax.inject.Inject
@HiltViewModel
class EmergencyViewModel @Inject constructor(
private val createEmergencyUseCase: CreateEmergencyEventUseCase,
private val getNearbyEventsUseCase: GetNearbyEventsUseCase,
private val respondToEventUseCase: RespondToEventUseCase,
private val observeEmergencyAlertsUseCase: ObserveEmergencyAlertsUseCase,
private val repository: EmergencyRepository,
private val permissionManager: PermissionManager,
private val notificationManager: EmergencyNotificationManager,
private val tokenManager: TokenManager,
private val authTokenRepository: AuthTokenRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(EmergencyUiState())
val uiState: StateFlow<EmergencyUiState> = _uiState.asStateFlow()
private val _emergencyEvents = MutableStateFlow<List<EmergencyEvent>>(emptyList())
val emergencyEvents: StateFlow<List<EmergencyEvent>> = _emergencyEvents.asStateFlow()
val connectionStatus = repository.getConnectionStatus()
init {
initializeEmergencyModule()
}
private fun initializeEmergencyModule() {
viewModelScope.launch {
// Проверить разрешения
checkRequiredPermissions()
// Попытка получить реальный токен и подключиться по WebSocket
// Предпочитаем TokenManager (свежий in-memory токен), затем DataStore-backed auth token
val dsToken = authTokenRepository.authToken.firstOrNull()
val tmToken = tokenManager.getAccessToken()
// Если в DataStore остался временный плейсхолдер вроде "temp_token_for_...", игнорируем его
// Игнорируем placeholder в обоих хранилищах
val effectiveTmToken = if (!tmToken.isNullOrEmpty() && !tmToken.startsWith("temp_token_for_")) tmToken else null
val effectiveDsToken = if (!dsToken.isNullOrEmpty() && !dsToken.startsWith("temp_token_for_")) dsToken else null
if (tmToken != null && tmToken.startsWith("temp_token_for_")) {
Log.w("EmergencyVM", "TokenManager contains placeholder token; ignoring for WS connect: $tmToken")
}
if (dsToken != null && dsToken.startsWith("temp_token_for_")) {
Log.w("EmergencyVM", "DataStore contains placeholder token; ignoring for WS connect: $dsToken")
}
val token = effectiveTmToken ?: effectiveDsToken
if (!token.isNullOrEmpty()) {
Log.d("EmergencyVM", "Connecting WebSocket with masked token: ${token.take(6)}... (source=${if (!tmToken.isNullOrEmpty()) "TokenManager" else "DataStore"})")
repository.connectWebSocket("current_user_id", token)
} else {
Log.d("EmergencyVM", "No access token available — пропускаем авто-подключение WebSocket")
}
// Подписаться на алерты
observeEmergencyAlerts()
// Загрузить ближайшие события
refreshNearbyEvents()
// Синхронизировать локальные события
syncLocalEvents()
}
}
fun createSOS(message: String? = null) {
viewModelScope.launch {
if (!permissionManager.hasLocationPermission()) {
_uiState.value = _uiState.value.copy(
errorMessage = "Необходимо разрешение на геолокацию"
)
return@launch
}
_uiState.value = _uiState.value.copy(
isCreatingEmergency = true,
sosButtonPressed = true,
errorMessage = null
)
Log.d("EmergencyVM", "Calling createEmergencyUseCase...")
// Создаем правильный объект CreateEmergencyRequest
val request = CreateEmergencyRequest(
latitude = 0.0, // TODO: получить реальные координаты
longitude = 0.0,
eventType = EmergencyType.SOS,
message = message,
severity = 5
)
createEmergencyUseCase(request).fold(
onSuccess = { response ->
Log.i("EmergencyVM", "SOS created successfully: eventId=${response.eventId}, nearbyUsers=${response.nearbyUsersNotified}")
_uiState.value = _uiState.value.copy(
isCreatingEmergency = false,
sosButtonPressed = false,
lastEmergencyId = response.eventId,
showSuccessMessage = true,
successMessage = "SOS отправлен! Уведомлено ${response.nearbyUsersNotified} пользователей"
)
// Показать уведомление
try {
viewModelScope.launch {
notificationManager.showSOSCreatedNotification(response)
}
Log.d("EmergencyVM", "SOS notification shown")
} catch (e: Exception) {
Log.e("EmergencyVM", "Error showing SOS notification: ${e.message}", e)
}
// Обновить список событий
Log.d("EmergencyVM", "Refreshing nearby events after SOS creation")
refreshNearbyEvents()
},
onFailure = { error ->
Log.e("EmergencyVM", "Failed to create SOS: ${error.message}", error)
val userFriendlyMessage = when {
error.message?.contains("геолокац") == true -> "Не удалось получить вашу геолокацию"
error.message?.contains("auth token") == true -> "Ошибка авторизации. Попробуйте войти заново"
error.message?.contains("API Error: 401") == true -> "Сессия истекла. Необходимо войти заново"
error.message?.contains("API Error: 403") == true -> "Доступ запрещён. Проверьте права доступа"
error.message?.contains("API Error: 500") == true -> "Ошибка сервера. Попробуйте позже"
error.message?.contains("network") == true -> "Проблемы с сетью. Проверьте подключение к интернету"
else -> "Ошибка отправки SOS: ${error.message}"
}
_uiState.value = _uiState.value.copy(
isCreatingEmergency = false,
sosButtonPressed = false,
errorMessage = userFriendlyMessage
)
}
)
}
}
fun createCustomAlert(eventType: EmergencyType, message: String?, severity: Int) {
viewModelScope.launch {
if (!permissionManager.hasLocationPermission()) {
_uiState.value = _uiState.value.copy(
errorMessage = "Необходимо разрешение на геолокацию"
)
return@launch
}
_uiState.value = _uiState.value.copy(isCreatingEmergency = true)
val request = CreateEmergencyRequest(
latitude = 0.0, // TODO: получить реальные координаты
longitude = 0.0,
eventType = eventType,
message = message,
severity = severity
)
createEmergencyUseCase(request).fold(
onSuccess = { response ->
_uiState.value = _uiState.value.copy(
isCreatingEmergency = false,
showSuccessMessage = true,
successMessage = "Сигнал ${eventType.name} отправлен"
)
refreshNearbyEvents()
},
onFailure = { error ->
_uiState.value = _uiState.value.copy(
isCreatingEmergency = false,
errorMessage = error.message
)
}
)
}
}
fun respondToEvent(eventId: String, responseType: ResponseType = ResponseType.ON_WAY, message: String? = null) {
viewModelScope.launch {
val response = EventResponse(
id = "",
eventId = eventId,
responderId = "current_user", // TODO: получить реальный ID
responseType = responseType,
message = message,
estimatedArrival = null,
createdAt = System.currentTimeMillis(),
responderInfo = null
)
respondToEventUseCase(eventId, response).fold(
onSuccess = {
_uiState.value = _uiState.value.copy(
showSuccessMessage = true,
successMessage = "Ваш ответ отправлен"
)
},
onFailure = { error ->
_uiState.value = _uiState.value.copy(
errorMessage = "Ошибка отправки ответа: ${error.message}"
)
}
)
}
}
fun refreshNearbyEvents() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoadingNearbyEvents = true)
// TODO: получить реальные координаты пользователя
getNearbyEventsUseCase(0.0, 0.0, 1000).fold(
onSuccess = { events ->
_emergencyEvents.value = events
_uiState.value = _uiState.value.copy(isLoadingNearbyEvents = false)
},
onFailure = { error ->
_uiState.value = _uiState.value.copy(
isLoadingNearbyEvents = false,
errorMessage = "Ошибка загрузки событий: ${error.message}"
)
}
)
}
}
private fun observeEmergencyAlerts() {
viewModelScope.launch {
observeEmergencyAlertsUseCase().collect { events ->
_emergencyEvents.value = events
}
}
}
private fun checkRequiredPermissions() {
val hasLocation = permissionManager.hasLocationPermission()
val hasNotifications = permissionManager.hasNotificationPermission()
// For enabling SOS we require location permissions (foreground). Notification permission is optional.
val permissionsGranted = hasLocation
// Log for debugging why UI may show permissions required
try {
Log.d("EmergencyVM", "Permissions check — location=$hasLocation, notifications=$hasNotifications -> ui.permissionsGranted=$permissionsGranted")
} catch (_: Exception) {
// ignore logging errors
}
_uiState.value = _uiState.value.copy(permissionsGranted = permissionsGranted)
}
private fun syncLocalEvents() {
viewModelScope.launch {
repository.syncLocalEvents()
}
}
fun clearSuccessMessage() {
_uiState.value = _uiState.value.copy(
showSuccessMessage = false,
successMessage = null
)
}
fun clearErrorMessage() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
// Метод для ручной очистки SOS уведомлений
fun clearSOSNotifications() {
viewModelScope.launch {
try {
notificationManager.clearSOSNotifications()
Log.d("EmergencyVM", "SOS notifications cleared successfully")
_uiState.value = _uiState.value.copy(
showSuccessMessage = true,
successMessage = "SOS уведомления очищены"
)
} catch (e: Exception) {
Log.e("EmergencyVM", "Error clearing SOS notifications: ${e.message}", e)
_uiState.value = _uiState.value.copy(
errorMessage = "Ошибка при очистке уведомлений: ${e.message}"
)
}
}
}
// Метод для очистки всех экстренных уведомлений
fun clearAllEmergencyNotifications() {
viewModelScope.launch {
try {
notificationManager.cancelAllEmergencyNotifications()
Log.d("EmergencyVM", "All emergency notifications cleared successfully")
_uiState.value = _uiState.value.copy(
showSuccessMessage = true,
successMessage = "Все экстренные уведомления очищены"
)
} catch (e: Exception) {
Log.e("EmergencyVM", "Error clearing all emergency notifications: ${e.message}", e)
_uiState.value = _uiState.value.copy(
errorMessage = "Ошибка при очистке всех уведомлений: ${e.message}"
)
}
}
}
fun onPermissionsResult(granted: Boolean) {
_uiState.value = _uiState.value.copy(permissionsGranted = granted)
}
}
data class EmergencyUiState(
val isCreatingEmergency: Boolean = false,
val sosButtonPressed: Boolean = false,
val lastEmergencyId: String? = null,
val showSuccessMessage: Boolean = false,
val successMessage: String? = null,
val errorMessage: String? = null,
val isLoadingNearbyEvents: Boolean = false,
val permissionsGranted: Boolean = false
)

View File

@@ -0,0 +1,58 @@
package kr.smartsoltech.wellshe.emergency.utils
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConnectivityManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
fun isNetworkAvailable(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} else {
@Suppress("DEPRECATION")
val networkInfo = connectivityManager.activeNetworkInfo
networkInfo?.isConnected == true
}
}
fun getNetworkType(): String {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val network = connectivityManager.activeNetwork ?: return "none"
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return "none"
return when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
else -> "other"
}
} else {
@Suppress("DEPRECATION")
val networkInfo = connectivityManager.activeNetworkInfo
return networkInfo?.typeName?.lowercase() ?: "none"
}
}
fun isWifiConnected(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
} else {
@Suppress("DEPRECATION")
val networkInfo = connectivityManager.activeNetworkInfo
return networkInfo?.type == ConnectivityManager.TYPE_WIFI && networkInfo.isConnected
}
}
}

View File

@@ -0,0 +1,57 @@
package kr.smartsoltech.wellshe.emergency.utils
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext
import kr.smartsoltech.wellshe.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DeviceInfoProvider @Inject constructor(
@ApplicationContext private val context: Context
) {
fun getBatteryLevel(): Int {
val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
return if (level != -1 && scale != -1) {
(level * 100 / scale.toFloat()).toInt()
} else {
-1
}
}
fun getAppVersion(): String {
return try {
BuildConfig.VERSION_NAME
} catch (e: Exception) {
"unknown"
}
}
fun getDeviceInfo(): Map<String, String> {
return mapOf(
"model" to Build.MODEL,
"manufacturer" to Build.MANUFACTURER,
"android_version" to Build.VERSION.RELEASE,
"sdk_int" to Build.VERSION.SDK_INT.toString(),
"device" to Build.DEVICE,
"product" to Build.PRODUCT
)
}
fun isLowPowerMode(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
powerManager.isPowerSaveMode
} else {
false
}
}
}

View File

@@ -0,0 +1,135 @@
package kr.smartsoltech.wellshe.emergency.utils
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.emergency.domain.models.EventResponse
import kr.smartsoltech.wellshe.emergency.data.models.ResponseType
class EmergencyActionReceiver : BroadcastReceiver() {
private val receiverScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
val eventId = intent.getStringExtra("event_id")
val userName = intent.getStringExtra("user_name")
Log.d("EmergencyActionReceiver", "Received action: ${intent.action} for event: $eventId")
when (intent.action) {
"HELP_ACTION" -> {
handleHelpAction(context, eventId, userName)
}
"CALL_POLICE_ACTION" -> {
handleCallPoliceAction(context, eventId)
}
}
}
private fun handleHelpAction(context: Context, eventId: String?, userName: String?) {
if (eventId.isNullOrEmpty()) {
Log.e("EmergencyActionReceiver", "Event ID is null or empty for help action")
return
}
receiverScope.launch {
try {
// Получаем репозиторий через Application context
val appContext = context.applicationContext as kr.smartsoltech.wellshe.WellSheApplication
val emergencyRepository = appContext.emergencyRepository
// Создаем правильный объект EventResponse для отправки ответа на событие
val response = EventResponse(
id = "",
eventId = eventId,
responderId = "current_user", // TODO: получить реальный ID пользователя
responseType = ResponseType.ON_WAY,
message = "Иду на помощь",
estimatedArrival = null,
createdAt = System.currentTimeMillis(),
responderInfo = null
)
// Отправляем ответ на событие
emergencyRepository.respondToEvent(eventId, response)
Log.i("EmergencyActionReceiver", "Help response sent for event: $eventId")
// Показываем уведомление об успешном ответе
showResponseConfirmation(context, "Ваш ответ отправлен! Вы идете на помощь ${userName ?: "пользователю"}")
} catch (e: Exception) {
Log.e("EmergencyActionReceiver", "Failed to send help response for event: $eventId", e)
showResponseConfirmation(context, "Ошибка отправки ответа: ${e.message}")
}
}
}
private fun handleCallPoliceAction(context: Context, eventId: String?) {
if (eventId.isNullOrEmpty()) {
Log.e("EmergencyActionReceiver", "Event ID is null or empty for police call action")
return
}
try {
// Номер экстренных служб (можно настроить в зависимости от страны)
val emergencyNumber = "102" // Полиция в России
val callIntent = Intent(Intent.ACTION_CALL).apply {
data = Uri.parse("tel:$emergencyNumber")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(callIntent)
Log.i("EmergencyActionReceiver", "Emergency call initiated for event: $eventId")
// Отправляем информацию о том, что была вызвана полиция
receiverScope.launch {
try {
// Получаем репозиторий через Application context
val appContext = context.applicationContext as kr.smartsoltech.wellshe.WellSheApplication
val emergencyRepository = appContext.emergencyRepository
val response = EventResponse(
id = "",
eventId = eventId,
responderId = "current_user", // TODO: получить реальный ID пользователя
responseType = ResponseType.CALLED_POLICE,
message = "Вызвана полиция",
estimatedArrival = null,
createdAt = System.currentTimeMillis(),
responderInfo = null
)
emergencyRepository.respondToEvent(eventId, response)
showResponseConfirmation(context, "Полиция вызвана")
} catch (e: Exception) {
Log.e("EmergencyActionReceiver", "Failed to send police call response", e)
}
}
} catch (e: SecurityException) {
Log.e("EmergencyActionReceiver", "Permission denied for making phone calls", e)
showResponseConfirmation(context, "Нет разрешения для совершения звонков")
} catch (e: Exception) {
Log.e("EmergencyActionReceiver", "Failed to initiate emergency call", e)
showResponseConfirmation(context, "Ошибка при попытке вызвать полицию: ${e.message}")
}
}
private fun showResponseConfirmation(context: Context, message: String) {
// Можно показать Toast или отдельное уведомление
try {
android.widget.Toast.makeText(context, message, android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Log.e("EmergencyActionReceiver", "Failed to show response confirmation", e)
}
}
}

View File

@@ -0,0 +1,495 @@
package kr.smartsoltech.wellshe.emergency.utils
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.media.AudioAttributes
import android.media.RingtoneManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kr.smartsoltech.wellshe.R
import kr.smartsoltech.wellshe.emergency.data.websocket.EmergencyAlert
import kr.smartsoltech.wellshe.emergency.domain.models.EmergencyEventResponse
import kr.smartsoltech.wellshe.MainActivity
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.delay
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class EmergencyNotificationManager @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
const val EMERGENCY_CHANNEL_ID = "emergency_alerts"
const val SOS_CHANNEL_ID = "sos_notifications"
const val GENERAL_CHANNEL_ID = "general_emergency"
// Группировка уведомлений
const val EMERGENCY_GROUP_KEY = "emergency_group"
const val SOS_GROUP_KEY = "sos_group"
const val NOTIFICATION_ID_EMERGENCY_ALERT = 1001
const val NOTIFICATION_ID_SOS_CREATED = 1002
const val NOTIFICATION_ID_SOS_SUMMARY = 1003
const val NOTIFICATION_ID_EMERGENCY_SUMMARY = 1004
// Время жизни SOS уведомлений
private const val SOS_NOTIFICATION_LIFETIME_MS = 10 * 60 * 1000L // 10 минут
private const val AUTO_CLEANUP_INTERVAL_MS = 2 * 60 * 1000L // Проверка каждые 2 минуты
}
private val notificationManager = NotificationManagerCompat.from(context)
private val mutex = Mutex()
// CoroutineScope для фоновых задач
private val notificationScope = CoroutineScope(SupervisorJob())
// Хранение активных SOS событий для группировки
private val activeSosEvents = mutableMapOf<String, SOSEventInfo>()
data class SOSEventInfo(
val eventId: String,
val response: EmergencyEventResponse,
val timestamp: Long = System.currentTimeMillis()
)
init {
createNotificationChannels()
startAutomaticCleanup()
}
// Автоматическая очистка старых SOS уведомлений
private fun startAutomaticCleanup() {
notificationScope.launch {
while (true) {
delay(AUTO_CLEANUP_INTERVAL_MS)
cleanupOldSOSNotifications()
}
}
}
private suspend fun cleanupOldSOSNotifications() {
mutex.withLock {
val currentTime = System.currentTimeMillis()
val expiredEvents = activeSosEvents.filter {
currentTime - it.value.timestamp > SOS_NOTIFICATION_LIFETIME_MS
}
if (expiredEvents.isNotEmpty()) {
expiredEvents.forEach { (eventId, _) ->
activeSosEvents.remove(eventId)
// Отменяем индивидуальное уведомление
notificationManager.cancel(eventId.hashCode())
}
// Обновляем группированное уведомление или убираем его если событий больше нет
when (activeSosEvents.size) {
0 -> {
notificationManager.cancel(NOTIFICATION_ID_SOS_CREATED)
notificationManager.cancel(NOTIFICATION_ID_SOS_SUMMARY)
}
1 -> {
// Показываем одиночное уведомление вместо группы
val singleEvent = activeSosEvents.values.first()
showSingleSOSNotification(singleEvent.response)
notificationManager.cancel(NOTIFICATION_ID_SOS_SUMMARY)
}
else -> {
// Обновляем группированное уведомление
showGroupedSOSNotification()
}
}
android.util.Log.d("EmergencyNotificationManager",
"Cleaned up ${expiredEvents.size} expired SOS notifications. " +
"Active events remaining: ${activeSosEvents.size}")
}
}
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val emergencyChannel = NotificationChannel(
EMERGENCY_CHANNEL_ID,
"Экстренные уведомления",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Критически важные уведомления об экстренных ситуациях"
enableLights(true)
lightColor = Color.RED
enableVibration(true)
vibrationPattern = longArrayOf(0, 1000, 500, 1000, 500, 1000)
setSound(
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM),
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ALARM)
.build()
)
}
val sosChannel = NotificationChannel(
SOS_CHANNEL_ID,
"SOS уведомления",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Подтверждения отправки SOS сигналов"
enableLights(true)
lightColor = Color.BLUE
}
val generalChannel = NotificationChannel(
GENERAL_CHANNEL_ID,
"Общие уведомления",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Общие уведомления модуля безопасности"
}
val systemNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
systemNotificationManager.createNotificationChannel(emergencyChannel)
systemNotificationManager.createNotificationChannel(sosChannel)
systemNotificationManager.createNotificationChannel(generalChannel)
}
}
fun showCriticalEmergencyAlert(alert: EmergencyAlert) {
val intent = Intent(context, MainActivity::class.java).apply {
putExtra("navigate_to", "emergency")
putExtra("emergency_event_id", alert.eventId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context,
alert.eventId.hashCode(),
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val notification = NotificationCompat.Builder(context, EMERGENCY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_emergency_24)
.setContentTitle("🚨 ЭКСТРЕННАЯ СИТУАЦИЯ")
.setContentText("${alert.userInfo.firstName} нуждается в помощи (${alert.distanceMeters}м)")
.setStyle(
NotificationCompat.BigTextStyle()
.bigText("${alert.userInfo.firstName} (${alert.userInfo.age ?: "неизвестен"} лет) сообщает: ${alert.message}\n\nРасстояние: ${alert.distanceMeters}м\nУровень опасности: ${alert.severity}/5")
)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM))
.setVibrate(longArrayOf(0, 1000, 500, 1000, 500, 1000))
.setLights(Color.RED, 1000, 500)
.setGroup(EMERGENCY_GROUP_KEY)
.addAction(createHelpAction(alert.eventId, alert.userInfo.firstName))
.addAction(createCallPoliceAction(alert.eventId))
.build()
notificationManager.notify(alert.eventId.hashCode(), notification)
}
suspend fun showSOSCreatedNotification(response: EmergencyEventResponse) {
mutex.withLock {
// Добавляем новое SOS событие
activeSosEvents[response.eventId] = SOSEventInfo(response.eventId, response)
// Очищаем старые события (старше 5 минут)
val fiveMinutesAgo = System.currentTimeMillis() - 5 * 60 * 1000
activeSosEvents.entries.removeAll { it.value.timestamp < fiveMinutesAgo }
when (activeSosEvents.size) {
1 -> showSingleSOSNotification(response)
else -> showGroupedSOSNotification()
}
}
}
private fun showSingleSOSNotification(response: EmergencyEventResponse) {
val intent = Intent(context, MainActivity::class.java).apply {
putExtra("navigate_to", "emergency")
putExtra("emergency_event_id", response.eventId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
val pendingIntent = PendingIntent.getActivity(
context,
response.eventId.hashCode(),
intent,
PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, SOS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_check_circle_24)
.setContentTitle("✅ SOS отправлен")
.setContentText("Уведомлено ${response.nearbyUsersNotified} пользователей поблизости")
.setStyle(
NotificationCompat.BigTextStyle()
.bigText("Ваш SOS сигнал успешно отправлен!\n\nУведомлено пользователей: ${response.nearbyUsersNotified}\nВремя ответа: ${response.estimatedResponseTime ?: "неизвестно"}\n\nИдентификатор: ${response.eventId}")
)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setColor(Color.GREEN)
.setGroup(SOS_GROUP_KEY)
.build()
notificationManager.notify(NOTIFICATION_ID_SOS_CREATED, notification)
}
private fun showGroupedSOSNotification() {
val totalEvents = activeSosEvents.size
val totalNotifiedUsers = activeSosEvents.values.sumOf { it.response.nearbyUsersNotified }
val intent = Intent(context, MainActivity::class.java).apply {
putExtra("navigate_to", "emergency")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
val pendingIntent = PendingIntent.getActivity(
context,
NOTIFICATION_ID_SOS_SUMMARY,
intent,
PendingIntent.FLAG_IMMUTABLE
)
// Создаем индивидуальные уведомления для группировки
activeSosEvents.values.forEachIndexed { index, sosEvent ->
val individualNotification = NotificationCompat.Builder(context, SOS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_check_circle_24)
.setContentTitle("SOS #${index + 1}")
.setContentText("Уведомлено ${sosEvent.response.nearbyUsersNotified} пользователей")
.setGroup(SOS_GROUP_KEY)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.build()
notificationManager.notify(sosEvent.eventId.hashCode(), individualNotification)
}
// Создаем summary уведомление
val inboxStyle = NotificationCompat.InboxStyle()
.setBigContentTitle("✅ Отправлено $totalEvents SOS сигналов")
.setSummaryText("Всего уведомлено $totalNotifiedUsers пользователей")
activeSosEvents.values.forEach { sosEvent ->
inboxStyle.addLine("SOS ${sosEvent.eventId.take(8)}: ${sosEvent.response.nearbyUsersNotified} уведомлений")
}
val summaryNotification = NotificationCompat.Builder(context, SOS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_check_circle_24)
.setContentTitle("✅ Отправлено $totalEvents SOS сигналов")
.setContentText("Всего уведомлено $totalNotifiedUsers пользователей поблизости")
.setStyle(inboxStyle)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setColor(Color.GREEN)
.setGroup(SOS_GROUP_KEY)
.setGroupSummary(true)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.build()
notificationManager.notify(NOTIFICATION_ID_SOS_SUMMARY, summaryNotification)
}
// Метод для показа группированных emergency уведомлений
fun showGroupedEmergencyAlert(alerts: List<EmergencyAlert>) {
if (alerts.isEmpty()) return
val intent = Intent(context, MainActivity::class.java).apply {
putExtra("navigate_to", "emergency")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context,
NOTIFICATION_ID_EMERGENCY_SUMMARY,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
if (alerts.size == 1) {
showCriticalEmergencyAlert(alerts.first())
return
}
// Создаем индивидуальные уведомления для группировки
alerts.forEach { alert ->
val individualNotification = NotificationCompat.Builder(context, EMERGENCY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_emergency_24)
.setContentTitle("🚨 ${alert.userInfo.firstName}")
.setContentText("${alert.distanceMeters}м - ${alert.message}")
.setGroup(EMERGENCY_GROUP_KEY)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.build()
notificationManager.notify(alert.eventId.hashCode(), individualNotification)
}
// Создаем summary уведомление
val inboxStyle = NotificationCompat.InboxStyle()
.setBigContentTitle("🚨 ${alerts.size} экстренных ситуаций")
.setSummaryText("Нажмите для просмотра всех событий")
alerts.take(5).forEach { alert ->
inboxStyle.addLine("${alert.userInfo.firstName} (${alert.distanceMeters}м): ${alert.message}")
}
if (alerts.size > 5) {
inboxStyle.addLine("И еще ${alerts.size - 5} событий...")
}
val summaryNotification = NotificationCompat.Builder(context, EMERGENCY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_emergency_24)
.setContentTitle("🚨 ${alerts.size} экстренных ситуаций")
.setContentText("Несколько человек нуждаются в помощи поблизости")
.setStyle(inboxStyle)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM))
.setVibrate(longArrayOf(0, 1000, 500, 1000, 500, 1000))
.setLights(Color.RED, 1000, 500)
.setGroup(EMERGENCY_GROUP_KEY)
.setGroupSummary(true)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.build()
notificationManager.notify(NOTIFICATION_ID_EMERGENCY_SUMMARY, summaryNotification)
}
// Метод для очистки группированных SOS уведомлений
suspend fun clearSOSNotifications() {
mutex.withLock {
activeSosEvents.clear()
notificationManager.cancel(NOTIFICATION_ID_SOS_CREATED)
notificationManager.cancel(NOTIFICATION_ID_SOS_SUMMARY)
}
}
private fun createHelpAction(eventId: String, userName: String): NotificationCompat.Action {
val intent = Intent(context, EmergencyActionReceiver::class.java).apply {
action = "HELP_ACTION"
putExtra("event_id", eventId)
putExtra("user_name", userName)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
eventId.hashCode(),
intent,
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Action.Builder(
R.drawable.ic_directions_24,
"Помочь",
pendingIntent
).build()
}
private fun createCallPoliceAction(eventId: String): NotificationCompat.Action {
val intent = Intent(context, EmergencyActionReceiver::class.java).apply {
action = "CALL_POLICE_ACTION"
putExtra("event_id", eventId)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
("police_" + eventId).hashCode(),
intent,
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Action.Builder(
R.drawable.ic_phone_24,
"Вызвать полицию",
pendingIntent
).build()
}
fun cancelNotification(notificationId: Int) {
notificationManager.cancel(notificationId)
}
fun cancelAllEmergencyNotifications() {
notificationManager.cancelAll()
}
// Получить информацию об активных SOS событиях
suspend fun getActiveSOSEventsInfo(): List<SOSEventInfo> {
return mutex.withLock {
activeSosEvents.values.toList()
}
}
// Получить количество активных SOS событий
suspend fun getActiveSOSEventsCount(): Int {
return mutex.withLock {
activeSosEvents.size
}
}
// Принудительная очистка всех индивидуальных SOS уведомлений
suspend fun clearAllSOSIndividualNotifications() {
mutex.withLock {
activeSosEvents.keys.forEach { eventId ->
notificationManager.cancel(eventId.hashCode())
}
}
}
// Обновить существующие групповые SOS уведомления
suspend fun refreshSOSNotifications() {
mutex.withLock {
// Сначала очищаем старые события
val currentTime = System.currentTimeMillis()
activeSosEvents.entries.removeAll {
currentTime - it.value.timestamp > SOS_NOTIFICATION_LIFETIME_MS
}
// Затем показываем актуальные уведомления
when (activeSosEvents.size) {
0 -> {
notificationManager.cancel(NOTIFICATION_ID_SOS_CREATED)
notificationManager.cancel(NOTIFICATION_ID_SOS_SUMMARY)
}
1 -> {
val singleEvent = activeSosEvents.values.first()
showSingleSOSNotification(singleEvent.response)
notificationManager.cancel(NOTIFICATION_ID_SOS_SUMMARY)
}
else -> showGroupedSOSNotification()
}
}
}
// Проверка разрешений на уведомления
fun areNotificationsEnabled(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = notificationManager.getNotificationChannel(EMERGENCY_CHANNEL_ID)
channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE
} else {
// Для версий ниже Oreo проверяем общее разрешение на уведомления
val pm = context.packageManager
val appOps = pm.getApplicationEnabledSetting(context.packageName)
appOps == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}
}
}

View File

@@ -0,0 +1,151 @@
package kr.smartsoltech.wellshe.emergency.utils
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.os.Looper
import android.util.Log
import androidx.core.app.ActivityCompat
import com.google.android.gms.location.*
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton
class LocationManager @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
private const val TAG = "LocationManager"
}
private val fusedLocationClient: FusedLocationProviderClient by lazy {
LocationServices.getFusedLocationProviderClient(context)
}
suspend fun getCurrentLocation(): Location? {
return getCurrentLocationWithTimeout(8000) // 8 seconds default
}
suspend fun getCurrentLocationWithTimeout(timeoutMs: Long): Location? {
if (!hasLocationPermission()) {
Log.e(TAG, "Location permission not granted")
throw SecurityException("Location permission not granted")
}
Log.d(TAG, "Requesting location with timeout ${timeoutMs}ms")
// Сначала попытаемся получить последнюю известную локацию
val lastKnownLocation = getLastKnownLocation()
Log.d(TAG, "Last known location: $lastKnownLocation")
// Попробуем получить свежую локацию
val freshLocation = try {
withTimeoutOrNull(timeoutMs) {
requestFreshLocation()
}
} catch (e: Exception) {
Log.e(TAG, "Error getting fresh location: ${e.message}", e)
null
}
return when {
freshLocation != null -> {
Log.d(TAG, "Got fresh location: lat=${freshLocation.latitude}, lng=${freshLocation.longitude}")
freshLocation
}
lastKnownLocation != null -> {
Log.d(TAG, "Using last known location: lat=${lastKnownLocation.latitude}, lng=${lastKnownLocation.longitude}")
lastKnownLocation
}
else -> {
Log.e(TAG, "No location available")
throw Exception("Не удалось получить геолокацию. Проверьте настройки GPS и попробуйте снова.")
}
}
}
private suspend fun getLastKnownLocation(): Location? {
return try {
suspendCancellableCoroutine { continuation ->
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
continuation.resume(location)
}
.addOnFailureListener { exception ->
Log.e(TAG, "Failed to get last known location", exception)
continuation.resume(null)
}
}
} catch (e: Exception) {
Log.e(TAG, "Exception getting last known location", e)
null
}
}
private suspend fun requestFreshLocation(): Location? {
return suspendCancellableCoroutine { continuation ->
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
.setMaxUpdates(1)
.build()
val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
val location = result.lastLocation
if (location != null && !continuation.isCompleted) {
Log.d(TAG, "Fresh location received: ${location.latitude}, ${location.longitude}")
continuation.resume(location)
fusedLocationClient.removeLocationUpdates(this)
}
}
override fun onLocationAvailability(availability: LocationAvailability) {
if (!availability.isLocationAvailable && !continuation.isCompleted) {
Log.d(TAG, "Location not available from provider")
continuation.resume(null)
fusedLocationClient.removeLocationUpdates(this)
}
}
}
try {
Log.d(TAG, "Starting location updates")
fusedLocationClient.requestLocationUpdates(
locationRequest,
locationCallback,
Looper.getMainLooper()
)
continuation.invokeOnCancellation {
Log.d(TAG, "Location request cancelled")
fusedLocationClient.removeLocationUpdates(locationCallback)
}
} catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location", e)
continuation.resumeWithException(e)
}
}
}
fun hasLocationPermission(): Boolean {
return ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val results = FloatArray(1)
Location.distanceBetween(lat1, lon1, lat2, lon2, results)
return results[0].toDouble()
}
}

View File

@@ -0,0 +1,67 @@
package kr.smartsoltech.wellshe.emergency.utils
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PermissionManager @Inject constructor(
@ApplicationContext private val context: Context
) {
fun hasLocationPermission(): Boolean {
return ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
fun hasNotificationPermission(): Boolean {
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
} else {
true // Notifications are allowed by default on older versions
}
}
fun hasPhonePermission(): Boolean {
return ActivityCompat.checkSelfPermission(
context,
Manifest.permission.CALL_PHONE
) == PackageManager.PERMISSION_GRANTED
}
fun getAllRequiredPermissions(): List<String> {
val permissions = mutableListOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
return permissions
}
fun getMissingPermissions(): List<String> {
return getAllRequiredPermissions().filter { permission ->
ActivityCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED
}
}
fun hasAllRequiredPermissions(): Boolean {
return getMissingPermissions().isEmpty()
}
}

View File

@@ -0,0 +1,46 @@
package kr.smartsoltech.wellshe.model
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
/**
* Модель данных для прогноза менструального цикла
*/
data class CycleForecast(
val nextPeriodStart: LocalDate,
val nextOvulation: LocalDate,
val fertileStart: LocalDate,
val fertileEnd: LocalDate,
val pmsStart: LocalDate,
val periodLengthDays: Int
) {
val periodEnd: LocalDate get() = nextPeriodStart.plusDays(periodLengthDays.toLong() - 1)
}
/**
* Расчет прогноза цикла на основе настроек
*/
fun computeForecast(settings: CycleSettings): CycleForecast {
val nextPeriod = settings.lastPeriodStart.plusDays(settings.baselineLength.toLong())
val ovulation = nextPeriod.minusDays(settings.lutealDays.toLong())
val fertileStart = ovulation.minusDays(5)
val fertileEnd = ovulation
val pmsStart = nextPeriod.minusDays(3)
return CycleForecast(
nextPeriodStart = nextPeriod,
nextOvulation = ovulation,
fertileStart = fertileStart,
fertileEnd = fertileEnd,
pmsStart = pmsStart,
periodLengthDays = settings.periodLength
)
}
/**
* Форматирует дату в формате "DD MMM"
*/
fun fmt(date: LocalDate): String {
return date.format(DateTimeFormatter.ofPattern("dd MMM"))
}

View File

@@ -0,0 +1,10 @@
package kr.smartsoltech.wellshe.model
import java.time.LocalDate
data class CycleSettings(
val baselineLength: Int = 28,
val periodLength: Int = 5,
val lutealDays: Int = 14,
val lastPeriodStart: LocalDate
)

View File

@@ -0,0 +1,24 @@
package kr.smartsoltech.wellshe.model
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.time.LocalDate
class JournalConverters {
@TypeConverter
fun fromLocalDate(date: LocalDate): String = date.toString()
@TypeConverter
fun toLocalDate(dateString: String): LocalDate = LocalDate.parse(dateString)
@TypeConverter
fun fromMediaList(media: List<JournalMedia>): String = Gson().toJson(media)
@TypeConverter
fun toMediaList(mediaString: String): List<JournalMedia> {
val type = object : TypeToken<List<JournalMedia>>() {}.type
return Gson().fromJson(mediaString, type)
}
}

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