fix
This commit is contained in:
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@@ -4,10 +4,10 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2025-10-16T05:53:10.409373833Z">
|
<DropdownSelection timestamp="2025-10-18T10:08:22.623831996Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=LGMG600S9b4da66b" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=R3CT80VPBQZ" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildConfigField("String", "API_BASE_URL", "\"${project.findProperty("API_BASE_URL")}\"")
|
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 {
|
buildTypes {
|
||||||
@@ -99,12 +115,15 @@ dependencies {
|
|||||||
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
|
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
|
||||||
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
|
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
|
||||||
|
|
||||||
// ViewBinding
|
// Emergency Module dependencies
|
||||||
implementation("androidx.databinding:databinding-runtime:8.2.2")
|
implementation("com.google.android.gms:play-services-location:21.0.1")
|
||||||
implementation("androidx.appcompat:appcompat:1.6.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(libs.junit)
|
||||||
testImplementation("io.mockk:mockk:1.13.8")
|
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,15 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<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
|
<application
|
||||||
android:name=".WellSheApplication"
|
android:name=".WellSheApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -50,6 +59,17 @@
|
|||||||
android:value="androidx.startup" />
|
android:value="androidx.startup" />
|
||||||
</provider>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -2,12 +2,31 @@ package kr.smartsoltech.wellshe
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
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
|
@HiltAndroidApp
|
||||||
class WellSheApplication : Application() {
|
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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
// TODO: Initialize app components when repositories are ready
|
// Hilt will inject dependencies after onCreate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,12 +40,16 @@ import androidx.room.TypeConverter
|
|||||||
ExerciseParam::class,
|
ExerciseParam::class,
|
||||||
ExerciseFormula::class,
|
ExerciseFormula::class,
|
||||||
ExerciseFormulaVar::class,
|
ExerciseFormulaVar::class,
|
||||||
CatalogVersion::class
|
CatalogVersion::class,
|
||||||
|
|
||||||
|
// Emergency Module entities
|
||||||
|
kr.smartsoltech.wellshe.emergency.data.entities.EmergencyEventEntity::class,
|
||||||
|
kr.smartsoltech.wellshe.emergency.data.entities.EmergencyResponseEntity::class
|
||||||
],
|
],
|
||||||
version = 13, // Увеличиваем версию базы данных после удаления полей mood и stressLevel
|
version = 14, // Увеличиваем версию для Emergency Module
|
||||||
exportSchema = true
|
exportSchema = true
|
||||||
)
|
)
|
||||||
@TypeConverters(LocalDateConverter::class, InstantConverter::class, StringListConverter::class)
|
@TypeConverters(LocalDateConverter::class, InstantConverter::class, StringListConverter::class, EmergencyTypeConverter::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun waterLogDao(): WaterLogDao
|
abstract fun waterLogDao(): WaterLogDao
|
||||||
abstract fun workoutDao(): WorkoutDao
|
abstract fun workoutDao(): WorkoutDao
|
||||||
@@ -76,6 +80,9 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun exerciseFormulaVarDao(): ExerciseFormulaVarDao
|
abstract fun exerciseFormulaVarDao(): ExerciseFormulaVarDao
|
||||||
abstract fun nutrientDao(): NutrientDao
|
abstract fun nutrientDao(): NutrientDao
|
||||||
abstract fun catalogVersionDao(): CatalogVersionDao
|
abstract fun catalogVersionDao(): CatalogVersionDao
|
||||||
|
|
||||||
|
// Emergency Module DAO
|
||||||
|
abstract fun emergencyDao(): kr.smartsoltech.wellshe.emergency.data.dao.EmergencyDao
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalDateConverter {
|
class LocalDateConverter {
|
||||||
@@ -84,3 +91,19 @@ class LocalDateConverter {
|
|||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun dateToTimestamp(date: LocalDate?): Long? = date?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,4 +67,11 @@ class AuthTokenRepository @Inject constructor(
|
|||||||
preferences.remove(USER_PASSWORD)
|
preferences.remove(USER_PASSWORD)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Удалить только auth token (не трогая сохранённые credentials)
|
||||||
|
suspend fun clearAuthToken() {
|
||||||
|
context.authDataStore.edit { preferences ->
|
||||||
|
preferences.remove(AUTH_TOKEN)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -23,7 +23,8 @@ object AppModule {
|
|||||||
context,
|
context,
|
||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"wellshe_database"
|
"wellshe_database"
|
||||||
).build()
|
).fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DAO Providers
|
// DAO Providers
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
)
|
||||||
@@ -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?
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
)
|
||||||
@@ -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>>
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ abstract class JournalDatabase : RoomDatabase() {
|
|||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
JournalDatabase::class.java,
|
JournalDatabase::class.java,
|
||||||
"journal_database"
|
"journal_database"
|
||||||
).build()
|
).fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
instance
|
instance
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import kr.smartsoltech.wellshe.domain.auth.RegisterUseCase
|
|||||||
import kr.smartsoltech.wellshe.model.auth.AuthTokenResponseWrapper
|
import kr.smartsoltech.wellshe.model.auth.AuthTokenResponseWrapper
|
||||||
import kr.smartsoltech.wellshe.model.auth.UserProfile
|
import kr.smartsoltech.wellshe.model.auth.UserProfile
|
||||||
import kr.smartsoltech.wellshe.util.Result
|
import kr.smartsoltech.wellshe.util.Result
|
||||||
|
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +28,8 @@ class AuthViewModel @Inject constructor(
|
|||||||
private val registerUseCase: RegisterUseCase,
|
private val registerUseCase: RegisterUseCase,
|
||||||
private val logoutUseCase: LogoutUseCase,
|
private val logoutUseCase: LogoutUseCase,
|
||||||
private val getUserProfileUseCase: GetUserProfileUseCase,
|
private val getUserProfileUseCase: GetUserProfileUseCase,
|
||||||
private val authTokenRepository: AuthTokenRepository
|
private val authTokenRepository: AuthTokenRepository,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _authState = MutableLiveData<AuthState>()
|
private val _authState = MutableLiveData<AuthState>()
|
||||||
@@ -77,36 +79,30 @@ class AuthViewModel @Inject constructor(
|
|||||||
|
|
||||||
when (val result = loginUseCase(identifier, password, isEmail)) {
|
when (val result = loginUseCase(identifier, password, isEmail)) {
|
||||||
is Result.Success -> {
|
is Result.Success -> {
|
||||||
// Получаем данные авторизации из ответа
|
// Получаем данные авторизации из результата use case — токены уже сохранены в TokenManager
|
||||||
val authData = result.data
|
Log.d("AuthViewModel", "Login Success: tokens saved to TokenManager")
|
||||||
Log.d("AuthViewModel", "Login Success: received data of type ${authData?.javaClass?.simpleName}")
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Используем более безопасный подход без рефлексии
|
|
||||||
if (authData != null) {
|
|
||||||
val dataJson = authData.toString()
|
|
||||||
Log.d("AuthViewModel", "Auth data toString: $dataJson")
|
|
||||||
|
|
||||||
// Устанавливаем состояние авторизации как успешное
|
// Устанавливаем состояние авторизации как успешное
|
||||||
_authState.value = AuthState.Authenticated
|
_authState.value = AuthState.Authenticated
|
||||||
|
|
||||||
// Сохраняем учетные данные для автологина
|
// Сохраняем учетные данные для автологина (email/password)
|
||||||
authTokenRepository.saveAuthCredentials(identifier, password)
|
authTokenRepository.saveAuthCredentials(identifier, password)
|
||||||
|
|
||||||
// Временно используем фиксированный токен (можно заменить на реальный, когда будет понятна структура данных)
|
// Сохраняем реальный access token в DataStore для долговременного хранения
|
||||||
val tempToken = "temp_token_for_$identifier"
|
try {
|
||||||
authTokenRepository.saveAuthToken(tempToken)
|
val realToken = tokenManager.getAccessToken()
|
||||||
|
if (!realToken.isNullOrEmpty()) {
|
||||||
|
authTokenRepository.saveAuthToken(realToken)
|
||||||
|
Log.d("AuthViewModel", "Saved real access token to DataStore (masked=${realToken.take(6)}...)")
|
||||||
|
} else {
|
||||||
|
Log.w("AuthViewModel", "TokenManager did not contain access token after login")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("AuthViewModel", "Failed to persist access token: ${e.message}", e)
|
||||||
|
}
|
||||||
|
|
||||||
// Загружаем профиль после успешной авторизации
|
// Загружаем профиль после успешной авторизации
|
||||||
fetchUserProfile()
|
fetchUserProfile()
|
||||||
} else {
|
|
||||||
Log.e("AuthViewModel", "Auth data is null")
|
|
||||||
_authState.value = AuthState.AuthError("Получены пустые данные авторизации")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("AuthViewModel", "Error processing login response: ${e.message}", e)
|
|
||||||
_authState.value = AuthState.AuthError("Ошибка обработки ответа: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
is Result.Error -> {
|
is Result.Error -> {
|
||||||
Log.e("AuthViewModel", "Login Error: ${result.exception.message}")
|
Log.e("AuthViewModel", "Login Error: ${result.exception.message}")
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package kr.smartsoltech.wellshe.ui.emergency
|
package kr.smartsoltech.wellshe.ui.emergency
|
||||||
|
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
@@ -23,6 +26,9 @@ import kr.smartsoltech.wellshe.databinding.FragmentEmergencyBinding
|
|||||||
import kr.smartsoltech.wellshe.di.ViewModelFactory
|
import kr.smartsoltech.wellshe.di.ViewModelFactory
|
||||||
import kr.smartsoltech.wellshe.ui.emergency.EmergencyViewModel.EmergencyState
|
import kr.smartsoltech.wellshe.ui.emergency.EmergencyViewModel.EmergencyState
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.text.get
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Фрагмент для экрана экстренных оповещений
|
* Фрагмент для экрана экстренных оповещений
|
||||||
@@ -32,6 +38,8 @@ class EmergencyFragment : Fragment(), LocationListener {
|
|||||||
private var _binding: FragmentEmergencyBinding? = null
|
private var _binding: FragmentEmergencyBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var locationPermissionLauncher: androidx.activity.result.ActivityResultLauncher<Array<String>>
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var viewModelFactory: ViewModelFactory
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
|
||||||
@@ -66,6 +74,19 @@ class EmergencyFragment : Fragment(), LocationListener {
|
|||||||
// Инициализируем менеджер местоположения
|
// Инициализируем менеджер местоположения
|
||||||
locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
|
|
||||||
|
locationPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { permissions ->
|
||||||
|
val granted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
|
||||||
|
permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
|
||||||
|
if (granted) {
|
||||||
|
startLocationUpdates(false)
|
||||||
|
enableEmergencyUI(true)
|
||||||
|
} else {
|
||||||
|
enableEmergencyUI(false)
|
||||||
|
showLocationPermissionDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
setupEmergencyButton()
|
setupEmergencyButton()
|
||||||
observeViewModel()
|
observeViewModel()
|
||||||
checkLocationPermissions()
|
checkLocationPermissions()
|
||||||
@@ -220,25 +241,45 @@ class EmergencyFragment : Fragment(), LocationListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun checkLocationPermissions() {
|
private fun checkLocationPermissions() {
|
||||||
if (ActivityCompat.checkSelfPermission(
|
val fineGranted = ActivityCompat.checkSelfPermission(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
Manifest.permission.ACCESS_FINE_LOCATION
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
) != PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
val coarseGranted = ActivityCompat.checkSelfPermission(
|
||||||
requestLocationPermissions()
|
requireContext(),
|
||||||
} else {
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
startLocationUpdates(false)
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
if (!fineGranted && !coarseGranted) {
|
||||||
}
|
locationPermissionLauncher.launch(
|
||||||
|
|
||||||
private fun requestLocationPermissions() {
|
|
||||||
requestPermissions(
|
|
||||||
arrayOf(
|
arrayOf(
|
||||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
),
|
|
||||||
locationPermissionRequestCode
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startLocationUpdates(false)
|
||||||
|
enableEmergencyUI(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableEmergencyUI(enabled: Boolean) {
|
||||||
|
binding.buttonSos.isEnabled = enabled
|
||||||
|
binding.buttonCancelAlert.isEnabled = enabled
|
||||||
|
binding.mapView.isEnabled = enabled
|
||||||
|
// Если есть другие кнопки/типы сигналов, добавьте их сюда
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLocationPermissionDialog() {
|
||||||
|
AlertDialog.Builder(requireContext())
|
||||||
|
.setTitle("Доступ к геопозиции")
|
||||||
|
.setMessage("Для работы экстренных функций нужен доступ к геопозиции. Откройте настройки и разрешите доступ.")
|
||||||
|
.setPositiveButton("Открыть настройки") { _, _ ->
|
||||||
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||||
|
intent.data = Uri.fromParts("package", requireContext().packageName, null)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
.setNegativeButton("Отмена", null)
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startLocationUpdates(isEmergency: Boolean) {
|
private fun startLocationUpdates(isEmergency: Boolean) {
|
||||||
@@ -305,24 +346,6 @@ class EmergencyFragment : Fragment(), LocationListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRequestPermissionsResult(
|
|
||||||
requestCode: Int,
|
|
||||||
permissions: Array<out String>,
|
|
||||||
grantResults: IntArray
|
|
||||||
) {
|
|
||||||
if (requestCode == locationPermissionRequestCode) {
|
|
||||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
startLocationUpdates(false)
|
|
||||||
} else {
|
|
||||||
Toast.makeText(
|
|
||||||
requireContext(),
|
|
||||||
"Для работы экстренных оповещений необходим доступ к местоположению",
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
// Если нет активного оповещения, останавливаем обновления местоположения
|
// Если нет активного оповещения, останавливаем обновления местоположения
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import androidx.navigation.compose.composable
|
|||||||
import kr.smartsoltech.wellshe.ui.analytics.AnalyticsScreen
|
import kr.smartsoltech.wellshe.ui.analytics.AnalyticsScreen
|
||||||
import kr.smartsoltech.wellshe.ui.body.BodyScreen
|
import kr.smartsoltech.wellshe.ui.body.BodyScreen
|
||||||
import kr.smartsoltech.wellshe.ui.cycle.CycleScreen
|
import kr.smartsoltech.wellshe.ui.cycle.CycleScreen
|
||||||
import kr.smartsoltech.wellshe.ui.emergency.EmergencyScreen
|
import kr.smartsoltech.wellshe.emergency.presentation.screens.EmergencyScreen
|
||||||
import kr.smartsoltech.wellshe.ui.profile.ProfileScreen
|
import kr.smartsoltech.wellshe.ui.profile.ProfileScreen
|
||||||
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
|
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
|
||||||
import kr.smartsoltech.wellshe.ui.auth.compose.LoginScreen
|
import kr.smartsoltech.wellshe.ui.auth.compose.LoginScreen
|
||||||
@@ -83,8 +83,11 @@ fun AppNavGraph(
|
|||||||
|
|
||||||
composable(BottomNavItem.Emergency.route) {
|
composable(BottomNavItem.Emergency.route) {
|
||||||
EmergencyScreen(
|
EmergencyScreen(
|
||||||
onNavigateBack = {
|
onNavigateToMap = {
|
||||||
navController.popBackStack()
|
// TODO: Добавить навигацию к карте
|
||||||
|
},
|
||||||
|
onNavigateToHistory = {
|
||||||
|
// TODO: Добавить навигацию к истории
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,114 +2,90 @@ package kr.smartsoltech.wellshe.ui.navigation
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import kr.smartsoltech.wellshe.ui.theme.*
|
import kr.smartsoltech.wellshe.ui.theme.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BottomNavigation(
|
fun BottomNavigation(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
|
||||||
NavigationBar(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(64.dp)
|
|
||||||
.imePadding(), // Добавляем отступ для клавиатуры
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
tonalElevation = 8.dp,
|
|
||||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) // Учитываем только горизонтальные системные отступы
|
|
||||||
) {
|
) {
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentDestination = navBackStackEntry?.destination
|
val currentDestination = navBackStackEntry?.destination
|
||||||
|
|
||||||
val items = BottomNavItem.items
|
val items = BottomNavItem.items
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(72.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
) {
|
) {
|
||||||
items.forEach { item ->
|
items.forEach { item ->
|
||||||
val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true
|
val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true
|
||||||
|
val isEmergency = item == BottomNavItem.Emergency
|
||||||
// Определяем цвет фона для выбранного элемента
|
val size = if (isEmergency) 72.dp else 56.dp
|
||||||
val backgroundColor = when (item) {
|
val iconSize = if (isEmergency) 40.dp else 28.dp
|
||||||
BottomNavItem.Cycle -> CycleTabColor
|
val offsetY = if (isEmergency) (-20).dp else if (selected) (-8).dp else 0.dp
|
||||||
BottomNavItem.Body -> BodyTabColor
|
val bgColor = when {
|
||||||
BottomNavItem.Emergency -> ErrorRed
|
isEmergency -> Color(0xFFFF1744)
|
||||||
BottomNavItem.Analytics -> AnalyticsTabColor
|
selected -> Color.White
|
||||||
BottomNavItem.Profile -> ProfileTabColor
|
else -> Color(0xFFF5F5F5)
|
||||||
}
|
}
|
||||||
|
val borderColor = when {
|
||||||
// Создаем кастомный элемент навигации с привязкой к верхнему краю
|
isEmergency -> Color(0xFFB71C1C)
|
||||||
Column(
|
selected -> Color(0xFF1976D2)
|
||||||
|
else -> Color.Transparent
|
||||||
|
}
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.offset(y = offsetY)
|
||||||
|
.size(size)
|
||||||
|
.shadow(if (isEmergency || selected) 8.dp else 0.dp, CircleShape)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(bgColor)
|
||||||
|
.border(
|
||||||
|
width = if (isEmergency || selected) 4.dp else 0.dp,
|
||||||
|
color = borderColor,
|
||||||
|
shape = CircleShape
|
||||||
|
)
|
||||||
.clickable {
|
.clickable {
|
||||||
navController.navigate(item.route) {
|
navController.navigate(item.route) {
|
||||||
// Pop up to the start destination of the graph to
|
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||||
// avoid building up a large stack of destinations
|
|
||||||
popUpTo(navController.graph.findStartDestination().id) {
|
|
||||||
saveState = true
|
|
||||||
}
|
|
||||||
// Avoid multiple copies of the same destination
|
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
// Restore state when reselecting
|
|
||||||
restoreState = true
|
restoreState = true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
.padding(4.dp),
|
contentAlignment = Alignment.Center
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Top // Привязываем контент к верхнему краю
|
|
||||||
) {
|
) {
|
||||||
// Иконка - размещаем вверху
|
|
||||||
if (selected) {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = item.icon,
|
imageVector = item.icon,
|
||||||
contentDescription = item.title,
|
contentDescription = item.title,
|
||||||
modifier = Modifier
|
modifier = Modifier.size(iconSize),
|
||||||
.padding(top = 4.dp)
|
tint = if (isEmergency) Color.White else if (selected) Color(0xFF1976D2) else Color(0xFF757575)
|
||||||
.size(32.dp) // Унифицируем размер иконок
|
|
||||||
.clip(RoundedCornerShape(6.dp))
|
|
||||||
.background(backgroundColor)
|
|
||||||
.padding(4.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
imageVector = item.icon,
|
|
||||||
contentDescription = item.title,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = 4.dp)
|
|
||||||
.size(32.dp), // Размер не изменился
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Текстовая метка
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
text = item.title,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = if (selected)
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 1
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
app/src/main/res/drawable/bg_nav_circle_default.xml
Normal file
4
app/src/main/res/drawable/bg_nav_circle_default.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||||
|
<solid android:color="#F5F5F5"/>
|
||||||
|
</shape>
|
||||||
|
|
||||||
6
app/src/main/res/drawable/bg_nav_circle_emergency.xml
Normal file
6
app/src/main/res/drawable/bg_nav_circle_emergency.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||||
|
<solid android:color="#FF1744"/>
|
||||||
|
<stroke android:width="4dp" android:color="#B71C1C"/>
|
||||||
|
<size android:width="72dp" android:height="72dp"/>
|
||||||
|
</shape>
|
||||||
|
|
||||||
7
app/src/main/res/drawable/bg_nav_circle_selected.xml
Normal file
7
app/src/main/res/drawable/bg_nav_circle_selected.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||||
|
<solid android:color="#FFFFFF"/>
|
||||||
|
<stroke android:width="3dp" android:color="#E91E63"/>
|
||||||
|
<size android:width="56dp" android:height="56dp"/>
|
||||||
|
<padding android:bottom="8dp"/>
|
||||||
|
</shape>
|
||||||
|
|
||||||
11
app/src/main/res/drawable/ic_check_circle_24.xml
Normal file
11
app/src/main/res/drawable/ic_check_circle_24.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurface">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M9,12l2,2 4,-4m6,2a9,9 0,1 1,-18 0,9 9,0 0,1 18,0z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
11
app/src/main/res/drawable/ic_directions_24.xml
Normal file
11
app/src/main/res/drawable/ic_directions_24.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurface">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M13.5,13.48l-4,-4L8.34,10.66 13.5,15.82 19.15,10.16 18.01,9.02z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
10
app/src/main/res/drawable/ic_emergency_24.xml
Normal file
10
app/src/main/res/drawable/ic_emergency_24.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="48dp"
|
||||||
|
android:height="48dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FF1744"> <!-- Ярко-красный цвет -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF1744"
|
||||||
|
android:pathData="M1,21h4L5,9L1,9v12zM23,10c0,-1.1 -0.9,-2 -2,-2h-6.31l0.95,-4.57 0.03,-0.32c0,-0.41 -0.17,-0.79 -0.44,-1.06L14.17,1 7.59,7.59C7.22,7.95 7,8.45 7,9v10c0,1.1 0.9,2 2,2h9c0.83,0 1.54,-0.5 1.84,-1.22l3.02,-7.05c0.09,-0.23 0.14,-0.47 0.14,-0.73v-1.91l-0.01,-0.01L23,10z"/>
|
||||||
|
</vector>
|
||||||
11
app/src/main/res/drawable/ic_phone_24.xml
Normal file
11
app/src/main/res/drawable/ic_phone_24.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurface">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
5
app/src/main/res/drawable/sos.xml
Normal file
5
app/src/main/res/drawable/sos.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="800dp" android:viewportHeight="512" android:viewportWidth="512" android:width="800dp">
|
||||||
|
|
||||||
|
<path android:fillColor="#000000" android:pathData="M68.79,19.5l57.51,69h23.4l-57.49,-69zM254.1,19.5l59.4,178.3c5.5,-2.1 11.2,-4 17,-5.7L273,19.5zM161.9,103.2l-2.5,25.1 90.7,108.8c4.4,-4.1 9,-7.9 13.8,-11.5zM83.45,106.5l14.19,142L129.4,248.5l14.2,-142zM385.5,202.5c-3.2,0 -6.4,0.1 -9.6,0.2L361,253.8l46.9,-21.5 -3,43.1 40.5,12.4 -47.2,32.2 27,36.8 -51.8,11.6 8.3,53.6 -74.3,-44.2 8.9,-70.8 -28.4,-44.7 58.9,-55.7c-75.8,16.2 -134,79.3 -143.1,157.6l41.5,-61.4 38.7,104.5 -29.9,12.5 80.4,40.5 -68.2,16.5 -52,-26.6c5.7,15.2 13.4,29.4 22.7,42.3h150.2l78.5,-65.2 -45.6,-36 45.7,-24.8 26.8,14.2L492.5,237c-30.1,-21.7 -67,-34.5 -107,-34.5zM113.5,266.5c-12.8,0 -23,10.2 -23,23s10.2,23 23,23 23,-10.2 23,-23 -10.2,-23 -23,-23zM19.5,277.6v18.7l60.11,16.2c-4.05,-6 -6.58,-13 -7.03,-20.6zM287.9,284.9l4.2,42.1 -14.1,25.5 -15.3,-51.2zM463.5,303l-15.4,43.4 -19.7,-24.9zM147.6,312.1c-4,6.1 -9.7,11.1 -16.3,14.3l57.9,15.6c1.4,-5.9 2.9,-11.7 4.7,-17.4zM318.7,399.2l29.7,23.5 -18.3,25.4zM187.3,419.2c-2.3,0.5 -4.5,1 -6.9,1.5 -69.9,15.5 -126.2,28.2 -160.9,35.9v18.5c32.9,-7.4 91.7,-20.5 164.8,-36.8 2.3,-0.5 4.5,-1 6.8,-1.5 -1.5,-5.8 -2.8,-11.7 -3.8,-17.6zM362.6,428.6l42.9,15.9 -32.3,12.2z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -11,11 +11,19 @@
|
|||||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toTopOf="@+id/custom_bottom_nav"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:defaultNavHost="true"
|
app:defaultNavHost="true"
|
||||||
app:navGraph="@navigation/nav_graph" />
|
app:navGraph="@navigation/nav_graph" />
|
||||||
|
|
||||||
|
<include
|
||||||
|
android:id="@+id/custom_bottom_nav"
|
||||||
|
layout="@layout/custom_bottom_nav"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="72dp"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|||||||
79
app/src/main/res/layout/custom_bottom_nav.xml
Normal file
79
app/src/main/res/layout/custom_bottom_nav.xml
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="72dp"
|
||||||
|
android:background="@color/white">
|
||||||
|
|
||||||
|
<!-- Первая вкладка -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/nav_item_1"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="0.15">
|
||||||
|
<View
|
||||||
|
android:id="@+id/circle_bg_1"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:background="@drawable/bg_nav_circle_default" />
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/icon_1"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:src="@drawable/ic_phone_24" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<!-- Кнопка "Экстренное" (выступает и всегда красная) -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/nav_item_emergency"
|
||||||
|
android:layout_width="72dp"
|
||||||
|
android:layout_height="72dp"
|
||||||
|
android:layout_marginBottom="-16dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="0.5">
|
||||||
|
<View
|
||||||
|
android:id="@+id/circle_bg_emergency"
|
||||||
|
android:layout_width="72dp"
|
||||||
|
android:layout_height="72dp"
|
||||||
|
android:background="@drawable/bg_nav_circle_emergency" />
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/icon_emergency"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:src="@drawable/ic_emergency_24" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<!-- Вторая вкладка -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/nav_item_2"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="0.85">
|
||||||
|
<View
|
||||||
|
android:id="@+id/circle_bg_2"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:background="@drawable/bg_nav_circle_default" />
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/icon_2"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:src="@drawable/ic_directions_24" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
@@ -121,4 +121,14 @@
|
|||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<com.google.android.gms.maps.MapView
|
||||||
|
android:id="@+id/mapView"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="200dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvTitle"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<network-security-config>
|
<network-security-config>
|
||||||
<!-- Разрешаем незащищенное HTTP-соединение -->
|
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
<domain includeSubdomains="true">192.168.0.112</domain>
|
<domain>localhost</domain>
|
||||||
<domain includeSubdomains="true">192.168.219.108</domain>
|
<domain>127.0.0.1</domain>
|
||||||
|
<domain>10.0.2.2</domain>
|
||||||
|
<domain>192.168.219.108</domain>
|
||||||
|
<domain>192.168.0.112</domain>
|
||||||
</domain-config>
|
</domain-config>
|
||||||
|
|
||||||
<!-- Настройки по умолчанию - запрещаем незащищенный HTTP-трафик для других адресов -->
|
|
||||||
<base-config cleartextTrafficPermitted="false">
|
|
||||||
<trust-anchors>
|
|
||||||
<certificates src="system" />
|
|
||||||
</trust-anchors>
|
|
||||||
</base-config>
|
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.debug
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
// Release (stub) implementation — debug build will override this with a real implementation in src/debug
|
||||||
|
object AuthTester {
|
||||||
|
suspend fun runFullTest(context: Context): Boolean {
|
||||||
|
// No-op in release
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -21,5 +21,9 @@ kotlin.code.style=official
|
|||||||
# resources declared in the library itself and none from the library's dependencies,
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
BASE_URL=192.168.219.108
|
||||||
API_BASE_URL=http://192.168.219.108:8000/api/v1/
|
API_BASE_URL=http://192.168.219.108:8000/api/v1/
|
||||||
|
|
||||||
|
# Service ports (used by BuildConfig defaults)
|
||||||
|
API_PORT=8002
|
||||||
|
WS_PORT=8002
|
||||||
|
|||||||
Reference in New Issue
Block a user