This commit is contained in:
2025-10-19 20:09:01 +09:00
parent 47afd9848b
commit 247cddd38a
54 changed files with 5792 additions and 159 deletions

View File

@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<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">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=LGMG600S9b4da66b" />
<DeviceId pluginId="PhysicalDevice" identifier="serial=R3CT80VPBQZ" />
</handle>
</Target>
</DropdownSelection>

View File

@@ -29,6 +29,22 @@ android {
}
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 {
@@ -99,12 +115,15 @@ dependencies {
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
// ViewBinding
implementation("androidx.databinding:databinding-runtime:8.2.2")
implementation("androidx.appcompat:appcompat:1.6.1")
// Emergency Module dependencies
implementation("com.google.android.gms:play-services-location:21.0.1")
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("com.google.android.gms:play-services-maps:18.2.0")
// Testing dependencies
testImplementation(libs.junit)
testImplementation("io.mockk:mockk:1.13.8")
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,12 +40,16 @@ import androidx.room.TypeConverter
ExerciseParam::class,
ExerciseFormula::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
)
@TypeConverters(LocalDateConverter::class, InstantConverter::class, StringListConverter::class)
@TypeConverters(LocalDateConverter::class, InstantConverter::class, StringListConverter::class, EmergencyTypeConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun waterLogDao(): WaterLogDao
abstract fun workoutDao(): WorkoutDao
@@ -76,6 +80,9 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun exerciseFormulaVarDao(): ExerciseFormulaVarDao
abstract fun nutrientDao(): NutrientDao
abstract fun catalogVersionDao(): CatalogVersionDao
// Emergency Module DAO
abstract fun emergencyDao(): kr.smartsoltech.wellshe.emergency.data.dao.EmergencyDao
}
class LocalDateConverter {
@@ -84,3 +91,19 @@ class LocalDateConverter {
@TypeConverter
fun dateToTimestamp(date: LocalDate?): Long? = date?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
}
class EmergencyTypeConverter {
@TypeConverter
fun fromEmergencyType(type: kr.smartsoltech.wellshe.emergency.data.models.EmergencyType): String = type.name
@TypeConverter
fun toEmergencyType(name: String): kr.smartsoltech.wellshe.emergency.data.models.EmergencyType =
kr.smartsoltech.wellshe.emergency.data.models.EmergencyType.valueOf(name)
@TypeConverter
fun fromEmergencyStatus(status: kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus): String = status.name
@TypeConverter
fun toEmergencyStatus(name: String): kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus =
kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus.valueOf(name)
}

View File

@@ -67,4 +67,11 @@ class AuthTokenRepository @Inject constructor(
preferences.remove(USER_PASSWORD)
}
}
// Удалить только auth token (не трогая сохранённые credentials)
suspend fun clearAuthToken() {
context.authDataStore.edit { preferences ->
preferences.remove(AUTH_TOKEN)
}
}
}

View File

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

View File

@@ -23,7 +23,8 @@ object AppModule {
context,
AppDatabase::class.java,
"wellshe_database"
).build()
).fallbackToDestructiveMigration()
.build()
}
// DAO Providers

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,8 @@ abstract class JournalDatabase : RoomDatabase() {
context.applicationContext,
JournalDatabase::class.java,
"journal_database"
).build()
).fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}

View File

@@ -16,6 +16,7 @@ import kr.smartsoltech.wellshe.domain.auth.RegisterUseCase
import kr.smartsoltech.wellshe.model.auth.AuthTokenResponseWrapper
import kr.smartsoltech.wellshe.model.auth.UserProfile
import kr.smartsoltech.wellshe.util.Result
import kr.smartsoltech.wellshe.data.storage.TokenManager
import javax.inject.Inject
/**
@@ -27,7 +28,8 @@ class AuthViewModel @Inject constructor(
private val registerUseCase: RegisterUseCase,
private val logoutUseCase: LogoutUseCase,
private val getUserProfileUseCase: GetUserProfileUseCase,
private val authTokenRepository: AuthTokenRepository
private val authTokenRepository: AuthTokenRepository,
private val tokenManager: TokenManager
) : ViewModel() {
private val _authState = MutableLiveData<AuthState>()
@@ -77,36 +79,30 @@ class AuthViewModel @Inject constructor(
when (val result = loginUseCase(identifier, password, isEmail)) {
is Result.Success -> {
// Получаем данные авторизации из ответа
val authData = result.data
Log.d("AuthViewModel", "Login Success: received data of type ${authData?.javaClass?.simpleName}")
// Получаем данные авторизации из результата use case — токены уже сохранены в TokenManager
Log.d("AuthViewModel", "Login Success: tokens saved to TokenManager")
// Устанавливаем состояние авторизации как успешное
_authState.value = AuthState.Authenticated
// Сохраняем учетные данные для автологина (email/password)
authTokenRepository.saveAuthCredentials(identifier, password)
// Сохраняем реальный access token в DataStore для долговременного хранения
try {
// Используем более безопасный подход без рефлексии
if (authData != null) {
val dataJson = authData.toString()
Log.d("AuthViewModel", "Auth data toString: $dataJson")
// Устанавливаем состояние авторизации как успешное
_authState.value = AuthState.Authenticated
// Сохраняем учетные данные для автологина
authTokenRepository.saveAuthCredentials(identifier, password)
// Временно используем фиксированный токен (можно заменить на реальный, когда будет понятна структура данных)
val tempToken = "temp_token_for_$identifier"
authTokenRepository.saveAuthToken(tempToken)
// Загружаем профиль после успешной авторизации
fetchUserProfile()
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.e("AuthViewModel", "Auth data is null")
_authState.value = AuthState.AuthError("Получены пустые данные авторизации")
Log.w("AuthViewModel", "TokenManager did not contain access token after login")
}
} catch (e: Exception) {
Log.e("AuthViewModel", "Error processing login response: ${e.message}", e)
_authState.value = AuthState.AuthError("Ошибка обработки ответа: ${e.message}")
Log.e("AuthViewModel", "Failed to persist access token: ${e.message}", e)
}
// Загружаем профиль после успешной авторизации
fetchUserProfile()
}
is Result.Error -> {
Log.e("AuthViewModel", "Login Error: ${result.exception.message}")

View File

@@ -1,5 +1,8 @@
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.content.Context
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.ui.emergency.EmergencyViewModel.EmergencyState
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 val binding get() = _binding!!
private lateinit var locationPermissionLauncher: androidx.activity.result.ActivityResultLauncher<Array<String>>
@Inject
lateinit var viewModelFactory: ViewModelFactory
@@ -66,6 +74,19 @@ class EmergencyFragment : Fragment(), LocationListener {
// Инициализируем менеджер местоположения
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()
observeViewModel()
checkLocationPermissions()
@@ -220,25 +241,45 @@ class EmergencyFragment : Fragment(), LocationListener {
}
private fun checkLocationPermissions() {
if (ActivityCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
requestLocationPermissions()
val fineGranted = ActivityCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val coarseGranted = ActivityCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
} else {
startLocationUpdates(false)
enableEmergencyUI(true)
}
}
private fun requestLocationPermissions() {
requestPermissions(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
),
locationPermissionRequestCode
)
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) {
@@ -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() {
super.onPause()
// Если нет активного оповещения, останавливаем обновления местоположения

View File

@@ -8,7 +8,7 @@ import androidx.navigation.compose.composable
import kr.smartsoltech.wellshe.ui.analytics.AnalyticsScreen
import kr.smartsoltech.wellshe.ui.body.BodyScreen
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.auth.AuthViewModel
import kr.smartsoltech.wellshe.ui.auth.compose.LoginScreen
@@ -83,8 +83,11 @@ fun AppNavGraph(
composable(BottomNavItem.Emergency.route) {
EmergencyScreen(
onNavigateBack = {
navController.popBackStack()
onNavigateToMap = {
// TODO: Добавить навигацию к карте
},
onNavigateToHistory = {
// TODO: Добавить навигацию к истории
}
)
}

View File

@@ -2,21 +2,23 @@ package kr.smartsoltech.wellshe.ui.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.material3.Icon
import kr.smartsoltech.wellshe.ui.theme.*
@Composable
@@ -24,92 +26,66 @@ fun BottomNavigation(
navController: NavController,
modifier: Modifier = Modifier
) {
NavigationBar(
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val items = BottomNavItem.items
Box(
modifier = modifier
.fillMaxWidth()
.height(64.dp)
.imePadding(), // Добавляем отступ для клавиатуры
containerColor = MaterialTheme.colorScheme.background,
tonalElevation = 8.dp,
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) // Учитываем только горизонтальные системные отступы
.height(72.dp)
.background(MaterialTheme.colorScheme.background)
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val items = BottomNavItem.items
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.Bottom
) {
items.forEach { item ->
val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true
// Определяем цвет фона для выбранного элемента
val backgroundColor = when (item) {
BottomNavItem.Cycle -> CycleTabColor
BottomNavItem.Body -> BodyTabColor
BottomNavItem.Emergency -> ErrorRed
BottomNavItem.Analytics -> AnalyticsTabColor
BottomNavItem.Profile -> ProfileTabColor
val isEmergency = item == BottomNavItem.Emergency
val size = if (isEmergency) 72.dp else 56.dp
val iconSize = if (isEmergency) 40.dp else 28.dp
val offsetY = if (isEmergency) (-20).dp else if (selected) (-8).dp else 0.dp
val bgColor = when {
isEmergency -> Color(0xFFFF1744)
selected -> Color.White
else -> Color(0xFFF5F5F5)
}
// Создаем кастомный элемент навигации с привязкой к верхнему краю
Column(
val borderColor = when {
isEmergency -> Color(0xFFB71C1C)
selected -> Color(0xFF1976D2)
else -> Color.Transparent
}
Box(
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 {
navController.navigate(item.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
// Restore state when reselecting
restoreState = true
}
}
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top // Привязываем контент к верхнему краю
},
contentAlignment = Alignment.Center
) {
// Иконка - размещаем вверху
if (selected) {
Icon(
imageVector = item.icon,
contentDescription = item.title,
modifier = Modifier
.padding(top = 4.dp)
.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
Icon(
imageVector = item.icon,
contentDescription = item.title,
modifier = Modifier.size(iconSize),
tint = if (isEmergency) Color.White else if (selected) Color(0xFF1976D2) else Color(0xFF757575)
)
}
}

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#F5F5F5"/>
</shape>

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

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

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

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

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

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

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

View File

@@ -11,11 +11,19 @@
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="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_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:defaultNavHost="true"
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>

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

View File

@@ -121,4 +121,14 @@
app:layout_constraintStart_toStartOf="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>

View File

@@ -1,16 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Разрешаем незащищенное HTTP-соединение -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.0.112</domain>
<domain includeSubdomains="true">192.168.219.108</domain>
<domain>localhost</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>
<!-- Настройки по умолчанию - запрещаем незащищенный HTTP-трафик для других адресов -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

View File

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

View File

@@ -21,5 +21,9 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
BASE_URL=192.168.219.108
API_BASE_URL=http://192.168.219.108:8000/api/v1/
# Service ports (used by BuildConfig defaults)
API_PORT=8002
WS_PORT=8002