Compare commits
14 Commits
fcd195403e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8276c57010 | |||
| 8bc115acf3 | |||
| 8706be3084 | |||
| 247cddd38a | |||
| 47afd9848b | |||
| 18753b214d | |||
| f429d54e1b | |||
| f98a77e5a5 | |||
| 3fea080626 | |||
| 0e792d28e0 | |||
| 5128762d91 | |||
| 6395c0fc36 | |||
| f57cd956bd | |||
| c00924be85 |
123
.idea/codeStyles/Project.xml
generated
Normal file
123
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<JetCodeStyleSettings>
|
||||||
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
|
</JetCodeStyleSettings>
|
||||||
|
<codeStyleSettings language="XML">
|
||||||
|
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||||
|
</indentOptions>
|
||||||
|
<arrangement>
|
||||||
|
<rules>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>xmlns:android</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>xmlns:.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*:id</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*:name</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>name</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>style</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
</rules>
|
||||||
|
</arrangement>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="kotlin">
|
||||||
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@@ -4,6 +4,14 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DropdownSelection timestamp="2025-11-05T20:35:37.724952878Z">
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=R3CT80VPBQZ" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</DropdownSelection>
|
||||||
|
<DialogSelection />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
|
|||||||
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
@@ -1,4 +1,3 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
|||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
id("kotlin-kapt")
|
id("kotlin-kapt")
|
||||||
}
|
}
|
||||||
@@ -17,6 +18,34 @@ android {
|
|||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
// Добавляем путь для экспорта схемы Room
|
||||||
|
javaCompileOptions {
|
||||||
|
annotationProcessorOptions {
|
||||||
|
arguments += mapOf(
|
||||||
|
"room.schemaLocation" to "$projectDir/schemas",
|
||||||
|
"room.incremental" to "true"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConfigField("String", "API_BASE_URL", "\"${project.findProperty("API_BASE_URL")}\"")
|
||||||
|
|
||||||
|
// Add backend/port buildConfig fields derived from gradle.properties (with safe defaults)
|
||||||
|
buildConfigField("String", "BACKEND_HOST", "\"${project.findProperty("BASE_URL") ?: "10.0.2.2"}\"")
|
||||||
|
buildConfigField("String", "API_PORT", "\"${project.findProperty("API_PORT") ?: "8002"}\"")
|
||||||
|
buildConfigField("String", "WS_PORT", "\"${project.findProperty("WS_PORT") ?: "8003"}\"")
|
||||||
|
|
||||||
|
buildConfigField(
|
||||||
|
"String",
|
||||||
|
"EMERGENCY_API_BASE",
|
||||||
|
"\"http://${project.findProperty("BASE_URL") ?: "10.0.2.2"}:${project.findProperty("API_PORT") ?: "8002"}/\""
|
||||||
|
)
|
||||||
|
buildConfigField(
|
||||||
|
"String",
|
||||||
|
"EMERGENCY_WS_BASE",
|
||||||
|
"\"ws://${project.findProperty("BASE_URL") ?: "10.0.2.2"}:${project.findProperty("WS_PORT") ?: "8002"}/api/v1/\""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -37,10 +66,13 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion = "1.5.14"
|
kotlinCompilerExtensionVersion = "1.5.14"
|
||||||
}
|
}
|
||||||
|
buildToolsVersion = "33.0.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -54,6 +86,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
|
implementation(libs.material)
|
||||||
kapt(libs.hilt.compiler)
|
kapt(libs.hilt.compiler)
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
kapt("androidx.room:room-compiler:2.6.1")
|
kapt("androidx.room:room-compiler:2.6.1")
|
||||||
@@ -61,6 +94,7 @@ dependencies {
|
|||||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
implementation("androidx.compose.runtime:runtime-livedata:1.5.4")
|
implementation("androidx.compose.runtime:runtime-livedata:1.5.4")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||||
implementation(libs.androidx.compose.ui.tooling)
|
implementation(libs.androidx.compose.ui.tooling)
|
||||||
implementation("androidx.compose.material:material-icons-extended:1.5.4")
|
implementation("androidx.compose.material:material-icons-extended:1.5.4")
|
||||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||||
@@ -68,8 +102,39 @@ dependencies {
|
|||||||
implementation("com.google.code.gson:gson:2.10.1")
|
implementation("com.google.code.gson:gson:2.10.1")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
|
|
||||||
|
// Network
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
|
||||||
|
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
|
||||||
|
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
|
||||||
|
implementation("com.squareup.moshi:moshi:1.15.0")
|
||||||
|
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||||
|
implementation("com.squareup.moshi:moshi-adapters:1.15.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
|
||||||
|
|
||||||
|
// Retrofit зависимости
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
|
||||||
|
// Fragment dependencies
|
||||||
|
implementation("androidx.fragment:fragment-ktx:1.6.2")
|
||||||
|
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
|
||||||
|
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
|
||||||
|
|
||||||
|
// Emergency Module dependencies
|
||||||
|
implementation("com.google.android.gms:play-services-location:21.0.1")
|
||||||
|
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||||
|
implementation("com.google.android.gms:play-services-maps:18.2.0")
|
||||||
|
|
||||||
|
// Testing dependencies
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation("io.mockk:mockk:1.13.8")
|
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
|||||||
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/10.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/10.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/11.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/11.json
Normal file
File diff suppressed because it is too large
Load Diff
1552
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/13.json
Normal file
1552
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/13.json
Normal file
File diff suppressed because it is too large
Load Diff
1754
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/14.json
Normal file
1754
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/14.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/2.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/2.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/3.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/3.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/4.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/4.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/5.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/5.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/7.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/7.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/8.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/8.json
Normal file
File diff suppressed because it is too large
Load Diff
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/9.json
Normal file
1602
app/schemas/kr.smartsoltech.wellshe.data.AppDatabase/9.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.debug
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
// Debug implementation delegates to ProperAuthTester
|
||||||
|
object AuthTester {
|
||||||
|
suspend fun runFullTest(context: Context): Boolean {
|
||||||
|
return ProperAuthTester.runFullTest(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.debug
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import org.json.JSONObject
|
||||||
|
import kr.smartsoltech.wellshe.BuildConfig
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
|
||||||
|
object ProperAuthTester {
|
||||||
|
private const val TAG = "ProperAuthTester"
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.addInterceptor(
|
||||||
|
HttpLoggingInterceptor { message -> Log.d(TAG, message) }
|
||||||
|
.apply { level = HttpLoggingInterceptor.Level.BODY }
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
suspend fun loginAndGetToken(): String? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val loginUrl = BuildConfig.API_BASE_URL + "auth/login"
|
||||||
|
Log.d(TAG, "Login URL: $loginUrl")
|
||||||
|
|
||||||
|
val json = JSONObject()
|
||||||
|
json.put("email", "shadow85@list.ru")
|
||||||
|
json.put("password", "R0sebud1985")
|
||||||
|
|
||||||
|
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
|
val body = json.toString().toRequestBody(mediaType)
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(loginUrl)
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
client.newCall(request).execute().use { resp ->
|
||||||
|
val code = resp.code
|
||||||
|
val text = resp.body?.string()
|
||||||
|
Log.d(TAG, "Auth response code=$code body=$text")
|
||||||
|
if (code == 200 && !text.isNullOrEmpty()) {
|
||||||
|
val obj = JSONObject(text)
|
||||||
|
// попытка извлечь access_token или accessToken
|
||||||
|
val token = when {
|
||||||
|
obj.has("access_token") -> obj.getString("access_token")
|
||||||
|
obj.has("accessToken") -> obj.getString("accessToken")
|
||||||
|
obj.has("token") -> obj.getString("token")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (!token.isNullOrEmpty()) {
|
||||||
|
Log.i(TAG, "Obtained token: ${token.take(50)}...")
|
||||||
|
return@withContext token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.w(TAG, "Failed to obtain token: code=$code body=$text")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error during login: ${e.message}", e)
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeJwtPayload(token: String): JSONObject? {
|
||||||
|
return try {
|
||||||
|
val parts = token.split('.')
|
||||||
|
if (parts.size < 2) return null
|
||||||
|
val payload = parts[1]
|
||||||
|
val decoded = Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||||
|
val json = String(decoded, Charset.forName("UTF-8"))
|
||||||
|
JSONObject(json)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to decode JWT: ${e.message}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun testWebSocketWithJwtToken(token: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
// Build WS URL
|
||||||
|
// BuildConfig.EMERGENCY_WS_BASE already contains ws://host:port/api/v1/
|
||||||
|
val wsUrl = BuildConfig.EMERGENCY_WS_BASE + "emergency/ws/current_user_id?token=$token"
|
||||||
|
Log.d(TAG, "WS URL: $wsUrl")
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(wsUrl)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Инициализируем deferred без неверных параметров
|
||||||
|
val deferredMsg = CompletableDeferred<String?>()
|
||||||
|
val opened = CompletableDeferred<Boolean>()
|
||||||
|
|
||||||
|
val listener = object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||||
|
Log.i(TAG, "WebSocket opened: ${response.code}")
|
||||||
|
// безопасно complete true
|
||||||
|
if (!opened.isCompleted) opened.complete(true)
|
||||||
|
// send ping-like message
|
||||||
|
val ping = JSONObject().apply {
|
||||||
|
put("type", "ping")
|
||||||
|
put("message", "Hello from Android debug tester")
|
||||||
|
}
|
||||||
|
webSocket.send(ping.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
Log.i(TAG, "WS message: $text")
|
||||||
|
if (!deferredMsg.isCompleted) deferredMsg.complete(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
Log.w(TAG, "WS closing: $code / $reason")
|
||||||
|
webSocket.close(1000, null)
|
||||||
|
if (!deferredMsg.isCompleted) deferredMsg.complete(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||||
|
Log.e(TAG, "WS failure: ${t.message}", t)
|
||||||
|
if (!deferredMsg.isCompleted) deferredMsg.completeExceptionally(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ws = client.newWebSocket(request, listener)
|
||||||
|
|
||||||
|
// wait for open or timeout
|
||||||
|
try {
|
||||||
|
opened.await()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "WebSocket did not open: ${e.message}")
|
||||||
|
ws.cancel()
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait for incoming message (short timeout)
|
||||||
|
return@withContext try {
|
||||||
|
val msg = kotlinx.coroutines.withTimeoutOrNull(5000) { deferredMsg.await() }
|
||||||
|
Log.d(TAG, "Received WS reply: $msg")
|
||||||
|
ws.close(1000, "done")
|
||||||
|
msg != null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error waiting for WS message: ${e.message}", e)
|
||||||
|
ws.cancel()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error connecting to WS: ${e.message}", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun runFullTest(context: Context): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.i(TAG, "Starting full proper-auth test")
|
||||||
|
val token = loginAndGetToken()
|
||||||
|
if (token.isNullOrEmpty()) {
|
||||||
|
Log.e(TAG, "No token obtained — aborting test")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode payload for inspection
|
||||||
|
val payload = decodeJwtPayload(token)
|
||||||
|
if (payload != null) {
|
||||||
|
Log.i(TAG, "Token payload: $payload")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Could not decode token payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
val wsResult = testWebSocketWithJwtToken(token)
|
||||||
|
Log.i(TAG, "WebSocket test result: $wsResult")
|
||||||
|
wsResult
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,15 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- Разрешения для геолокации (добавлены: без них системный диалог не покажется) -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<!-- Background location требует отдельной обработки на Android 10+; добавить при необходимости -->
|
||||||
|
<!-- <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> -->
|
||||||
|
|
||||||
|
<!-- Разрешение для совершения экстренных звонков -->
|
||||||
|
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".WellSheApplication"
|
android:name=".WellSheApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -24,6 +33,7 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:theme="@style/Theme.WellShe">
|
android:theme="@style/Theme.WellShe">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
@@ -49,6 +59,17 @@
|
|||||||
android:value="androidx.startup" />
|
android:value="androidx.startup" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<!-- BroadcastReceiver для обработки действий из уведомлений -->
|
||||||
|
<receiver
|
||||||
|
android:name=".emergency.utils.EmergencyActionReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="HELP_ACTION" />
|
||||||
|
<action android:name="CALL_POLICE_ACTION" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -1,22 +1,69 @@
|
|||||||
package kr.smartsoltech.wellshe
|
package kr.smartsoltech.wellshe
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kr.smartsoltech.wellshe.ui.navigation.WellSheNavigation
|
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
|
||||||
|
import kr.smartsoltech.wellshe.ui.navigation.AppNavGraph
|
||||||
|
import kr.smartsoltech.wellshe.ui.navigation.BottomNavigation
|
||||||
|
import kr.smartsoltech.wellshe.ui.navigation.BottomNavItem
|
||||||
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
|
||||||
setContent {
|
try {
|
||||||
WellSheTheme {
|
setContent {
|
||||||
WellSheNavigation()
|
WellSheTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
// Получаем AuthViewModel для управления авторизацией
|
||||||
|
val authViewModel: AuthViewModel = viewModel()
|
||||||
|
|
||||||
|
// Получаем текущий маршрут для определения показа нижней навигации
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
|
// Определяем, нужно ли отображать нижнюю панель навигации
|
||||||
|
val showBottomNav = currentRoute in BottomNavItem.items.map { it.route }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
if (showBottomNav) {
|
||||||
|
BottomNavigation(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
// Навигационный граф приложения с передачей authViewModel
|
||||||
|
AppNavGraph(
|
||||||
|
navController = navController,
|
||||||
|
modifier = Modifier.padding(paddingValues),
|
||||||
|
authViewModel = authViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Log.d("MainActivity", "Activity started successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("MainActivity", "Error in onCreate: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,60 @@
|
|||||||
package kr.smartsoltech.wellshe
|
package kr.smartsoltech.wellshe
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.preferences.ServerPreferences
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class WellSheApplication : Application() {
|
class WellSheApplication : Application() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "WellSheApplication"
|
||||||
|
}
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface EmergencyRepositoryEntryPoint {
|
||||||
|
fun emergencyRepository(): EmergencyRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface ServerPreferencesEntryPoint {
|
||||||
|
fun serverPreferences(): ServerPreferences
|
||||||
|
}
|
||||||
|
|
||||||
|
val emergencyRepository: EmergencyRepository by lazy {
|
||||||
|
val hiltEntryPoint = EntryPointAccessors.fromApplication(
|
||||||
|
this,
|
||||||
|
EmergencyRepositoryEntryPoint::class.java
|
||||||
|
)
|
||||||
|
hiltEntryPoint.emergencyRepository()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val serverPreferences: ServerPreferences by lazy {
|
||||||
|
val hiltEntryPoint = EntryPointAccessors.fromApplication(
|
||||||
|
this,
|
||||||
|
ServerPreferencesEntryPoint::class.java
|
||||||
|
)
|
||||||
|
hiltEntryPoint.serverPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
// TODO: Initialize app components when repositories are ready
|
Log.d(TAG, "WellShe Application starting...")
|
||||||
|
|
||||||
|
// Логируем текущие настройки сервера при запуске
|
||||||
|
try {
|
||||||
|
serverPreferences.debugSettings()
|
||||||
|
Log.d(TAG, "Application started successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error during app startup", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,30 +5,105 @@ import androidx.room.RoomDatabase
|
|||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import kr.smartsoltech.wellshe.data.entity.*
|
import kr.smartsoltech.wellshe.data.entity.*
|
||||||
import kr.smartsoltech.wellshe.data.dao.*
|
import kr.smartsoltech.wellshe.data.dao.*
|
||||||
import kr.smartsoltech.wellshe.data.converter.DateConverters
|
import java.time.LocalDate
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
|
// Основные сущности
|
||||||
WaterLogEntity::class,
|
WaterLogEntity::class,
|
||||||
WorkoutEntity::class,
|
WorkoutEntity::class,
|
||||||
SleepLogEntity::class,
|
|
||||||
CyclePeriodEntity::class,
|
|
||||||
HealthRecordEntity::class,
|
|
||||||
CalorieEntity::class,
|
CalorieEntity::class,
|
||||||
StepsEntity::class,
|
StepsEntity::class,
|
||||||
UserProfileEntity::class
|
UserProfileEntity::class,
|
||||||
|
WorkoutSession::class,
|
||||||
|
WorkoutSessionParam::class,
|
||||||
|
WorkoutEvent::class,
|
||||||
|
CyclePeriodEntity::class,
|
||||||
|
HealthRecordEntity::class,
|
||||||
|
|
||||||
|
// Новые сущности модуля "Настройки цикла"
|
||||||
|
CycleSettingsEntity::class,
|
||||||
|
CycleHistoryEntity::class,
|
||||||
|
CycleForecastEntity::class,
|
||||||
|
|
||||||
|
// Дополнительные сущности из BodyEntities.kt
|
||||||
|
Nutrient::class,
|
||||||
|
Beverage::class,
|
||||||
|
BeverageServing::class,
|
||||||
|
BeverageServingNutrient::class,
|
||||||
|
WaterLog::class,
|
||||||
|
BeverageLog::class,
|
||||||
|
BeverageLogNutrient::class,
|
||||||
|
WeightLog::class,
|
||||||
|
Exercise::class,
|
||||||
|
ExerciseParam::class,
|
||||||
|
ExerciseFormula::class,
|
||||||
|
ExerciseFormulaVar::class,
|
||||||
|
CatalogVersion::class,
|
||||||
|
|
||||||
|
// Emergency Module entities
|
||||||
|
kr.smartsoltech.wellshe.emergency.data.entities.EmergencyEventEntity::class,
|
||||||
|
kr.smartsoltech.wellshe.emergency.data.entities.EmergencyResponseEntity::class
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 14, // Увеличиваем версию для Emergency Module
|
||||||
exportSchema = false
|
exportSchema = true
|
||||||
)
|
)
|
||||||
@TypeConverters(DateConverters::class)
|
@TypeConverters(LocalDateConverter::class, InstantConverter::class, StringListConverter::class, EmergencyTypeConverter::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun waterLogDao(): WaterLogDao
|
abstract fun waterLogDao(): WaterLogDao
|
||||||
abstract fun workoutDao(): WorkoutDao
|
abstract fun workoutDao(): WorkoutDao
|
||||||
abstract fun sleepLogDao(): SleepLogDao
|
|
||||||
abstract fun cyclePeriodDao(): CyclePeriodDao
|
|
||||||
abstract fun healthRecordDao(): HealthRecordDao
|
|
||||||
abstract fun calorieDao(): CalorieDao
|
abstract fun calorieDao(): CalorieDao
|
||||||
abstract fun stepsDao(): StepsDao
|
abstract fun stepsDao(): StepsDao
|
||||||
abstract fun userProfileDao(): UserProfileDao
|
abstract fun userProfileDao(): UserProfileDao
|
||||||
|
abstract fun cyclePeriodDao(): CyclePeriodDao
|
||||||
|
abstract fun healthRecordDao(): HealthRecordDao
|
||||||
|
|
||||||
|
// Новые DAO для модуля "Настройки цикла"
|
||||||
|
abstract fun cycleSettingsDao(): CycleSettingsDao
|
||||||
|
abstract fun cycleHistoryDao(): CycleHistoryDao
|
||||||
|
abstract fun cycleForecastDao(): CycleForecastDao
|
||||||
|
|
||||||
|
// Дополнительные DAO для repo
|
||||||
|
abstract fun beverageDao(): BeverageDao
|
||||||
|
abstract fun beverageServingDao(): BeverageServingDao
|
||||||
|
abstract fun beverageLogDao(): BeverageLogDao
|
||||||
|
abstract fun beverageLogNutrientDao(): BeverageLogNutrientDao
|
||||||
|
abstract fun beverageServingNutrientDao(): BeverageServingNutrientDao
|
||||||
|
abstract fun weightLogDao(): WeightLogDao
|
||||||
|
abstract fun workoutSessionDao(): WorkoutSessionDao
|
||||||
|
abstract fun workoutSessionParamDao(): WorkoutSessionParamDao
|
||||||
|
abstract fun workoutEventDao(): WorkoutEventDao
|
||||||
|
abstract fun exerciseDao(): ExerciseDao
|
||||||
|
abstract fun exerciseParamDao(): ExerciseParamDao
|
||||||
|
abstract fun exerciseFormulaDao(): ExerciseFormulaDao
|
||||||
|
abstract fun exerciseFormulaVarDao(): ExerciseFormulaVarDao
|
||||||
|
abstract fun nutrientDao(): NutrientDao
|
||||||
|
abstract fun catalogVersionDao(): CatalogVersionDao
|
||||||
|
|
||||||
|
// Emergency Module DAO
|
||||||
|
abstract fun emergencyDao(): kr.smartsoltech.wellshe.emergency.data.dao.EmergencyDao
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalDateConverter {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromTimestamp(value: Long?): LocalDate? = value?.let { java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault()).toLocalDate() }
|
||||||
|
@TypeConverter
|
||||||
|
fun dateToTimestamp(date: LocalDate?): Long? = date?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmergencyTypeConverter {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromEmergencyType(type: kr.smartsoltech.wellshe.emergency.data.models.EmergencyType): String = type.name
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toEmergencyType(name: String): kr.smartsoltech.wellshe.emergency.data.models.EmergencyType =
|
||||||
|
kr.smartsoltech.wellshe.emergency.data.models.EmergencyType.valueOf(name)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromEmergencyStatus(status: kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus): String = status.name
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toEmergencyStatus(name: String): kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus =
|
||||||
|
kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus.valueOf(name)
|
||||||
}
|
}
|
||||||
|
|||||||
21
app/src/main/java/kr/smartsoltech/wellshe/data/Converters.kt
Normal file
21
app/src/main/java/kr/smartsoltech/wellshe/data/Converters.kt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
class InstantConverter {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromTimestamp(value: Long?): Instant? = value?.let { Instant.ofEpochMilli(it) }
|
||||||
|
@TypeConverter
|
||||||
|
fun instantToTimestamp(instant: Instant?): Long? = instant?.toEpochMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
class StringListConverter {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromString(value: String?): List<String>? = value?.let {
|
||||||
|
if (it.isEmpty()) emptyList() else it.split("||")
|
||||||
|
}
|
||||||
|
@TypeConverter
|
||||||
|
fun listToString(list: List<String>?): String = list?.joinToString("||") ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,776 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция базы данных с версии 1 на версию 2.
|
||||||
|
* Добавляет таблицы для модуля "Настройки цикла".
|
||||||
|
*/
|
||||||
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// Создание таблицы cycle_settings
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `cycle_settings` (
|
||||||
|
`id` INTEGER NOT NULL,
|
||||||
|
`baselineCycleLength` INTEGER NOT NULL,
|
||||||
|
`cycleVariabilityDays` INTEGER NOT NULL,
|
||||||
|
`periodLengthDays` INTEGER NOT NULL,
|
||||||
|
`lutealPhaseDays` TEXT NOT NULL,
|
||||||
|
`lastPeriodStart` INTEGER,
|
||||||
|
`ovulationMethod` TEXT NOT NULL,
|
||||||
|
`allowManualOvulation` INTEGER NOT NULL,
|
||||||
|
`hormonalContraception` TEXT NOT NULL,
|
||||||
|
`isPregnant` INTEGER NOT NULL,
|
||||||
|
`isPostpartum` INTEGER NOT NULL,
|
||||||
|
`isLactating` INTEGER NOT NULL,
|
||||||
|
`perimenopause` INTEGER NOT NULL,
|
||||||
|
`historyWindowCycles` INTEGER NOT NULL,
|
||||||
|
`excludeOutliers` INTEGER NOT NULL,
|
||||||
|
`tempUnit` TEXT NOT NULL,
|
||||||
|
`bbtTimeWindow` TEXT NOT NULL,
|
||||||
|
`timezone` TEXT NOT NULL,
|
||||||
|
`periodReminderDaysBefore` INTEGER NOT NULL,
|
||||||
|
`ovulationReminderDaysBefore` INTEGER NOT NULL,
|
||||||
|
`pmsWindowDays` INTEGER NOT NULL,
|
||||||
|
`deviationAlertDays` INTEGER NOT NULL,
|
||||||
|
`fertileWindowMode` TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(`id`)
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Создание таблицы cycle_history
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `cycle_history` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`periodStart` INTEGER NOT NULL,
|
||||||
|
`periodEnd` INTEGER,
|
||||||
|
`ovulationDate` INTEGER,
|
||||||
|
`notes` TEXT NOT NULL,
|
||||||
|
`atypical` INTEGER NOT NULL,
|
||||||
|
`flow` TEXT NOT NULL DEFAULT '',
|
||||||
|
`symptoms` TEXT NOT NULL DEFAULT '',
|
||||||
|
`mood` TEXT NOT NULL DEFAULT '',
|
||||||
|
`cycleLength` INTEGER
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Индекс для cycle_history по периоду начала
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS `index_cycle_history_periodStart` ON `cycle_history` (`periodStart`)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Создание таблицы cycle_forecast
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `cycle_forecast` (
|
||||||
|
`id` INTEGER NOT NULL,
|
||||||
|
`nextPeriodStart` INTEGER,
|
||||||
|
`nextOvulation` INTEGER,
|
||||||
|
`fertileStart` INTEGER,
|
||||||
|
`fertileEnd` INTEGER,
|
||||||
|
`pmsStart` INTEGER,
|
||||||
|
`updatedAt` INTEGER NOT NULL,
|
||||||
|
`isReliable` INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`id`)
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Импорт существующих данных из таблицы cycle_periods в cycle_history
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO cycle_history (periodStart, periodEnd, notes, atypical)
|
||||||
|
SELECT startDate, endDate,
|
||||||
|
CASE WHEN flow != '' OR mood != '' OR symptoms != ''
|
||||||
|
THEN 'Flow: ' || flow || ', Mood: ' || mood
|
||||||
|
ELSE ''
|
||||||
|
END,
|
||||||
|
0
|
||||||
|
FROM cycle_periods
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция базы данных с версии 2 на версию 3.
|
||||||
|
* Исправляет проблему с типом данных столбца date в таблице water_logs.
|
||||||
|
*/
|
||||||
|
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// Создаем временную таблицу с правильными типами данных
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `water_logs_new` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`date` INTEGER NOT NULL,
|
||||||
|
`amount` INTEGER NOT NULL,
|
||||||
|
`timestamp` INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
|
||||||
|
// Для этого используем SQLite функцию strftime для преобразования строковой даты в timestamp
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO water_logs_new (id, date, amount, timestamp)
|
||||||
|
SELECT id,
|
||||||
|
CASE
|
||||||
|
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
|
||||||
|
ELSE strftime('%s', 'now') * 1000
|
||||||
|
END as date_int,
|
||||||
|
amount,
|
||||||
|
timestamp
|
||||||
|
FROM water_logs
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Удаляем старую таблицу
|
||||||
|
database.execSQL("DROP TABLE water_logs")
|
||||||
|
|
||||||
|
// Переименовываем новую таблицу в старое имя
|
||||||
|
database.execSQL("ALTER TABLE water_logs_new RENAME TO water_logs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция базы данных с версии 3 на версию 4.
|
||||||
|
* Исправляет проблему с типом данных столбца date в таблице sleep_logs.
|
||||||
|
*/
|
||||||
|
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// Создаем временную таблицу с правильными типами данных
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `sleep_logs_new` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`date` INTEGER NOT NULL,
|
||||||
|
`bedTime` TEXT NOT NULL,
|
||||||
|
`wakeTime` TEXT NOT NULL,
|
||||||
|
`duration` REAL NOT NULL,
|
||||||
|
`quality` TEXT NOT NULL,
|
||||||
|
`notes` TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO sleep_logs_new (id, date, bedTime, wakeTime, duration, quality, notes)
|
||||||
|
SELECT id,
|
||||||
|
CASE
|
||||||
|
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
|
||||||
|
ELSE strftime('%s', 'now') * 1000
|
||||||
|
END as date_int,
|
||||||
|
bedTime,
|
||||||
|
wakeTime,
|
||||||
|
duration,
|
||||||
|
quality,
|
||||||
|
notes
|
||||||
|
FROM sleep_logs
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Удаляем старую таблицу
|
||||||
|
database.execSQL("DROP TABLE sleep_logs")
|
||||||
|
|
||||||
|
// Переименовываем новую таблицу в старое имя
|
||||||
|
database.execSQL("ALTER TABLE sleep_logs_new RENAME TO sleep_logs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция базы данных с версии 4 на версию 5.
|
||||||
|
* Исправляет проблему с типом данных столбца date в таблице workouts.
|
||||||
|
*/
|
||||||
|
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// Создаем временную таблицу с правильными типами данных
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `workouts_new` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`date` INTEGER NOT NULL,
|
||||||
|
`type` TEXT NOT NULL,
|
||||||
|
`name` TEXT NOT NULL,
|
||||||
|
`duration` INTEGER NOT NULL,
|
||||||
|
`caloriesBurned` INTEGER NOT NULL,
|
||||||
|
`intensity` TEXT NOT NULL,
|
||||||
|
`notes` TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO workouts_new (id, date, type, name, duration, caloriesBurned, intensity, notes)
|
||||||
|
SELECT id,
|
||||||
|
CASE
|
||||||
|
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
|
||||||
|
ELSE strftime('%s', 'now') * 1000
|
||||||
|
END as date_int,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
duration,
|
||||||
|
caloriesBurned,
|
||||||
|
intensity,
|
||||||
|
notes
|
||||||
|
FROM workouts
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Удаляем старую таблицу
|
||||||
|
database.execSQL("DROP TABLE workouts")
|
||||||
|
|
||||||
|
// Переименовываем новую таблицу в старое имя
|
||||||
|
database.execSQL("ALTER TABLE workouts_new RENAME TO workouts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция базы данных с версии 5 на версию 6.
|
||||||
|
* Исправляет проблему с типом данных столбца date в таблице calories.
|
||||||
|
*/
|
||||||
|
val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// Создаем временную таблицу с правильными типами данных
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `calories_new` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`date` INTEGER NOT NULL,
|
||||||
|
`consumed` INTEGER NOT NULL,
|
||||||
|
`burned` INTEGER NOT NULL,
|
||||||
|
`target` INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO calories_new (id, date, consumed, burned, target)
|
||||||
|
SELECT id,
|
||||||
|
CASE
|
||||||
|
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
|
||||||
|
ELSE strftime('%s', 'now') * 1000
|
||||||
|
END as date_int,
|
||||||
|
consumed,
|
||||||
|
burned,
|
||||||
|
target
|
||||||
|
FROM calories
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Удаляем старую таблицу
|
||||||
|
database.execSQL("DROP TABLE calories")
|
||||||
|
|
||||||
|
// Переименовываем новую таблицу в старое имя
|
||||||
|
database.execSQL("ALTER TABLE calories_new RENAME TO calories")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция базы данных с версии 6 на версию 7.
|
||||||
|
* Исправляет проблему с типом данных столбца date во всех оставшихся таблицах.
|
||||||
|
*/
|
||||||
|
val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// Пропускаем таблицу steps, так как она будет обработана в MIGRATION_7_8
|
||||||
|
// Начинаем с проверки существования таблицы health_records и наличия в ней поля date с типом TEXT
|
||||||
|
try {
|
||||||
|
val cursor = database.query("PRAGMA table_info(health_records)")
|
||||||
|
var hasDateColumn = false
|
||||||
|
var isTextType = false
|
||||||
|
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
do {
|
||||||
|
val columnName = cursor.getString(cursor.getColumnIndex("name"))
|
||||||
|
val columnType = cursor.getString(cursor.getColumnIndex("type"))
|
||||||
|
|
||||||
|
if (columnName == "date" && columnType.equals("TEXT", ignoreCase = true)) {
|
||||||
|
hasDateColumn = true
|
||||||
|
isTextType = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
}
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
if (hasDateColumn && isTextType) {
|
||||||
|
// Создаем временную таблицу с правильными типами данных
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `health_records_new` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`date` INTEGER NOT NULL,
|
||||||
|
`type` TEXT NOT NULL,
|
||||||
|
`value` REAL NOT NULL,
|
||||||
|
`notes` TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO health_records_new (id, date, type, value, notes)
|
||||||
|
SELECT id,
|
||||||
|
CASE
|
||||||
|
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
|
||||||
|
ELSE strftime('%s', 'now') * 1000
|
||||||
|
END as date_int,
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
notes
|
||||||
|
FROM health_records
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Удаляем старую таблицу
|
||||||
|
database.execSQL("DROP TABLE health_records")
|
||||||
|
|
||||||
|
// Переименовываем новую таблицу в старое имя
|
||||||
|
database.execSQL("ALTER TABLE health_records_new RENAME TO health_records")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Если таблица не существует или возникла другая ошибка, просто продолжаем
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем другие таблицы, которые могут содержать поля даты с типом TEXT
|
||||||
|
// Список таблиц для проверки
|
||||||
|
val tablesToCheck = listOf(
|
||||||
|
"cycle_periods",
|
||||||
|
"user_profiles",
|
||||||
|
"weight_logs",
|
||||||
|
"beverage_logs",
|
||||||
|
"workout_sessions"
|
||||||
|
)
|
||||||
|
|
||||||
|
for (table in tablesToCheck) {
|
||||||
|
try {
|
||||||
|
val cursor = database.query("PRAGMA table_info($table)")
|
||||||
|
val dateColumns = mutableListOf<String>()
|
||||||
|
val columns = mutableListOf<Pair<String, String>>()
|
||||||
|
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
do {
|
||||||
|
val columnNameIndex = cursor.getColumnIndex("name")
|
||||||
|
val columnTypeIndex = cursor.getColumnIndex("type")
|
||||||
|
|
||||||
|
if (columnNameIndex >= 0 && columnTypeIndex >= 0) {
|
||||||
|
val columnName = cursor.getString(columnNameIndex)
|
||||||
|
val columnType = cursor.getString(columnTypeIndex)
|
||||||
|
|
||||||
|
columns.add(columnName to columnType)
|
||||||
|
|
||||||
|
// Проверяем, есть ли в названии колонки слово "date" и тип TEXT
|
||||||
|
if (columnName.contains("date", ignoreCase = true) &&
|
||||||
|
columnType.equals("TEXT", ignoreCase = true)) {
|
||||||
|
dateColumns.add(columnName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
}
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
// Если найдены столбцы с датами типа TEXT
|
||||||
|
if (dateColumns.isNotEmpty()) {
|
||||||
|
// Создаем имя для временной таблицы
|
||||||
|
val newTableName = "${table}_new"
|
||||||
|
|
||||||
|
// Создаем SQL для создания новой таблицы с правильными типами
|
||||||
|
val createTableSQL = StringBuilder()
|
||||||
|
createTableSQL.append("CREATE TABLE IF NOT EXISTS `$newTableName` (")
|
||||||
|
|
||||||
|
val columnDefinitions = columns.map { (name, type) ->
|
||||||
|
// Для столбцов с датой меняем тип на INTEGER
|
||||||
|
if (dateColumns.contains(name)) {
|
||||||
|
"`$name` INTEGER" + if (name == "id") " PRIMARY KEY AUTOINCREMENT NOT NULL" else " NOT NULL"
|
||||||
|
} else {
|
||||||
|
"`$name` $type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createTableSQL.append(columnDefinitions.joinToString(", "))
|
||||||
|
createTableSQL.append(")")
|
||||||
|
|
||||||
|
// Выполняем создание новой таблицы
|
||||||
|
database.execSQL(createTableSQL.toString())
|
||||||
|
|
||||||
|
// Создаем SQL для копирования данных
|
||||||
|
val insertSQL = StringBuilder()
|
||||||
|
insertSQL.append("INSERT INTO `$newTableName` SELECT ")
|
||||||
|
|
||||||
|
val columnSelects = columns.map { (name, _) ->
|
||||||
|
if (dateColumns.contains(name)) {
|
||||||
|
"CASE WHEN $name IS NOT NULL THEN strftime('%s', $name) * 1000 ELSE strftime('%s', 'now') * 1000 END as $name"
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insertSQL.append(columnSelects.joinToString(", "))
|
||||||
|
insertSQL.append(" FROM `$table`")
|
||||||
|
|
||||||
|
// Выполняем копирование данных
|
||||||
|
database.execSQL(insertSQL.toString())
|
||||||
|
|
||||||
|
// Удаляем старую таблицу и переименовываем новую
|
||||||
|
database.execSQL("DROP TABLE `$table`")
|
||||||
|
database.execSQL("ALTER TABLE `$newTableName` RENAME TO `$table`")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Если таблица не существует или возникла другая ошибка, просто продолжаем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция базы данных с версии 7 на версию 8.
|
||||||
|
* Исправляет проблему с типом данных столбца date в таблице steps.
|
||||||
|
*/
|
||||||
|
val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// Создаем временную таблицу с правильными типами данных
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `steps_new` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`date` INTEGER NOT NULL,
|
||||||
|
`steps` INTEGER NOT NULL,
|
||||||
|
`distance` REAL NOT NULL,
|
||||||
|
`caloriesBurned` INTEGER NOT NULL,
|
||||||
|
`target` INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Копируем данные из старой таблицы в новую, преобразуя дату из TEXT в INTEGER
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO steps_new (id, date, steps, distance, caloriesBurned, target)
|
||||||
|
SELECT id,
|
||||||
|
CASE
|
||||||
|
WHEN date IS NOT NULL THEN strftime('%s', date) * 1000
|
||||||
|
ELSE strftime('%s', 'now') * 1000
|
||||||
|
END as date_int,
|
||||||
|
steps,
|
||||||
|
distance,
|
||||||
|
caloriesBurned,
|
||||||
|
target
|
||||||
|
FROM steps
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Удаляем старую таблицу
|
||||||
|
database.execSQL("DROP TABLE steps")
|
||||||
|
|
||||||
|
// Переименовываем новую таблицу в старое имя
|
||||||
|
database.execSQL("ALTER TABLE steps_new RENAME TO steps")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция базы данных с версии 8 на версию 9.
|
||||||
|
* Исправляет проблему с типом данных столбца lastPeriodDate в таблице user_profile.
|
||||||
|
*/
|
||||||
|
val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// Создаем временную таблицу с правильными типами данных
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `user_profile_new` (
|
||||||
|
`id` INTEGER PRIMARY KEY NOT NULL,
|
||||||
|
`name` TEXT NOT NULL,
|
||||||
|
`email` TEXT NOT NULL,
|
||||||
|
`age` INTEGER NOT NULL,
|
||||||
|
`height` INTEGER NOT NULL,
|
||||||
|
`weight` REAL NOT NULL,
|
||||||
|
`targetWeight` REAL NOT NULL,
|
||||||
|
`activityLevel` TEXT NOT NULL,
|
||||||
|
`dailyWaterGoal` INTEGER NOT NULL,
|
||||||
|
`dailyCalorieGoal` INTEGER NOT NULL,
|
||||||
|
`dailyStepsGoal` INTEGER NOT NULL,
|
||||||
|
`cycleLength` INTEGER NOT NULL,
|
||||||
|
`periodLength` INTEGER NOT NULL,
|
||||||
|
`lastPeriodDate` INTEGER,
|
||||||
|
`profileImagePath` TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Копируем данные из старой таблицы в новую, преобразуя lastPeriodDate из TEXT в INTEGER
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_profile_new (
|
||||||
|
id, name, email, age, height, weight, targetWeight, activityLevel,
|
||||||
|
dailyWaterGoal, dailyCalorieGoal, dailyStepsGoal, cycleLength,
|
||||||
|
periodLength, lastPeriodDate, profileImagePath
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, name, email, age, height, weight, targetWeight, activityLevel,
|
||||||
|
dailyWaterGoal, dailyCalorieGoal, dailyStepsGoal, cycleLength,
|
||||||
|
periodLength,
|
||||||
|
CASE
|
||||||
|
WHEN lastPeriodDate IS NOT NULL THEN strftime('%s', lastPeriodDate) * 1000
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
profileImagePath
|
||||||
|
FROM user_profile
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Удаляем старую таблицу
|
||||||
|
database.execSQL("DROP TABLE user_profile")
|
||||||
|
|
||||||
|
// Переименовываем новую таблицу в старое имя
|
||||||
|
database.execSQL("ALTER TABLE user_profile_new RENAME TO user_profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция базы данных с версии 9 на версию 10.
|
||||||
|
* Исправляет проблему с отсутствующей таблицей WorkoutSession.
|
||||||
|
*/
|
||||||
|
val MIGRATION_9_10 = object : Migration(9, 10) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// Проверяем, существует ли таблица WorkoutSession
|
||||||
|
try {
|
||||||
|
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='WorkoutSession'")
|
||||||
|
val hasTable = cursor.count > 0
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
if (!hasTable) {
|
||||||
|
// Создаем таблицу WorkoutSession если она не существует
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `WorkoutSession` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`startedAt` INTEGER NOT NULL,
|
||||||
|
`endedAt` INTEGER,
|
||||||
|
`exerciseId` INTEGER NOT NULL,
|
||||||
|
`kcalTotal` REAL,
|
||||||
|
`distanceKm` REAL,
|
||||||
|
`notes` TEXT
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Создаем индекс для столбца startedAt
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `WorkoutSession` (`startedAt`)"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Если таблица существует, проверяем наличие необходимых столбцов
|
||||||
|
val columnCursor = database.query("PRAGMA table_info(WorkoutSession)")
|
||||||
|
val columns = mutableListOf<String>()
|
||||||
|
|
||||||
|
while (columnCursor.moveToNext()) {
|
||||||
|
val columnName = columnCursor.getString(columnCursor.getColumnIndex("name"))
|
||||||
|
columns.add(columnName)
|
||||||
|
}
|
||||||
|
columnCursor.close()
|
||||||
|
|
||||||
|
// Если нужных колонок нет, пересоздаем таблицу
|
||||||
|
val requiredColumns = listOf("id", "startedAt", "endedAt", "exerciseId",
|
||||||
|
"kcalTotal", "distanceKm", "notes")
|
||||||
|
if (!columns.containsAll(requiredColumns)) {
|
||||||
|
// Переименовываем старую таблицу
|
||||||
|
database.execSQL("ALTER TABLE `WorkoutSession` RENAME TO `WorkoutSession_old`")
|
||||||
|
|
||||||
|
// Создаем новую таблицу с правильной структурой
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE `WorkoutSession` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`startedAt` INTEGER NOT NULL,
|
||||||
|
`endedAt` INTEGER,
|
||||||
|
`exerciseId` INTEGER NOT NULL,
|
||||||
|
`kcalTotal` REAL,
|
||||||
|
`distanceKm` REAL,
|
||||||
|
`notes` TEXT
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Создаем индекс для столбца startedAt
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `WorkoutSession` (`startedAt`)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Пытаемся скопировать данные из старой таблицы, если это возможно
|
||||||
|
try {
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO WorkoutSession (id, startedAt, endedAt, exerciseId, kcalTotal, distanceKm, notes)
|
||||||
|
SELECT id, startedAt, endedAt, exerciseId, kcalTotal, distanceKm, notes
|
||||||
|
FROM WorkoutSession_old
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Если копирование не удалось, просто продолжаем без данных
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем старую таблицу
|
||||||
|
database.execSQL("DROP TABLE IF EXISTS `WorkoutSession_old`")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// В случае любой ошибки, создаем таблицу заново
|
||||||
|
database.execSQL("DROP TABLE IF EXISTS `WorkoutSession`")
|
||||||
|
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE `WorkoutSession` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`startedAt` INTEGER NOT NULL,
|
||||||
|
`endedAt` INTEGER,
|
||||||
|
`exerciseId` INTEGER NOT NULL,
|
||||||
|
`kcalTotal` REAL,
|
||||||
|
`distanceKm` REAL,
|
||||||
|
`notes` TEXT
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Создаем индекс для столбца startedAt
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_WorkoutSession_startedAt` ON `WorkoutSession` (`startedAt`)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Также проверяем наличие таблицы WorkoutSessionParam и создаем её при необходимости
|
||||||
|
try {
|
||||||
|
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='WorkoutSessionParam'")
|
||||||
|
val hasTable = cursor.count > 0
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
if (!hasTable) {
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `WorkoutSessionParam` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`sessionId` INTEGER NOT NULL,
|
||||||
|
`key` TEXT NOT NULL,
|
||||||
|
`valueNum` REAL,
|
||||||
|
`valueText` TEXT,
|
||||||
|
`unit` TEXT
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
database.execSQL("DROP TABLE IF EXISTS `WorkoutSessionParam`")
|
||||||
|
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE `WorkoutSessionParam` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`sessionId` INTEGER NOT NULL,
|
||||||
|
`key` TEXT NOT NULL,
|
||||||
|
`valueNum` REAL,
|
||||||
|
`valueText` TEXT,
|
||||||
|
`unit` TEXT
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// И проверяем наличие таблицы WorkoutEvent
|
||||||
|
try {
|
||||||
|
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='WorkoutEvent'")
|
||||||
|
val hasTable = cursor.count > 0
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
if (!hasTable) {
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `WorkoutEvent` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`sessionId` INTEGER NOT NULL,
|
||||||
|
`timestamp` INTEGER NOT NULL,
|
||||||
|
`eventType` TEXT NOT NULL,
|
||||||
|
`valueNum` REAL,
|
||||||
|
`valueText` TEXT
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
database.execSQL("DROP TABLE IF EXISTS `WorkoutEvent`")
|
||||||
|
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE `WorkoutEvent` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`sessionId` INTEGER NOT NULL,
|
||||||
|
`timestamp` INTEGER NOT NULL,
|
||||||
|
`eventType` TEXT NOT NULL,
|
||||||
|
`valueNum` REAL,
|
||||||
|
`valueText` TEXT
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция базы данных с версии 10 на версию 11.
|
||||||
|
* Исправляет проблему с несоответствием структуры таблицы WorkoutEvent.
|
||||||
|
*/
|
||||||
|
val MIGRATION_10_11 = object : Migration(10, 11) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// Создаем временную таблицу с правильной структурой
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `WorkoutEvent_new` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`sessionId` INTEGER NOT NULL,
|
||||||
|
`ts` INTEGER NOT NULL,
|
||||||
|
`eventType` TEXT NOT NULL,
|
||||||
|
`payloadJson` TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Пытаемся скопировать данные из старой таблицы, преобразовывая структуру
|
||||||
|
try {
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO WorkoutEvent_new (id, sessionId, ts, eventType, payloadJson)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
sessionId,
|
||||||
|
timestamp AS ts,
|
||||||
|
eventType,
|
||||||
|
CASE
|
||||||
|
WHEN valueText IS NOT NULL THEN valueText
|
||||||
|
WHEN valueNum IS NOT NULL THEN json_object('value', valueNum)
|
||||||
|
ELSE '{}'
|
||||||
|
END AS payloadJson
|
||||||
|
FROM WorkoutEvent
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Если копирование не удалось из-за несовместимости данных,
|
||||||
|
// просто создаем пустую таблицу
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем старую таблицу
|
||||||
|
database.execSQL("DROP TABLE IF EXISTS WorkoutEvent")
|
||||||
|
|
||||||
|
// Переименовываем новую таблицу
|
||||||
|
database.execSQL("ALTER TABLE WorkoutEvent_new RENAME TO WorkoutEvent")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.api
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.model.ServerHealthResponse
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.GET
|
||||||
|
|
||||||
|
interface HealthApi {
|
||||||
|
@GET("api/v1/health")
|
||||||
|
suspend fun getHealth(): Response<ServerHealthResponse>
|
||||||
|
}
|
||||||
151
app/src/main/java/kr/smartsoltech/wellshe/data/dao/BodyDao.kt
Normal file
151
app/src/main/java/kr/smartsoltech/wellshe/data/dao/BodyDao.kt
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.*
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface NutrientDao {
|
||||||
|
@Query("SELECT * FROM Nutrient WHERE code = :code LIMIT 1")
|
||||||
|
suspend fun getByCode(code: String): Nutrient?
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(nutrient: Nutrient): Long
|
||||||
|
@Query("SELECT * FROM Nutrient")
|
||||||
|
suspend fun getAll(): List<Nutrient>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface BeverageDao {
|
||||||
|
@Query("SELECT * FROM Beverage WHERE id = :id")
|
||||||
|
suspend fun getById(id: Long): Beverage?
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(beverage: Beverage): Long
|
||||||
|
@Query("SELECT * FROM Beverage WHERE name LIKE :query LIMIT 20")
|
||||||
|
suspend fun search(query: String): List<Beverage>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface BeverageServingDao {
|
||||||
|
@Query("SELECT * FROM BeverageServing WHERE beverageId = :beverageId")
|
||||||
|
suspend fun getByBeverage(beverageId: Long): List<BeverageServing>
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(serving: BeverageServing): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface BeverageServingNutrientDao {
|
||||||
|
@Query("SELECT * FROM BeverageServingNutrient WHERE servingId = :servingId")
|
||||||
|
suspend fun getByServing(servingId: Long): List<BeverageServingNutrient>
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(nutrient: BeverageServingNutrient): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WaterLogDao {
|
||||||
|
@Query("SELECT * FROM water_logs WHERE date = :date ORDER BY timestamp DESC")
|
||||||
|
suspend fun getWaterLogsForDate(date: LocalDate): List<WaterLogEntity>
|
||||||
|
|
||||||
|
@Query("SELECT SUM(amount) FROM water_logs WHERE date = :date")
|
||||||
|
suspend fun getTotalWaterForDate(date: LocalDate): Int?
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
suspend fun insertWaterLog(waterLog: WaterLogEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteWaterLog(waterLog: WaterLogEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface BeverageLogDao {
|
||||||
|
@Query("SELECT * FROM BeverageLog WHERE ts BETWEEN :from AND :to ORDER BY ts DESC")
|
||||||
|
suspend fun getLogs(from: Instant, to: Instant): List<BeverageLog>
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(log: BeverageLog): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface BeverageLogNutrientDao {
|
||||||
|
@Query("SELECT * FROM BeverageLogNutrient WHERE beverageLogId = :beverageLogId")
|
||||||
|
suspend fun getByLog(beverageLogId: Long): List<BeverageLogNutrient>
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(nutrient: BeverageLogNutrient): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WeightLogDao {
|
||||||
|
@Query("SELECT * FROM WeightLog ORDER BY ts DESC LIMIT 1")
|
||||||
|
suspend fun getLatestWeightKg(): WeightLog?
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(log: WeightLog): Long
|
||||||
|
@Query("SELECT * FROM WeightLog WHERE ts BETWEEN :from AND :to ORDER BY ts DESC")
|
||||||
|
suspend fun getLogs(from: Instant, to: Instant): List<WeightLog>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ExerciseDao {
|
||||||
|
@Query("SELECT * FROM Exercise WHERE id = :id")
|
||||||
|
suspend fun getById(id: Long): Exercise?
|
||||||
|
@Query("SELECT * FROM Exercise WHERE name LIKE :query LIMIT 20")
|
||||||
|
suspend fun search(query: String): List<Exercise>
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(exercise: Exercise): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ExerciseParamDao {
|
||||||
|
@Query("SELECT * FROM ExerciseParam WHERE exerciseId = :exerciseId")
|
||||||
|
suspend fun getByExercise(exerciseId: Long): List<ExerciseParam>
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(param: ExerciseParam): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ExerciseFormulaDao {
|
||||||
|
@Query("SELECT * FROM ExerciseFormula WHERE exerciseId = :exerciseId")
|
||||||
|
suspend fun getByExercise(exerciseId: Long): List<ExerciseFormula>
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(formula: ExerciseFormula): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ExerciseFormulaVarDao {
|
||||||
|
@Query("SELECT * FROM ExerciseFormulaVar WHERE formulaId = :formulaId")
|
||||||
|
suspend fun getByFormula(formulaId: Long): List<ExerciseFormulaVar>
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(varDef: ExerciseFormulaVar): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WorkoutSessionDao {
|
||||||
|
@Query("SELECT * FROM WorkoutSession WHERE startedAt BETWEEN :from AND :to ORDER BY startedAt DESC")
|
||||||
|
suspend fun getSessions(from: Instant, to: Instant): List<WorkoutSession>
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(session: WorkoutSession): Long
|
||||||
|
@Query("SELECT * FROM WorkoutSession WHERE id = :id")
|
||||||
|
suspend fun getById(id: Long): WorkoutSession?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WorkoutSessionParamDao {
|
||||||
|
@Query("SELECT * FROM WorkoutSessionParam WHERE sessionId = :sessionId")
|
||||||
|
suspend fun getBySession(sessionId: Long): List<WorkoutSessionParam>
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(param: WorkoutSessionParam): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WorkoutEventDao {
|
||||||
|
@Query("SELECT * FROM WorkoutEvent WHERE sessionId = :sessionId ORDER BY ts ASC")
|
||||||
|
suspend fun getBySession(sessionId: Long): List<WorkoutEvent>
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(event: WorkoutEvent): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CatalogVersionDao {
|
||||||
|
@Query("SELECT * FROM CatalogVersion WHERE source = :source LIMIT 1")
|
||||||
|
suspend fun getBySource(source: String): CatalogVersion?
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(version: CatalogVersion): Long
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleForecastEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CycleForecastDao {
|
||||||
|
@Query("SELECT * FROM cycle_forecast WHERE id = 1")
|
||||||
|
fun getForecastFlow(): Flow<CycleForecastEntity?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_forecast WHERE id = 1")
|
||||||
|
suspend fun getForecast(): CycleForecastEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(forecast: CycleForecastEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(forecast: CycleForecastEntity)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun insertOrUpdate(forecast: CycleForecastEntity) {
|
||||||
|
val existing = getForecast()
|
||||||
|
if (existing == null) {
|
||||||
|
insert(forecast)
|
||||||
|
} else {
|
||||||
|
update(forecast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("DELETE FROM cycle_forecast")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CycleHistoryDao {
|
||||||
|
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC")
|
||||||
|
fun getAllFlow(): Flow<List<CycleHistoryEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC")
|
||||||
|
suspend fun getAll(): List<CycleHistoryEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_history ORDER BY periodStart DESC LIMIT :limit")
|
||||||
|
suspend fun getRecentCycles(limit: Int): List<CycleHistoryEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_history WHERE atypical = 0 ORDER BY periodStart DESC LIMIT :limit")
|
||||||
|
suspend fun getRecentTypicalCycles(limit: Int): List<CycleHistoryEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_history WHERE id = :id")
|
||||||
|
suspend fun getById(id: Long): CycleHistoryEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_history WHERE periodStart = :date")
|
||||||
|
suspend fun getByStartDate(date: LocalDate): CycleHistoryEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(cycle: CycleHistoryEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(cycle: CycleHistoryEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun delete(cycle: CycleHistoryEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM cycle_history")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_history WHERE periodStart BETWEEN :startDate AND :endDate")
|
||||||
|
suspend fun getCyclesInRange(startDate: LocalDate, endDate: LocalDate): List<CycleHistoryEntity>
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CyclePeriodDao {
|
||||||
|
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC")
|
||||||
|
suspend fun getAll(): List<CyclePeriodEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(period: CyclePeriodEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(period: CyclePeriodEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun delete(period: CyclePeriodEntity)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_periods WHERE startDate = :date LIMIT 1")
|
||||||
|
suspend fun getByStartDate(date: LocalDate): CyclePeriodEntity?
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CycleSettingsDao {
|
||||||
|
@Query("SELECT * FROM cycle_settings WHERE id = 1")
|
||||||
|
fun getSettingsFlow(): Flow<CycleSettingsEntity?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_settings WHERE id = 1")
|
||||||
|
suspend fun getSettings(): CycleSettingsEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(settings: CycleSettingsEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(settings: CycleSettingsEntity)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun insertOrUpdate(settings: CycleSettingsEntity) {
|
||||||
|
val existing = getSettings()
|
||||||
|
if (existing == null) {
|
||||||
|
insert(settings)
|
||||||
|
} else {
|
||||||
|
update(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("DELETE FROM cycle_settings")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
|
||||||
|
@Query("UPDATE cycle_settings SET lastPeriodStart = :date WHERE id = 1")
|
||||||
|
suspend fun updateLastPeriodStart(date: java.time.LocalDate)
|
||||||
|
}
|
||||||
@@ -5,72 +5,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kr.smartsoltech.wellshe.data.entity.*
|
import kr.smartsoltech.wellshe.data.entity.*
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface WaterLogDao {
|
|
||||||
@Query("SELECT * FROM water_logs WHERE date = :date ORDER BY timestamp DESC")
|
|
||||||
fun getWaterLogsForDate(date: LocalDate): Flow<List<WaterLogEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT SUM(amount) FROM water_logs WHERE date = :date")
|
|
||||||
suspend fun getTotalWaterForDate(date: LocalDate): Int?
|
|
||||||
|
|
||||||
@Insert
|
|
||||||
suspend fun insertWaterLog(waterLog: WaterLogEntity)
|
|
||||||
|
|
||||||
@Delete
|
|
||||||
suspend fun deleteWaterLog(waterLog: WaterLogEntity)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM water_logs WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
|
||||||
fun getWaterLogsForPeriod(startDate: LocalDate, endDate: LocalDate): Flow<List<WaterLogEntity>>
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface CyclePeriodDao {
|
|
||||||
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC")
|
|
||||||
fun getAllPeriods(): Flow<List<CyclePeriodEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT 1")
|
|
||||||
suspend fun getLastPeriod(): CyclePeriodEntity?
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT 1")
|
|
||||||
fun getCurrentPeriod(): Flow<CyclePeriodEntity?>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cycle_periods ORDER BY startDate DESC LIMIT :limit")
|
|
||||||
fun getRecentPeriods(limit: Int): Flow<List<CyclePeriodEntity>>
|
|
||||||
|
|
||||||
@Insert
|
|
||||||
suspend fun insertPeriod(period: CyclePeriodEntity)
|
|
||||||
|
|
||||||
@Update
|
|
||||||
suspend fun updatePeriod(period: CyclePeriodEntity)
|
|
||||||
|
|
||||||
@Delete
|
|
||||||
suspend fun deletePeriod(period: CyclePeriodEntity)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cycle_periods WHERE startDate BETWEEN :startDate AND :endDate")
|
|
||||||
fun getPeriodsInRange(startDate: LocalDate, endDate: LocalDate): Flow<List<CyclePeriodEntity>>
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface SleepLogDao {
|
|
||||||
@Query("SELECT * FROM sleep_logs WHERE date = :date")
|
|
||||||
suspend fun getSleepForDate(date: LocalDate): SleepLogEntity?
|
|
||||||
|
|
||||||
@Query("SELECT * FROM sleep_logs ORDER BY date DESC LIMIT 7")
|
|
||||||
fun getRecentSleepLogs(): Flow<List<SleepLogEntity>>
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertSleepLog(sleepLog: SleepLogEntity)
|
|
||||||
|
|
||||||
@Update
|
|
||||||
suspend fun updateSleepLog(sleepLog: SleepLogEntity)
|
|
||||||
|
|
||||||
@Delete
|
|
||||||
suspend fun deleteSleepLog(sleepLog: SleepLogEntity)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM sleep_logs WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
|
||||||
fun getSleepLogsForPeriod(startDate: LocalDate, endDate: LocalDate): Flow<List<SleepLogEntity>>
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface WorkoutDao {
|
interface WorkoutDao {
|
||||||
@Query("SELECT * FROM workouts WHERE date = :date ORDER BY id DESC")
|
@Query("SELECT * FROM workouts WHERE date = :date ORDER BY id DESC")
|
||||||
|
|||||||
@@ -1,36 +1,29 @@
|
|||||||
package kr.smartsoltech.wellshe.data.dao
|
package kr.smartsoltech.wellshe.data.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
|
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface HealthRecordDao {
|
interface HealthRecordDao {
|
||||||
@Query("SELECT * FROM health_records WHERE date = :date")
|
@Query("SELECT * FROM health_records ORDER BY date DESC")
|
||||||
suspend fun getHealthRecordForDate(date: LocalDate): HealthRecordEntity?
|
suspend fun getAll(): List<HealthRecordEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM health_records ORDER BY date DESC LIMIT :limit")
|
|
||||||
fun getRecentHealthRecords(limit: Int = 30): Flow<List<HealthRecordEntity>>
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertHealthRecord(record: HealthRecordEntity)
|
suspend fun insert(record: HealthRecordEntity): Long
|
||||||
|
|
||||||
@Update
|
@Update
|
||||||
suspend fun updateHealthRecord(record: HealthRecordEntity)
|
suspend fun update(record: HealthRecordEntity)
|
||||||
|
|
||||||
@Delete
|
@Delete
|
||||||
suspend fun deleteHealthRecord(record: HealthRecordEntity)
|
suspend fun delete(record: HealthRecordEntity)
|
||||||
|
|
||||||
@Query("DELETE FROM health_records WHERE id = :id")
|
@Query("SELECT * FROM health_records WHERE date = :date LIMIT 1")
|
||||||
suspend fun deleteHealthRecordById(id: Long)
|
suspend fun getByDate(date: LocalDate): HealthRecordEntity?
|
||||||
|
|
||||||
@Query("SELECT * FROM health_records WHERE date BETWEEN :startDate AND :endDate ORDER BY date")
|
@Query("SELECT * FROM health_records ORDER BY date DESC")
|
||||||
suspend fun getHealthRecordsInRange(startDate: LocalDate, endDate: LocalDate): List<HealthRecordEntity>
|
fun getAllFlow(): kotlinx.coroutines.flow.Flow<List<HealthRecordEntity>>
|
||||||
|
|
||||||
@Query("SELECT AVG(weight) FROM health_records WHERE weight IS NOT NULL AND date BETWEEN :startDate AND :endDate")
|
@Query("SELECT * FROM health_records WHERE date = :date LIMIT 1")
|
||||||
suspend fun getAverageWeight(startDate: LocalDate, endDate: LocalDate): Float?
|
fun getByDateFlow(date: LocalDate): kotlinx.coroutines.flow.Flow<HealthRecordEntity?>
|
||||||
|
|
||||||
@Query("SELECT AVG(heartRate) FROM health_records WHERE heartRate IS NOT NULL AND date BETWEEN :startDate AND :endDate")
|
|
||||||
suspend fun getAverageHeartRate(startDate: LocalDate, endDate: LocalDate): Float?
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@Entity(tableName = "Nutrient", indices = [Index("code", unique = true)])
|
||||||
|
data class Nutrient(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val code: String,
|
||||||
|
val name: String,
|
||||||
|
val unit: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "Beverage")
|
||||||
|
data class Beverage(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val name: String,
|
||||||
|
val brand: String?,
|
||||||
|
val category: String,
|
||||||
|
val source: String,
|
||||||
|
val sourceRef: String,
|
||||||
|
val isCaffeinated: Boolean,
|
||||||
|
val isSweetened: Boolean,
|
||||||
|
val createdAt: Instant
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "BeverageServing")
|
||||||
|
data class BeverageServing(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val beverageId: Long,
|
||||||
|
val label: String,
|
||||||
|
val volumeMl: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "BeverageServingNutrient")
|
||||||
|
data class BeverageServingNutrient(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val servingId: Long,
|
||||||
|
val nutrientId: Long,
|
||||||
|
val amountPerServing: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "WaterLog", indices = [Index("ts")])
|
||||||
|
data class WaterLog(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val ts: Instant,
|
||||||
|
val volumeMl: Int,
|
||||||
|
val source: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "BeverageLog", indices = [Index("ts")])
|
||||||
|
data class BeverageLog(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val ts: Instant,
|
||||||
|
val beverageId: Long,
|
||||||
|
val servingId: Long,
|
||||||
|
val servingsCount: Int,
|
||||||
|
val notes: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "BeverageLogNutrient")
|
||||||
|
data class BeverageLogNutrient(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val beverageLogId: Long,
|
||||||
|
val nutrientId: Long,
|
||||||
|
val amountTotal: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "WeightLog", indices = [Index("ts")])
|
||||||
|
data class WeightLog(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val ts: Instant,
|
||||||
|
val weightKg: Float,
|
||||||
|
val source: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "Exercise")
|
||||||
|
data class Exercise(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val name: String,
|
||||||
|
val category: String,
|
||||||
|
val description: String?,
|
||||||
|
val metValue: Float?,
|
||||||
|
val source: String,
|
||||||
|
val sourceRef: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "ExerciseParam")
|
||||||
|
data class ExerciseParam(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val exerciseId: Long,
|
||||||
|
val key: String,
|
||||||
|
val valueType: String,
|
||||||
|
val unit: String?,
|
||||||
|
val required: Boolean,
|
||||||
|
val defaultValue: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "ExerciseFormula")
|
||||||
|
data class ExerciseFormula(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val exerciseId: Long,
|
||||||
|
val name: String,
|
||||||
|
val exprKcal: String,
|
||||||
|
val notes: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "ExerciseFormulaVar")
|
||||||
|
data class ExerciseFormulaVar(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val formulaId: Long,
|
||||||
|
val varKey: String,
|
||||||
|
val required: Boolean,
|
||||||
|
val unit: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "WorkoutSession", indices = [Index("startedAt")])
|
||||||
|
data class WorkoutSession(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val startedAt: Instant,
|
||||||
|
val endedAt: Instant?,
|
||||||
|
val exerciseId: Long,
|
||||||
|
val kcalTotal: Float?,
|
||||||
|
val distanceKm: Float?,
|
||||||
|
val notes: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "WorkoutSessionParam")
|
||||||
|
data class WorkoutSessionParam(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val sessionId: Long,
|
||||||
|
val key: String,
|
||||||
|
val valueNum: Float?,
|
||||||
|
val valueText: String?,
|
||||||
|
val unit: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "WorkoutEvent")
|
||||||
|
data class WorkoutEvent(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val sessionId: Long,
|
||||||
|
val ts: Instant,
|
||||||
|
val eventType: String,
|
||||||
|
val payloadJson: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "CatalogVersion", indices = [Index("source", unique = true)])
|
||||||
|
data class CatalogVersion(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val localVersion: Int,
|
||||||
|
val source: String,
|
||||||
|
val lastSyncAt: Instant
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кэш прогнозов цикла для быстрого доступа в UI.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "cycle_forecast")
|
||||||
|
data class CycleForecastEntity(
|
||||||
|
@PrimaryKey val id: Int = 1, // Singleton
|
||||||
|
val nextPeriodStart: LocalDate? = null,
|
||||||
|
val nextOvulation: LocalDate? = null,
|
||||||
|
val fertileStart: LocalDate? = null,
|
||||||
|
val fertileEnd: LocalDate? = null,
|
||||||
|
val pmsStart: LocalDate? = null,
|
||||||
|
val updatedAt: Instant = Instant.now(),
|
||||||
|
val isReliable: Boolean = true // Flag для пониженной точности при определенных статусах
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* История циклов для расчета прогнозов и анализа.
|
||||||
|
*/
|
||||||
|
@Entity(
|
||||||
|
tableName = "cycle_history",
|
||||||
|
indices = [Index(value = ["periodStart"], unique = true)]
|
||||||
|
)
|
||||||
|
data class CycleHistoryEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val periodStart: LocalDate,
|
||||||
|
val periodEnd: LocalDate? = null,
|
||||||
|
val ovulationDate: LocalDate? = null,
|
||||||
|
val notes: String = "",
|
||||||
|
val atypical: Boolean = false,
|
||||||
|
// Добавляем поля для соответствия с CyclePeriodEntity
|
||||||
|
val flow: String = "",
|
||||||
|
val symptoms: List<String> = emptyList(),
|
||||||
|
val cycleLength: Int? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Entity(tableName = "cycle_periods")
|
||||||
|
data class CyclePeriodEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val startDate: LocalDate,
|
||||||
|
val endDate: LocalDate?,
|
||||||
|
val flow: String = "",
|
||||||
|
val symptoms: List<String> = emptyList(),
|
||||||
|
val cycleLength: Int? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основные настройки для модуля отслеживания менструального цикла.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "cycle_settings")
|
||||||
|
data class CycleSettingsEntity(
|
||||||
|
@PrimaryKey val id: Int = 1, // Singleton
|
||||||
|
|
||||||
|
// Основные параметры цикла
|
||||||
|
val baselineCycleLength: Int = 28,
|
||||||
|
val cycleVariabilityDays: Int = 3,
|
||||||
|
val periodLengthDays: Int = 5,
|
||||||
|
val lutealPhaseDays: String = "auto", // "auto" или число (8-17)
|
||||||
|
val lastPeriodStart: LocalDate? = null,
|
||||||
|
|
||||||
|
// Метод определения овуляции
|
||||||
|
val ovulationMethod: String = "auto", // auto, bbt, lh_test, cervical_mucus, medical
|
||||||
|
val allowManualOvulation: Boolean = false,
|
||||||
|
|
||||||
|
// Статусы влияющие на точность
|
||||||
|
val hormonalContraception: String = "none", // none, coc, iud, implant, other
|
||||||
|
val isPregnant: Boolean = false,
|
||||||
|
val isPostpartum: Boolean = false,
|
||||||
|
val isLactating: Boolean = false,
|
||||||
|
val perimenopause: Boolean = false,
|
||||||
|
|
||||||
|
// Настройки истории и исключения выбросов
|
||||||
|
val historyWindowCycles: Int = 6,
|
||||||
|
val excludeOutliers: Boolean = true,
|
||||||
|
|
||||||
|
// Сенсоры и единицы измерения
|
||||||
|
val tempUnit: String = "C", // C или F
|
||||||
|
val bbtTimeWindow: String = "06:00-10:00",
|
||||||
|
val timezone: String = "Asia/Seoul",
|
||||||
|
|
||||||
|
// Уведомления
|
||||||
|
val periodReminderDaysBefore: Int = 2,
|
||||||
|
val ovulationReminderDaysBefore: Int = 1,
|
||||||
|
val pmsWindowDays: Int = 3,
|
||||||
|
val deviationAlertDays: Int = 5,
|
||||||
|
val fertileWindowMode: String = "balanced" // conservative, balanced, broad
|
||||||
|
)
|
||||||
@@ -13,47 +13,6 @@ data class WaterLogEntity(
|
|||||||
val timestamp: Long = System.currentTimeMillis()
|
val timestamp: Long = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "cycle_periods")
|
|
||||||
data class CyclePeriodEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
|
||||||
val id: Long = 0,
|
|
||||||
val startDate: LocalDate,
|
|
||||||
val endDate: LocalDate?,
|
|
||||||
val cycleLength: Int = 28,
|
|
||||||
val flow: String = "medium", // light, medium, heavy
|
|
||||||
val symptoms: String = "", // JSON строка симптомов
|
|
||||||
val mood: String = "neutral"
|
|
||||||
)
|
|
||||||
|
|
||||||
@Entity(tableName = "sleep_logs")
|
|
||||||
data class SleepLogEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
|
||||||
val id: Long = 0,
|
|
||||||
val date: LocalDate,
|
|
||||||
val bedTime: String, // HH:mm
|
|
||||||
val wakeTime: String, // HH:mm
|
|
||||||
val duration: Float, // часы
|
|
||||||
val quality: String = "good", // poor, fair, good, excellent
|
|
||||||
val notes: String = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
@Entity(tableName = "health_records")
|
|
||||||
data class HealthRecordEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
|
||||||
val id: Long = 0,
|
|
||||||
val date: LocalDate,
|
|
||||||
val weight: Float? = null,
|
|
||||||
val heartRate: Int? = null,
|
|
||||||
val bloodPressureS: Int? = null, // систолическое
|
|
||||||
val bloodPressureD: Int? = null, // диастолическое
|
|
||||||
val temperature: Float? = null,
|
|
||||||
val mood: String = "neutral",
|
|
||||||
val energyLevel: Int = 5, // 1-10
|
|
||||||
val stressLevel: Int = 5, // 1-10
|
|
||||||
val symptoms: String = "", // JSON строка симптомов
|
|
||||||
val notes: String = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
@Entity(tableName = "workouts")
|
@Entity(tableName = "workouts")
|
||||||
data class WorkoutEntity(
|
data class WorkoutEntity(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
@@ -105,5 +64,10 @@ data class UserProfileEntity(
|
|||||||
val cycleLength: Int = 28,
|
val cycleLength: Int = 28,
|
||||||
val periodLength: Int = 5,
|
val periodLength: Int = 5,
|
||||||
val lastPeriodDate: LocalDate? = null,
|
val lastPeriodDate: LocalDate? = null,
|
||||||
val profileImagePath: String = ""
|
val profileImagePath: String = "",
|
||||||
|
val emergency_contact_1_name: String? = null,
|
||||||
|
val emergency_contact_1_phone: String? = null,
|
||||||
|
val emergency_contact_2_name: String? = null,
|
||||||
|
val emergency_contact_2_phone: String? = null,
|
||||||
|
val emergency_notifications_enabled: Boolean? = false
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
|
||||||
|
class HealthRecordConverters {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromSymptomsList(list: List<String>?): String? {
|
||||||
|
return list?.joinToString(separator = "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toSymptomsList(data: String?): List<String>? {
|
||||||
|
return data?.split("|")?.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Entity(tableName = "health_records")
|
||||||
|
data class HealthRecordEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val date: LocalDate,
|
||||||
|
val weight: Float?,
|
||||||
|
val heartRate: Int?,
|
||||||
|
val bloodPressureS: Int?,
|
||||||
|
val bloodPressureD: Int?,
|
||||||
|
val temperature: Float?,
|
||||||
|
val energyLevel: Int?,
|
||||||
|
val symptoms: List<String>?,
|
||||||
|
val notes: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val Context.authDataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_preferences")
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AuthTokenRepository @Inject constructor(
|
||||||
|
private val context: Context
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private val AUTH_TOKEN = stringPreferencesKey("auth_token")
|
||||||
|
private val USER_EMAIL = stringPreferencesKey("user_email")
|
||||||
|
private val USER_PASSWORD = stringPreferencesKey("user_password") // Храним зашифрованный пароль
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение токена авторизации
|
||||||
|
val authToken: Flow<String?> = context.authDataStore.data
|
||||||
|
.map { preferences -> preferences[AUTH_TOKEN] }
|
||||||
|
|
||||||
|
// Получение сохраненного email
|
||||||
|
val savedEmail: Flow<String?> = context.authDataStore.data
|
||||||
|
.map { preferences -> preferences[USER_EMAIL] }
|
||||||
|
|
||||||
|
// Получение сохраненного пароля
|
||||||
|
val savedPassword: Flow<String?> = context.authDataStore.data
|
||||||
|
.map { preferences -> preferences[USER_PASSWORD] }
|
||||||
|
|
||||||
|
// Проверка, есть ли сохраненные данные для автологина
|
||||||
|
val hasAuthData: Flow<Boolean> = context.authDataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
val email = preferences[USER_EMAIL]
|
||||||
|
val password = preferences[USER_PASSWORD]
|
||||||
|
!email.isNullOrEmpty() && !password.isNullOrEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение токена авторизации
|
||||||
|
suspend fun saveAuthToken(token: String) {
|
||||||
|
context.authDataStore.edit { preferences ->
|
||||||
|
preferences[AUTH_TOKEN] = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение учетных данных для автологина
|
||||||
|
suspend fun saveAuthCredentials(email: String, password: String) {
|
||||||
|
context.authDataStore.edit { preferences ->
|
||||||
|
preferences[USER_EMAIL] = email
|
||||||
|
// TODO: здесь должно быть шифрование пароля перед сохранением
|
||||||
|
preferences[USER_PASSWORD] = password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка данных авторизации при выходе
|
||||||
|
suspend fun clearAuthData() {
|
||||||
|
context.authDataStore.edit { preferences ->
|
||||||
|
preferences.remove(AUTH_TOKEN)
|
||||||
|
preferences.remove(USER_EMAIL)
|
||||||
|
preferences.remove(USER_PASSWORD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удалить только auth token (не трогая сохранённые credentials)
|
||||||
|
suspend fun clearAuthToken() {
|
||||||
|
context.authDataStore.edit { preferences ->
|
||||||
|
preferences.remove(AUTH_TOKEN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.model
|
||||||
|
|
||||||
|
data class ServerHealthResponse(
|
||||||
|
val status: String,
|
||||||
|
val timestamp: String? = null,
|
||||||
|
val version: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ServerStatus(
|
||||||
|
val url: String,
|
||||||
|
val isHealthy: Boolean,
|
||||||
|
val pingMs: Long,
|
||||||
|
val status: HealthStatus,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class HealthStatus {
|
||||||
|
EXCELLENT, // < 10ms, зеленый
|
||||||
|
GOOD, // 10-200ms, желтый
|
||||||
|
POOR, // 200-600ms, оранжевый
|
||||||
|
BAD, // 600ms+, красный
|
||||||
|
OFFLINE // недоступен, серый
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Long.toHealthStatus(): HealthStatus {
|
||||||
|
return when {
|
||||||
|
this < 10 -> HealthStatus.EXCELLENT
|
||||||
|
this < 200 -> HealthStatus.GOOD
|
||||||
|
this < 600 -> HealthStatus.POOR
|
||||||
|
else -> HealthStatus.BAD
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.network
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import kr.smartsoltech.wellshe.data.preferences.ServerPreferences
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для настройки и создания API-клиентов
|
||||||
|
*/
|
||||||
|
class ApiClient(private val serverPreferences: ServerPreferences) {
|
||||||
|
private val defaultBaseUrl = "http://192.168.0.112:8000/api/v1/"
|
||||||
|
private val connectTimeout = 15L
|
||||||
|
private val readTimeout = 15L
|
||||||
|
private val writeTimeout = 15L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает экземпляр Retrofit с настройками для работы с API
|
||||||
|
*/
|
||||||
|
private fun createRetrofit(baseUrl: String? = null): Retrofit {
|
||||||
|
val actualBaseUrl = baseUrl ?: serverPreferences.getApiBaseUrl()
|
||||||
|
|
||||||
|
val gson: Gson = GsonBuilder()
|
||||||
|
.setLenient()
|
||||||
|
.create()
|
||||||
|
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl(actualBaseUrl)
|
||||||
|
.client(createOkHttpClient())
|
||||||
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает настроенный OkHttpClient с логированием и таймаутами
|
||||||
|
*/
|
||||||
|
private fun createOkHttpClient(): OkHttpClient {
|
||||||
|
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
}
|
||||||
|
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.addInterceptor(loggingInterceptor)
|
||||||
|
.connectTimeout(connectTimeout, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(readTimeout, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(writeTimeout, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает сервис для работы с авторизацией
|
||||||
|
*/
|
||||||
|
fun createAuthService(): AuthService {
|
||||||
|
return createRetrofit().create(AuthService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает сервис для работы с экстренными оповещениями
|
||||||
|
*/
|
||||||
|
fun createEmergencyService(): EmergencyService {
|
||||||
|
return createRetrofit().create(EmergencyService::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.network
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перехватчик, добавляющий токен авторизации в заголовки запросов
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AuthInterceptor @Inject constructor(
|
||||||
|
private val authTokenRepository: AuthTokenRepository
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
|
||||||
|
// Пробуем получить токен авторизации (в блокирующем режиме, т.к. Interceptor не поддерживает suspend функции)
|
||||||
|
val token = runBlocking { authTokenRepository.authToken.firstOrNull() }
|
||||||
|
|
||||||
|
// Если токен есть, добавляем его в заголовок запроса
|
||||||
|
val modifiedRequest = if (!token.isNullOrEmpty()) {
|
||||||
|
originalRequest.newBuilder()
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
originalRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain.proceed(modifiedRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.network
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.model.auth.*
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Header
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс для работы с API авторизации
|
||||||
|
*/
|
||||||
|
interface AuthService {
|
||||||
|
/**
|
||||||
|
* Регистрация нового пользователя
|
||||||
|
*/
|
||||||
|
@POST("auth/register")
|
||||||
|
suspend fun register(@Body request: RegisterRequest): Response<RegisterResponseWrapper>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вход в систему
|
||||||
|
*/
|
||||||
|
@POST("auth/login")
|
||||||
|
suspend fun login(@Body request: AuthRequest): Response<DirectAuthResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление токена
|
||||||
|
*/
|
||||||
|
@POST("auth/refresh")
|
||||||
|
suspend fun refreshToken(@Body request: TokenRefreshRequest): Response<TokenRefreshResponseWrapper>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выход из системы
|
||||||
|
*/
|
||||||
|
@POST("auth/logout")
|
||||||
|
suspend fun logout(@Header("Authorization") token: String): Response<BaseResponseWrapper>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение профиля текущего пользователя
|
||||||
|
*/
|
||||||
|
@GET("users/me")
|
||||||
|
suspend fun getProfile(@Header("Authorization") token: String): Response<UserProfileResponseWrapper>
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.network
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.model.auth.BaseResponseWrapper
|
||||||
|
import kr.smartsoltech.wellshe.model.emergency.*
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс для работы с API экстренных оповещений
|
||||||
|
*/
|
||||||
|
interface EmergencyService {
|
||||||
|
/**
|
||||||
|
* Создание нового экстренного оповещения
|
||||||
|
*/
|
||||||
|
@POST("emergency/alert")
|
||||||
|
suspend fun createAlert(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Body request: EmergencyAlertRequest
|
||||||
|
): Response<EmergencyAlertResponseWrapper>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение информации о статусе экстренного оповещения
|
||||||
|
*/
|
||||||
|
@GET("emergency/alert/{alert_id}")
|
||||||
|
suspend fun getAlertStatus(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: String
|
||||||
|
): Response<EmergencyAlertStatusWrapper>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление местоположения для активного оповещения
|
||||||
|
*/
|
||||||
|
@PUT("emergency/alert/{alert_id}/location")
|
||||||
|
suspend fun updateLocation(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: String,
|
||||||
|
@Body request: LocationUpdateRequest
|
||||||
|
): Response<LocationUpdateResponseWrapper>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отмена активного экстренного оповещения
|
||||||
|
*/
|
||||||
|
@POST("emergency/alert/{alert_id}/cancel")
|
||||||
|
suspend fun cancelAlert(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: String,
|
||||||
|
@Body request: AlertCancelRequest
|
||||||
|
): Response<AlertCancelResponseWrapper>
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.network
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class RetrofitFactory @Inject constructor(
|
||||||
|
private val gson: Gson,
|
||||||
|
private val authTokenRepository: AuthTokenRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun create(baseUrl: String): Retrofit {
|
||||||
|
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BASIC
|
||||||
|
}
|
||||||
|
|
||||||
|
val authInterceptor = AuthInterceptor(authTokenRepository)
|
||||||
|
|
||||||
|
val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor(loggingInterceptor)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.network
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||||
|
import kr.smartsoltech.wellshe.data.preferences.ServerPreferences
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ActivityRetainedScoped
|
||||||
|
class RetrofitProvider @Inject constructor(
|
||||||
|
private val serverPreferences: ServerPreferences,
|
||||||
|
private val retrofitFactory: RetrofitFactory
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "RetrofitProvider"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentServerUrl: String? = null
|
||||||
|
private var currentRetrofit: Retrofit? = null
|
||||||
|
|
||||||
|
fun getRetrofit(): Retrofit {
|
||||||
|
val serverUrl = serverPreferences.getApiBaseUrl()
|
||||||
|
Log.d(TAG, "Getting Retrofit for serverUrl: $serverUrl")
|
||||||
|
|
||||||
|
if (currentRetrofit == null || currentServerUrl != serverUrl) {
|
||||||
|
Log.d(TAG, "Creating new Retrofit instance. Old URL: $currentServerUrl, New URL: $serverUrl")
|
||||||
|
currentServerUrl = serverUrl
|
||||||
|
currentRetrofit = retrofitFactory.create(serverUrl)
|
||||||
|
Log.d(TAG, "Retrofit instance created successfully with baseUrl: $serverUrl")
|
||||||
|
|
||||||
|
// Показываем настройки для отладки
|
||||||
|
serverPreferences.debugSettings()
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Reusing existing Retrofit instance with baseUrl: $serverUrl")
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentRetrofit!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recreateRetrofit() {
|
||||||
|
Log.d(TAG, "Forcing Retrofit recreation. Current URL: $currentServerUrl")
|
||||||
|
currentRetrofit = null
|
||||||
|
currentServerUrl = null
|
||||||
|
Log.d(TAG, "Retrofit instance cleared, will be recreated on next access")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.preferences
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ServerPreferences @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private val sharedPreferences: SharedPreferences =
|
||||||
|
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ServerPreferences"
|
||||||
|
private const val PREF_NAME = "server_preferences"
|
||||||
|
private const val KEY_SERVER_URL = "server_url"
|
||||||
|
// Используем локальный IP для разработки - можно легко изменить через UI
|
||||||
|
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:8000"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getServerUrl(): String {
|
||||||
|
val url = sharedPreferences.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
|
||||||
|
Log.d(TAG, "Getting server URL: $url")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getApiBaseUrl(): String {
|
||||||
|
val serverUrl = getServerUrl()
|
||||||
|
val apiUrl = if (serverUrl.endsWith("/")) {
|
||||||
|
"${serverUrl}api/v1/"
|
||||||
|
} else {
|
||||||
|
"$serverUrl/api/v1/"
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Getting API base URL: $apiUrl")
|
||||||
|
return apiUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setServerUrl(url: String) {
|
||||||
|
Log.d(TAG, "Setting server URL: $url")
|
||||||
|
val success = sharedPreferences.edit()
|
||||||
|
.putString(KEY_SERVER_URL, url)
|
||||||
|
.commit() // Используем commit() вместо apply() для синхронного сохранения
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Log.d(TAG, "Server URL saved successfully: $url")
|
||||||
|
// Проверяем, что значение действительно сохранилось
|
||||||
|
val savedUrl = sharedPreferences.getString(KEY_SERVER_URL, "NOT_FOUND")
|
||||||
|
Log.d(TAG, "Verification - saved URL: $savedUrl")
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Failed to save server URL: $url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метод для получения предложенных серверов
|
||||||
|
fun getSuggestedServers(): List<String> {
|
||||||
|
return listOf(
|
||||||
|
"http://10.0.2.2:8000", // Android Emulator localhost
|
||||||
|
"http://192.168.0.112:8000", // Локальная сеть
|
||||||
|
"http://localhost:8000", // Localhost
|
||||||
|
"https://api.wellshe.example.com" // Пример продакшн сервера
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метод для отладки - показывает все сохраненные настройки
|
||||||
|
fun debugSettings() {
|
||||||
|
Log.d(TAG, "=== Debug Server Settings ===")
|
||||||
|
Log.d(TAG, "Preferences file: $PREF_NAME")
|
||||||
|
Log.d(TAG, "Current server URL: ${getServerUrl()}")
|
||||||
|
Log.d(TAG, "Default server URL: $DEFAULT_SERVER_URL")
|
||||||
|
Log.d(TAG, "All preferences: ${sharedPreferences.all}")
|
||||||
|
Log.d(TAG, "===============================")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repo
|
||||||
|
|
||||||
|
// Устаревший репозиторий, используйте kr.smartsoltech.wellshe.data.repository.AuthRepository вместо этого
|
||||||
|
@Deprecated("Используйте kr.smartsoltech.wellshe.data.repository.AuthRepository вместо этого")
|
||||||
|
typealias AuthRepository = kr.smartsoltech.wellshe.data.repository.AuthRepository
|
||||||
119
app/src/main/java/kr/smartsoltech/wellshe/data/repo/BodyRepo.kt
Normal file
119
app/src/main/java/kr/smartsoltech/wellshe/data/repo/BodyRepo.kt
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repo
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.*
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.*
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
class BeverageCatalogRepository(
|
||||||
|
private val beverageDao: BeverageDao,
|
||||||
|
private val servingDao: BeverageServingDao,
|
||||||
|
private val servingNutrientDao: BeverageServingNutrientDao
|
||||||
|
) {
|
||||||
|
suspend fun search(query: String): List<Beverage> = beverageDao.search("%$query%")
|
||||||
|
suspend fun getServings(beverageId: Long): List<BeverageServing> = servingDao.getByBeverage(beverageId)
|
||||||
|
suspend fun getNutrients(servingId: Long): List<BeverageServingNutrient> = servingNutrientDao.getByServing(servingId)
|
||||||
|
// Методы syncFromUsda(), syncFromOpenFoodFacts() будут реализованы отдельно
|
||||||
|
}
|
||||||
|
|
||||||
|
class DrinkLogger(
|
||||||
|
private val waterLogDao: WaterLogDao,
|
||||||
|
private val beverageLogDao: BeverageLogDao,
|
||||||
|
private val beverageLogNutrientDao: BeverageLogNutrientDao,
|
||||||
|
private val servingNutrientDao: BeverageServingNutrientDao
|
||||||
|
) {
|
||||||
|
suspend fun logWater(ts: Instant, volumeMl: Int, source: String = "manual") {
|
||||||
|
waterLogDao.insertWaterLog(WaterLogEntity(date = ts.atZone(java.time.ZoneId.systemDefault()).toLocalDate(), amount = volumeMl, timestamp = ts.toEpochMilli()))
|
||||||
|
}
|
||||||
|
suspend fun logBeverage(ts: Instant, beverageId: Long, servingId: Long, servingsCount: Int, notes: String? = null) {
|
||||||
|
val logId = beverageLogDao.insert(BeverageLog(ts = ts, beverageId = beverageId, servingId = servingId, servingsCount = servingsCount, notes = notes))
|
||||||
|
val nutrients = servingNutrientDao.getByServing(servingId)
|
||||||
|
nutrients.forEach { n ->
|
||||||
|
beverageLogNutrientDao.insert(
|
||||||
|
BeverageLogNutrient(
|
||||||
|
beverageLogId = logId,
|
||||||
|
nutrientId = n.nutrientId,
|
||||||
|
amountTotal = n.amountPerServing * servingsCount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suspend fun getWaterHistory(days: Int): List<Int> {
|
||||||
|
val today = java.time.LocalDate.now()
|
||||||
|
return (0 until days).map { offset ->
|
||||||
|
val date = today.minusDays(offset.toLong())
|
||||||
|
waterLogDao.getTotalWaterForDate(date) ?: 0
|
||||||
|
}.reversed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WeightRepository(private val weightLogDao: WeightLogDao) {
|
||||||
|
suspend fun addWeight(ts: Instant, kg: Float, source: String = "manual") {
|
||||||
|
weightLogDao.insert(WeightLog(ts = ts, weightKg = kg, source = source))
|
||||||
|
}
|
||||||
|
suspend fun getLatestWeightKg(): Float? = weightLogDao.getLatestWeightKg()?.weightKg
|
||||||
|
suspend fun getWeightHistory(days: Int): List<Pair<String, Float>> {
|
||||||
|
val today = java.time.LocalDate.now()
|
||||||
|
return (0 until days).map { offset ->
|
||||||
|
val date = today.minusDays(offset.toLong())
|
||||||
|
val logs = weightLogDao.getLogs(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant(), date.plusDays(1).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant())
|
||||||
|
val weight = logs.firstOrNull()?.weightKg ?: 0f
|
||||||
|
date.toString() to weight
|
||||||
|
}.reversed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExerciseCatalogRepository(
|
||||||
|
private val exerciseDao: ExerciseDao,
|
||||||
|
private val paramDao: ExerciseParamDao,
|
||||||
|
private val formulaDao: ExerciseFormulaDao
|
||||||
|
) {
|
||||||
|
suspend fun searchExercises(query: String): List<Exercise> = exerciseDao.search("%$query%")
|
||||||
|
suspend fun getParams(exerciseId: Long): List<ExerciseParam> = paramDao.getByExercise(exerciseId)
|
||||||
|
suspend fun getFormulas(exerciseId: Long): List<ExerciseFormula> = formulaDao.getByExercise(exerciseId)
|
||||||
|
// Методы syncFromWger(), syncMetTables() будут реализованы отдельно
|
||||||
|
}
|
||||||
|
|
||||||
|
class WorkoutService(
|
||||||
|
private val sessionDao: WorkoutSessionDao,
|
||||||
|
private val paramDao: WorkoutSessionParamDao,
|
||||||
|
private val eventDao: WorkoutEventDao,
|
||||||
|
private val weightRepo: WeightRepository,
|
||||||
|
private val formulaDao: ExerciseFormulaDao,
|
||||||
|
private val formulaVarDao: ExerciseFormulaVarDao,
|
||||||
|
private val exerciseDao: ExerciseDao
|
||||||
|
) {
|
||||||
|
suspend fun searchExercises(query: String): List<Exercise> = exerciseDao.search("%$query%")
|
||||||
|
suspend fun getSessions(days: Int): List<WorkoutSession> {
|
||||||
|
val now = java.time.Instant.now()
|
||||||
|
val start = now.minusSeconds(days * 24 * 3600L)
|
||||||
|
return sessionDao.getSessions(start, now)
|
||||||
|
}
|
||||||
|
suspend fun stopSession(sessionId: Long) {
|
||||||
|
val session = sessionDao.getById(sessionId)
|
||||||
|
if (session != null && session.endedAt == null) {
|
||||||
|
sessionDao.insert(session.copy(endedAt = java.time.Instant.now()))
|
||||||
|
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = java.time.Instant.now(), eventType = "stop", payloadJson = "{}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suspend fun startSession(exerciseId: Long): Long {
|
||||||
|
val baseWeightKg = weightRepo.getLatestWeightKg() ?: 70f
|
||||||
|
val sessionId = sessionDao.insert(
|
||||||
|
WorkoutSession(
|
||||||
|
startedAt = Instant.now(),
|
||||||
|
endedAt = null,
|
||||||
|
exerciseId = exerciseId,
|
||||||
|
kcalTotal = null,
|
||||||
|
distanceKm = null,
|
||||||
|
notes = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
paramDao.insert(WorkoutSessionParam(sessionId = sessionId, key = "baseWeightKg", valueNum = baseWeightKg, valueText = null, unit = "kg"))
|
||||||
|
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = Instant.now(), eventType = "start", payloadJson = "{}"))
|
||||||
|
return sessionId
|
||||||
|
}
|
||||||
|
suspend fun updateParam(sessionId: Long, key: String, valueNum: Float?, valueText: String?, unit: String?) {
|
||||||
|
paramDao.insert(WorkoutSessionParam(sessionId = sessionId, key = key, valueNum = valueNum, valueText = valueText, unit = unit))
|
||||||
|
eventDao.insert(WorkoutEvent(sessionId = sessionId, ts = Instant.now(), eventType = "param_change", payloadJson = "{\"key\":\"$key\"}"))
|
||||||
|
}
|
||||||
|
// tick(sessionId) и stopSession(sessionId) будут реализованы с расчетом калорий по формуле
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repository
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.network.AuthService
|
||||||
|
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||||
|
import kr.smartsoltech.wellshe.model.auth.*
|
||||||
|
import kr.smartsoltech.wellshe.util.Result
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий для работы с авторизацией и профилем пользователя
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AuthRepository @Inject constructor(
|
||||||
|
private val authService: AuthService,
|
||||||
|
private val authTokenRepository: AuthTokenRepository,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вход в систему
|
||||||
|
*/
|
||||||
|
suspend fun login(identifier: String, password: String, isEmail: Boolean): Result<AuthTokenResponse> {
|
||||||
|
return try {
|
||||||
|
// Если имя пользователя - galya0815, преобразуем его в Galya0815 с большой буквы
|
||||||
|
val correctedIdentifier = if (!isEmail && identifier.equals("galya0815", ignoreCase = true)) {
|
||||||
|
"Galya0815"
|
||||||
|
} else {
|
||||||
|
identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
val authRequest = if (isEmail) {
|
||||||
|
AuthRequest(email = correctedIdentifier, password = password)
|
||||||
|
} else {
|
||||||
|
AuthRequest(username = correctedIdentifier, password = password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем реальный API-метод login
|
||||||
|
val response = authService.login(authRequest)
|
||||||
|
|
||||||
|
// Логирование для отладки
|
||||||
|
android.util.Log.d("AuthRepository", "Login response: ${response.code()}, isSuccessful: ${response.isSuccessful}")
|
||||||
|
if (response.body() != null) {
|
||||||
|
android.util.Log.d("AuthRepository", "Response body: ${response.body()}")
|
||||||
|
} else if (response.errorBody() != null) {
|
||||||
|
android.util.Log.d("AuthRepository", "Error body: ${response.errorBody()?.string()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val directAuthResponse = response.body()
|
||||||
|
|
||||||
|
// Если ответ успешен, но не содержит ожидаемых данных
|
||||||
|
if (directAuthResponse == null) {
|
||||||
|
return Result.Error(Exception("Получен пустой ответ от сервера"))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Создаем объект AuthTokenResponse из DirectAuthResponse
|
||||||
|
val authTokenResponse = AuthTokenResponse(
|
||||||
|
accessToken = directAuthResponse.accessToken,
|
||||||
|
tokenType = directAuthResponse.tokenType,
|
||||||
|
refreshToken = "", // Может отсутствовать в ответе сервера
|
||||||
|
expiresIn = 0 // Может отсутствовать в ответе сервера
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сохраняем токен в локальное хранилище
|
||||||
|
tokenManager.saveAccessToken(authTokenResponse.accessToken)
|
||||||
|
tokenManager.saveTokenType(authTokenResponse.tokenType)
|
||||||
|
|
||||||
|
android.util.Log.d("AuthRepository", "Login successful, token: ${authTokenResponse.accessToken.take(15)}...")
|
||||||
|
Result.Success(authTokenResponse)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("AuthRepository", "Error processing auth response: ${e.message}", e)
|
||||||
|
Result.Error(Exception("Ошибка обработки ответа авторизации: ${e.message}"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка авторизации"
|
||||||
|
android.util.Log.e("AuthRepository", "Login error: $errorMessage (code ${response.code()})")
|
||||||
|
Result.Error(Exception("Ошибка авторизации: $errorMessage (код ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("AuthRepository", "Exception during login: ${e.message}", e)
|
||||||
|
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регистрация нового пользователя
|
||||||
|
*/
|
||||||
|
suspend fun register(
|
||||||
|
email: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
phone: String
|
||||||
|
): Result<Boolean> {
|
||||||
|
return try {
|
||||||
|
val registerRequest = RegisterRequest(
|
||||||
|
email = email,
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
first_name = firstName,
|
||||||
|
last_name = lastName,
|
||||||
|
phone = phone
|
||||||
|
)
|
||||||
|
|
||||||
|
// Вызываем реальный API-метод register
|
||||||
|
val response = authService.register(registerRequest)
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
Result.Success(true)
|
||||||
|
} else {
|
||||||
|
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка регистрации"
|
||||||
|
Result.Error(Exception("Ошибка регистрации: $errorMessage (код ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выход из системы
|
||||||
|
*/
|
||||||
|
suspend fun logout(accessToken: String): Result<Boolean> {
|
||||||
|
return try {
|
||||||
|
// Формируем заголовок авторизации
|
||||||
|
val authHeader = "Bearer $accessToken"
|
||||||
|
|
||||||
|
// Вызываем реальный API-метод logout
|
||||||
|
val response = authService.logout(authHeader)
|
||||||
|
|
||||||
|
// Независимо от результата запроса очищаем локальные данные авторизации
|
||||||
|
authTokenRepository.clearAuthData()
|
||||||
|
tokenManager.clearTokens()
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
Result.Success(true)
|
||||||
|
} else {
|
||||||
|
// Даже при ошибке API считаем выход успешным, так как локальные данные очищены
|
||||||
|
Result.Success(true)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Даже при исключении считаем выход успешным, так как локальные данные очищены
|
||||||
|
Result.Success(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление токена доступа
|
||||||
|
*/
|
||||||
|
suspend fun refreshToken(refreshToken: String): Result<TokenResponse> {
|
||||||
|
return try {
|
||||||
|
// Создаем запрос на обновление токена
|
||||||
|
val tokenRefreshRequest = TokenRefreshRequest(refresh_token = refreshToken)
|
||||||
|
|
||||||
|
// Вызываем реальный API-метод refreshToken
|
||||||
|
val response = authService.refreshToken(tokenRefreshRequest)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val tokenResponse = response.body()?.data
|
||||||
|
if (tokenResponse != null) {
|
||||||
|
Result.Success(tokenResponse)
|
||||||
|
} else {
|
||||||
|
Result.Error(Exception("Ответ сервера не содержит данных обновления токена"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка обновления токена"
|
||||||
|
Result.Error(Exception("Ошибка обновления токена: $errorMessage (код ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение профиля пользователя
|
||||||
|
*/
|
||||||
|
suspend fun getUserProfile(accessToken: String): Result<UserProfile> {
|
||||||
|
return try {
|
||||||
|
// Формируем заголовок авторизации
|
||||||
|
val authHeader = "Bearer $accessToken"
|
||||||
|
|
||||||
|
// Вызываем реальный API-метод получения профиля
|
||||||
|
val response = authService.getProfile(authHeader)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val userProfile = response.body()?.data
|
||||||
|
if (userProfile != null) {
|
||||||
|
Result.Success(userProfile)
|
||||||
|
} else {
|
||||||
|
Result.Error(Exception("Ответ сервера не содержит данных профиля"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка получения профиля"
|
||||||
|
Result.Error(Exception("Ошибка получения профиля: $errorMessage (код ${response.code()})"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.CycleForecastDao
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.CycleHistoryDao
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.CycleSettingsDao
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleForecastEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleHistoryEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.CycleForecast
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.CycleSettings
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий для работы с данными цикла, настройками и прогнозами.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class CycleRepository @Inject constructor(
|
||||||
|
private val settingsDao: CycleSettingsDao,
|
||||||
|
private val historyDao: CycleHistoryDao,
|
||||||
|
private val forecastDao: CycleForecastDao
|
||||||
|
) {
|
||||||
|
// Настройки цикла
|
||||||
|
fun getSettingsFlow(): Flow<CycleSettingsEntity?> = settingsDao.getSettingsFlow()
|
||||||
|
|
||||||
|
suspend fun getSettings(): CycleSettingsEntity? = settingsDao.getSettings()
|
||||||
|
|
||||||
|
suspend fun saveSettings(settings: CycleSettingsEntity) {
|
||||||
|
settingsDao.insertOrUpdate(settings)
|
||||||
|
recalculateForecasts() // Пересчитываем прогнозы при изменении настроек
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateLastPeriodStart(date: LocalDate) {
|
||||||
|
val settings = settingsDao.getSettings() ?: createDefaultSettings()
|
||||||
|
settingsDao.insertOrUpdate(settings.copy(lastPeriodStart = date))
|
||||||
|
recalculateForecasts()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createDefaultSettings(): CycleSettingsEntity {
|
||||||
|
return CycleSettingsEntity()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resetToDefaults() {
|
||||||
|
settingsDao.insertOrUpdate(CycleSettingsEntity())
|
||||||
|
recalculateForecasts()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет настройки цикла
|
||||||
|
*/
|
||||||
|
suspend fun updateSettings(settings: CycleSettingsEntity) {
|
||||||
|
settingsDao.insertOrUpdate(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// История циклов
|
||||||
|
fun getAllHistoryFlow(): Flow<List<CycleHistoryEntity>> = historyDao.getAllFlow()
|
||||||
|
|
||||||
|
suspend fun getAllHistory(): List<CycleHistoryEntity> = historyDao.getAll()
|
||||||
|
|
||||||
|
suspend fun getRecentCycles(limit: Int): List<CycleHistoryEntity> = historyDao.getRecentCycles(limit)
|
||||||
|
|
||||||
|
suspend fun getRecentTypicalCycles(limit: Int): List<CycleHistoryEntity> =
|
||||||
|
historyDao.getRecentTypicalCycles(limit)
|
||||||
|
|
||||||
|
suspend fun addCycleToHistory(cycle: CycleHistoryEntity): Long {
|
||||||
|
val id = historyDao.insert(cycle)
|
||||||
|
recalculateForecasts()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateCycleInHistory(cycle: CycleHistoryEntity) {
|
||||||
|
historyDao.update(cycle)
|
||||||
|
recalculateForecasts()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteCycleFromHistory(cycle: CycleHistoryEntity) {
|
||||||
|
historyDao.delete(cycle)
|
||||||
|
recalculateForecasts()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markCycleAsAtypical(id: Long, atypical: Boolean) {
|
||||||
|
historyDao.getById(id)?.let { cycle ->
|
||||||
|
historyDao.update(cycle.copy(atypical = atypical))
|
||||||
|
recalculateForecasts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прогнозы
|
||||||
|
fun getForecastFlow(): Flow<CycleForecastEntity?> = forecastDao.getForecastFlow()
|
||||||
|
|
||||||
|
suspend fun getForecast(): CycleForecastEntity? = forecastDao.getForecast()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пересчитывает прогнозы на основе текущих настроек и истории циклов.
|
||||||
|
* Вызывается автоматически при изменении настроек или истории.
|
||||||
|
*/
|
||||||
|
suspend fun recalculateForecasts() {
|
||||||
|
val settings = settingsDao.getSettings() ?: return
|
||||||
|
val history = if (settings.excludeOutliers) {
|
||||||
|
historyDao.getRecentTypicalCycles(settings.historyWindowCycles)
|
||||||
|
} else {
|
||||||
|
historyDao.getRecentCycles(settings.historyWindowCycles)
|
||||||
|
}
|
||||||
|
|
||||||
|
val forecast = calculateForecast(settings, history)
|
||||||
|
forecastDao.insertOrUpdate(forecast)
|
||||||
|
|
||||||
|
// Здесь также можно вызвать планирование уведомлений на основе новых прогнозов
|
||||||
|
scheduleNotifications(forecast)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расчет прогноза цикла на основе настроек и истории.
|
||||||
|
*/
|
||||||
|
private fun calculateForecast(
|
||||||
|
settings: CycleSettingsEntity,
|
||||||
|
history: List<CycleHistoryEntity>
|
||||||
|
): CycleForecastEntity {
|
||||||
|
// Определяем надежность прогноза
|
||||||
|
val isReliable = !(settings.isPregnant || settings.isPostpartum ||
|
||||||
|
settings.isLactating || settings.perimenopause ||
|
||||||
|
settings.hormonalContraception != "none")
|
||||||
|
|
||||||
|
// Если нет истории и нет даты последней менструации, не можем сделать прогноз
|
||||||
|
if (history.isEmpty() && settings.lastPeriodStart == null) {
|
||||||
|
return CycleForecastEntity(
|
||||||
|
isReliable = isReliable,
|
||||||
|
updatedAt = Instant.now()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим дату последней менструации
|
||||||
|
val lastPeriodStart = settings.lastPeriodStart ?: history.firstOrNull()?.periodStart
|
||||||
|
|
||||||
|
if (lastPeriodStart == null) {
|
||||||
|
return CycleForecastEntity(
|
||||||
|
isReliable = isReliable,
|
||||||
|
updatedAt = Instant.now()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рассчитываем средний цикл на основе истории или используем базовые настройки
|
||||||
|
val cycleLength = if (history.size >= 2) {
|
||||||
|
calculateAverageCycleLength(history)
|
||||||
|
} else {
|
||||||
|
settings.baselineCycleLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рассчитываем л<><D0BB>теиновую фазу
|
||||||
|
val lutealPhase = if (settings.lutealPhaseDays == "auto") {
|
||||||
|
14 // Стандартная длина лютеиновой фазы
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
settings.lutealPhaseDays.toInt()
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рассчитываем даты
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val daysSinceLastPeriod = today.toEpochDay() - lastPeriodStart.toEpochDay()
|
||||||
|
val nextPeriodStart = if (daysSinceLastPeriod >= cycleLength) {
|
||||||
|
lastPeriodStart.plusDays(cycleLength.toLong() * (daysSinceLastPeriod / cycleLength + 1))
|
||||||
|
} else {
|
||||||
|
lastPeriodStart.plusDays(cycleLength.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
val ovulationDate = nextPeriodStart.minusDays(lutealPhase.toLong())
|
||||||
|
|
||||||
|
// Рассчитываем фертильное окно в зависимости от режима
|
||||||
|
val fertileWindowStart = when (settings.fertileWindowMode) {
|
||||||
|
"conservative" -> ovulationDate.minusDays(3)
|
||||||
|
"balanced" -> ovulationDate.minusDays(5)
|
||||||
|
"broad" -> ovulationDate.minusDays(7)
|
||||||
|
else -> ovulationDate.minusDays(5) // По умолчанию сбалансированный режим
|
||||||
|
}
|
||||||
|
|
||||||
|
val fertileWindowEnd = ovulationDate // День овуляции - последний фертильный день
|
||||||
|
|
||||||
|
// Рассчитываем начало ПМС
|
||||||
|
val pmsStart = nextPeriodStart.minusDays(settings.pmsWindowDays.toLong())
|
||||||
|
|
||||||
|
return CycleForecastEntity(
|
||||||
|
nextPeriodStart = nextPeriodStart,
|
||||||
|
nextOvulation = ovulationDate,
|
||||||
|
fertileStart = fertileWindowStart,
|
||||||
|
fertileEnd = fertileWindowEnd,
|
||||||
|
pmsStart = pmsStart,
|
||||||
|
isReliable = isReliable,
|
||||||
|
updatedAt = Instant.now()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рассчитывает среднюю длину цикла на основе истории.
|
||||||
|
*/
|
||||||
|
private fun calculateAverageCycleLength(history: List<CycleHistoryEntity>): Int {
|
||||||
|
if (history.size < 2) return 28 // Стандартный цикл если недостаточно данных
|
||||||
|
|
||||||
|
// Сортируем по дате начала
|
||||||
|
val sortedHistory = history.sortedBy { it.periodStart }
|
||||||
|
|
||||||
|
// Рассчитываем <20><>лину между началом каждого цикла
|
||||||
|
val cycleLengths = mutableListOf<Int>()
|
||||||
|
for (i in 0 until sortedHistory.size - 1) {
|
||||||
|
val current = sortedHistory[i]
|
||||||
|
val next = sortedHistory[i + 1]
|
||||||
|
val length = (next.periodStart.toEpochDay() - current.periodStart.toEpochDay()).toInt()
|
||||||
|
|
||||||
|
// Исключаем выбросы (слишком короткие или длинные циклы)
|
||||||
|
if (length >= 18 && length <= 60) {
|
||||||
|
cycleLengths.add(length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если после фильтрации нет данных, возвращаем стандартный цикл
|
||||||
|
if (cycleLengths.isEmpty()) return 28
|
||||||
|
|
||||||
|
// Возвращаем среднюю длину цикла, округленную до целого
|
||||||
|
return cycleLengths.average().toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Планирует уведомления на основе рассчитанных прогнозов.
|
||||||
|
*/
|
||||||
|
private suspend fun scheduleNotifications(forecast: CycleForecastEntity) {
|
||||||
|
// Это заглушка для метода планирования уведомлений
|
||||||
|
// Реальная реализация будет добавлена позже в классе NotificationManager
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экспортирует настройки цикла в JSON строку.
|
||||||
|
*/
|
||||||
|
suspend fun exportSettingsToJson(): String {
|
||||||
|
// В реальной реализации здесь будет использоваться библиотека для сериализации в JSON
|
||||||
|
// Например, Gson или Moshi
|
||||||
|
return "{}" // Заглушка
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Импортирует настройки цикла из JSON строки.
|
||||||
|
*/
|
||||||
|
suspend fun importSettingsFromJson(json: String): Boolean {
|
||||||
|
// В реальной реализации здесь будет использоваться библиотека для десериализации из JSON
|
||||||
|
return true // Заглушка
|
||||||
|
}
|
||||||
|
|
||||||
|
// Методы для работы с периодами (CyclePeriodEntity)
|
||||||
|
suspend fun getAllPeriods(): List<CyclePeriodEntity> {
|
||||||
|
// Получаем все периоды из истории и преобразуем их в CyclePeriodEntity
|
||||||
|
val history = historyDao.getAll()
|
||||||
|
return history.map { historyEntity ->
|
||||||
|
CyclePeriodEntity(
|
||||||
|
id = historyEntity.id,
|
||||||
|
startDate = historyEntity.periodStart,
|
||||||
|
endDate = historyEntity.periodEnd,
|
||||||
|
flow = historyEntity.flow,
|
||||||
|
symptoms = historyEntity.symptoms,
|
||||||
|
cycleLength = historyEntity.cycleLength
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun insertPeriod(period: CyclePeriodEntity): Long {
|
||||||
|
// Преобразуем CyclePeriodEntity в CycleHistoryEntity для сохранения
|
||||||
|
val historyEntity = CycleHistoryEntity(
|
||||||
|
id = period.id,
|
||||||
|
periodStart = period.startDate,
|
||||||
|
periodEnd = period.endDate,
|
||||||
|
flow = period.flow,
|
||||||
|
symptoms = period.symptoms,
|
||||||
|
cycleLength = period.cycleLength,
|
||||||
|
atypical = false // по умолчанию не отмечаем как нетипичный
|
||||||
|
)
|
||||||
|
return addCycleToHistory(historyEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updatePeriod(period: CyclePeriodEntity) {
|
||||||
|
// Преобразуем CyclePeriodEntity в CycleHistoryEntity для обновления
|
||||||
|
val historyEntity = CycleHistoryEntity(
|
||||||
|
id = period.id,
|
||||||
|
periodStart = period.startDate,
|
||||||
|
periodEnd = period.endDate,
|
||||||
|
flow = period.flow,
|
||||||
|
symptoms = period.symptoms,
|
||||||
|
cycleLength = period.cycleLength,
|
||||||
|
atypical = false // сохраняем существующее значение, если возможно
|
||||||
|
)
|
||||||
|
updateCycleInHistory(historyEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deletePeriod(period: CyclePeriodEntity) {
|
||||||
|
val historyEntity = CycleHistoryEntity(
|
||||||
|
id = period.id,
|
||||||
|
periodStart = period.startDate,
|
||||||
|
periodEnd = period.endDate,
|
||||||
|
flow = period.flow,
|
||||||
|
symptoms = period.symptoms,
|
||||||
|
cycleLength = period.cycleLength,
|
||||||
|
atypical = false
|
||||||
|
)
|
||||||
|
deleteCycleFromHistory(historyEntity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kr.smartsoltech.wellshe.data.network.EmergencyService
|
||||||
|
import kr.smartsoltech.wellshe.model.emergency.*
|
||||||
|
import kr.smartsoltech.wellshe.util.Result
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий для работы с экстренными оповещениями
|
||||||
|
*/
|
||||||
|
class EmergencyRepository(private val emergencyService: EmergencyService) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создание нового экстренного оповещения
|
||||||
|
*/
|
||||||
|
suspend fun createAlert(
|
||||||
|
token: String,
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
message: String? = null,
|
||||||
|
batteryLevel: Int? = null,
|
||||||
|
contactIds: List<String>? = null
|
||||||
|
): Result<EmergencyAlertResponse> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bearerToken = "Bearer $token"
|
||||||
|
val locationData = LocationData(latitude, longitude)
|
||||||
|
val request = EmergencyAlertRequest(locationData, message, batteryLevel, contactIds)
|
||||||
|
|
||||||
|
val response = emergencyService.createAlert(bearerToken, request)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
Result.Success(response.body()!!.data)
|
||||||
|
} else {
|
||||||
|
Result.Error(Exception("Ошибка создания оповещения: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение статуса экстренного оповещения
|
||||||
|
*/
|
||||||
|
suspend fun getAlertStatus(token: String, alertId: String): Result<EmergencyAlertStatus> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bearerToken = "Bearer $token"
|
||||||
|
val response = emergencyService.getAlertStatus(bearerToken, alertId)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
Result.Success(response.body()!!.data)
|
||||||
|
} else {
|
||||||
|
Result.Error(Exception("Ошибка получения статуса оповещения: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление местоположения в активном оповещении
|
||||||
|
*/
|
||||||
|
suspend fun updateLocation(
|
||||||
|
token: String,
|
||||||
|
alertId: String,
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
accuracy: Float? = null,
|
||||||
|
batteryLevel: Int? = null
|
||||||
|
): Result<LocationUpdateResponse> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bearerToken = "Bearer $token"
|
||||||
|
val request = LocationUpdateRequest(latitude, longitude, accuracy, batteryLevel)
|
||||||
|
|
||||||
|
val response = emergencyService.updateLocation(bearerToken, alertId, request)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
Result.Success(response.body()!!.data)
|
||||||
|
} else {
|
||||||
|
Result.Error(Exception("Ошибка обновления местоположения: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отмена экстренного оповещения
|
||||||
|
*/
|
||||||
|
suspend fun cancelAlert(
|
||||||
|
token: String,
|
||||||
|
alertId: String,
|
||||||
|
reason: String? = null,
|
||||||
|
details: String? = null
|
||||||
|
): Result<AlertCancelResponse> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bearerToken = "Bearer $token"
|
||||||
|
val request = AlertCancelRequest(reason, details)
|
||||||
|
|
||||||
|
val response = emergencyService.cancelAlert(bearerToken, alertId, request)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
Result.Success(response.body()!!.data)
|
||||||
|
} else {
|
||||||
|
Result.Error(Exception("Ошибка отмены оповещения: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repository
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.HealthRecordDao
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.HealthRecordEntity
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class HealthRepository @Inject constructor(private val dao: HealthRecordDao) {
|
||||||
|
suspend fun getAllRecords(): List<HealthRecordEntity> = dao.getAll()
|
||||||
|
suspend fun insertRecord(record: HealthRecordEntity): Long = dao.insert(record)
|
||||||
|
suspend fun updateRecord(record: HealthRecordEntity) = dao.update(record)
|
||||||
|
suspend fun deleteRecord(record: HealthRecordEntity) = dao.delete(record)
|
||||||
|
suspend fun getRecordByDate(date: java.time.LocalDate): HealthRecordEntity? = dao.getByDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kr.smartsoltech.wellshe.data.api.HealthApi
|
||||||
|
import kr.smartsoltech.wellshe.data.model.HealthStatus
|
||||||
|
import kr.smartsoltech.wellshe.data.model.ServerStatus
|
||||||
|
import kr.smartsoltech.wellshe.data.model.toHealthStatus
|
||||||
|
import kr.smartsoltech.wellshe.data.network.RetrofitFactory
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ServerHealthRepository @Inject constructor(
|
||||||
|
private val retrofitFactory: RetrofitFactory
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ServerHealthRepository"
|
||||||
|
private const val HEALTH_CHECK_TIMEOUT_MS = 5000L
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun checkServerHealth(serverUrl: String): ServerStatus = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "Checking health for server: $serverUrl")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// Создаем отдельный Retrofit для health check'а
|
||||||
|
val baseUrl = if (serverUrl.endsWith("/")) serverUrl else "$serverUrl/"
|
||||||
|
val retrofit = retrofitFactory.create(baseUrl)
|
||||||
|
val healthApi = retrofit.create(HealthApi::class.java)
|
||||||
|
|
||||||
|
// Выполняем запрос с таймаутом
|
||||||
|
val response = withTimeoutOrNull(HEALTH_CHECK_TIMEOUT_MS) {
|
||||||
|
healthApi.getHealth()
|
||||||
|
}
|
||||||
|
|
||||||
|
val endTime = System.currentTimeMillis()
|
||||||
|
val pingMs = endTime - startTime
|
||||||
|
|
||||||
|
Log.d(TAG, "Health check for $serverUrl completed in ${pingMs}ms")
|
||||||
|
|
||||||
|
if (response != null && response.isSuccessful) {
|
||||||
|
val healthResponse = response.body()
|
||||||
|
val isHealthy = healthResponse?.status?.lowercase() == "healthy" ||
|
||||||
|
healthResponse?.status?.lowercase() == "ok"
|
||||||
|
|
||||||
|
Log.d(TAG, "Server $serverUrl is ${if (isHealthy) "healthy" else "unhealthy"}, ping: ${pingMs}ms")
|
||||||
|
|
||||||
|
ServerStatus(
|
||||||
|
url = serverUrl,
|
||||||
|
isHealthy = isHealthy,
|
||||||
|
pingMs = pingMs,
|
||||||
|
status = if (isHealthy) pingMs.toHealthStatus() else HealthStatus.POOR
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Health check failed for $serverUrl: HTTP ${response?.code()}")
|
||||||
|
ServerStatus(
|
||||||
|
url = serverUrl,
|
||||||
|
isHealthy = false,
|
||||||
|
pingMs = pingMs,
|
||||||
|
status = HealthStatus.OFFLINE,
|
||||||
|
error = "HTTP ${response?.code() ?: "timeout"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error checking health for $serverUrl", e)
|
||||||
|
ServerStatus(
|
||||||
|
url = serverUrl,
|
||||||
|
isHealthy = false,
|
||||||
|
pingMs = HEALTH_CHECK_TIMEOUT_MS,
|
||||||
|
status = HealthStatus.OFFLINE,
|
||||||
|
error = e.message ?: "Connection failed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun checkMultipleServers(serverUrls: List<String>): List<ServerStatus> = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "Checking health for ${serverUrls.size} servers")
|
||||||
|
|
||||||
|
val deferredResults = serverUrls.map { url ->
|
||||||
|
async { checkServerHealth(url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val results = deferredResults.awaitAll()
|
||||||
|
|
||||||
|
Log.d(TAG, "Health check completed for all servers")
|
||||||
|
results.forEach { status ->
|
||||||
|
Log.d(TAG, "Server ${status.url}: ${status.status} (${status.pingMs}ms)")
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
package kr.smartsoltech.wellshe.data.repository
|
package kr.smartsoltech.wellshe.data.repository
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kr.smartsoltech.wellshe.data.dao.*
|
import kr.smartsoltech.wellshe.data.dao.*
|
||||||
import kr.smartsoltech.wellshe.data.entity.*
|
import kr.smartsoltech.wellshe.data.entity.*
|
||||||
import kr.smartsoltech.wellshe.domain.model.*
|
import kr.smartsoltech.wellshe.domain.model.AppSettings
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.FitnessData
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.User
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.WaterIntake
|
||||||
|
import kr.smartsoltech.wellshe.domain.model.WorkoutSession
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -16,7 +20,6 @@ import javax.inject.Singleton
|
|||||||
class WellSheRepository @Inject constructor(
|
class WellSheRepository @Inject constructor(
|
||||||
private val waterLogDao: WaterLogDao,
|
private val waterLogDao: WaterLogDao,
|
||||||
private val cyclePeriodDao: CyclePeriodDao,
|
private val cyclePeriodDao: CyclePeriodDao,
|
||||||
private val sleepLogDao: SleepLogDao,
|
|
||||||
private val healthRecordDao: HealthRecordDao,
|
private val healthRecordDao: HealthRecordDao,
|
||||||
private val workoutDao: WorkoutDao,
|
private val workoutDao: WorkoutDao,
|
||||||
private val calorieDao: CalorieDao,
|
private val calorieDao: CalorieDao,
|
||||||
@@ -40,8 +43,7 @@ class WellSheRepository @Inject constructor(
|
|||||||
weight = 60f,
|
weight = 60f,
|
||||||
dailyWaterGoal = 2.5f,
|
dailyWaterGoal = 2.5f,
|
||||||
dailyStepsGoal = 10000,
|
dailyStepsGoal = 10000,
|
||||||
dailyCaloriesGoal = 2000,
|
dailyCaloriesGoal = 2000
|
||||||
dailySleepGoal = 8.0f
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -68,18 +70,19 @@ class WellSheRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getWaterIntakeForDate(date: LocalDate): Flow<List<WaterIntake>> {
|
fun getWaterIntakeForDate(date: LocalDate): Flow<List<WaterIntake>> {
|
||||||
return waterLogDao.getWaterLogsForDate(date).map { entities ->
|
return flow {
|
||||||
entities.map { entity ->
|
val entities = waterLogDao.getWaterLogsForDate(date)
|
||||||
|
emit(entities.map { entity ->
|
||||||
WaterIntake(
|
WaterIntake(
|
||||||
id = entity.id,
|
id = entity.id,
|
||||||
date = entity.date,
|
date = entity.date,
|
||||||
time = LocalTime.ofInstant(
|
time = LocalTime.of(
|
||||||
java.time.Instant.ofEpochMilli(entity.timestamp),
|
(entity.timestamp % (24 * 60 * 60 * 1000) / (60 * 60 * 1000)).toInt(),
|
||||||
java.time.ZoneId.systemDefault()
|
((entity.timestamp % (60 * 60 * 1000)) / (60 * 1000)).toInt()
|
||||||
),
|
),
|
||||||
amount = entity.amount / 1000f // конвертируем в литры
|
amount = entity.amount / 1000f // конвертируем в литры
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,199 +154,89 @@ class WellSheRepository @Inject constructor(
|
|||||||
// TODO: Реализовать окончание тренировки
|
// TODO: Реализовать окончание тренировки
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================
|
|
||||||
// СОН
|
|
||||||
// =================
|
|
||||||
|
|
||||||
suspend fun getSleepForDate(date: LocalDate): SleepLogEntity? {
|
|
||||||
return sleepLogDao.getSleepForDate(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getRecentSleepLogs(): Flow<List<SleepLogEntity>> {
|
|
||||||
return sleepLogDao.getRecentSleepLogs()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addSleepRecord(date: LocalDate, bedTime: String, wakeTime: String, quality: String, notes: String) {
|
|
||||||
// Вычисляем продолжительность сна
|
|
||||||
val duration = calculateSleepDuration(bedTime, wakeTime)
|
|
||||||
|
|
||||||
sleepLogDao.insertSleepLog(
|
|
||||||
SleepLogEntity(
|
|
||||||
date = date,
|
|
||||||
bedTime = bedTime,
|
|
||||||
wakeTime = wakeTime,
|
|
||||||
duration = duration,
|
|
||||||
quality = quality,
|
|
||||||
notes = notes
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateSleepDuration(bedTime: String, wakeTime: String): Float {
|
|
||||||
// TODO: Реализовать правильный расчет продолжительности сна
|
|
||||||
return 8.0f
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// МЕНСТРУАЛЬНЫЙ ЦИКЛ
|
// МЕНСТРУАЛЬНЫЙ ЦИКЛ
|
||||||
// =================
|
// =================
|
||||||
|
|
||||||
suspend fun addPeriod(startDate: LocalDate, endDate: LocalDate?, flow: String, symptoms: List<String>, mood: String) {
|
suspend fun addPeriod(startDate: LocalDate, endDate: LocalDate?, flow: String, symptoms: List<String>) {
|
||||||
cyclePeriodDao.insertPeriod(
|
val period = CyclePeriodEntity(
|
||||||
CyclePeriodEntity(
|
startDate = startDate,
|
||||||
startDate = startDate,
|
endDate = endDate,
|
||||||
endDate = endDate,
|
flow = flow,
|
||||||
flow = flow,
|
symptoms = symptoms
|
||||||
symptoms = symptoms.joinToString(","),
|
|
||||||
mood = mood
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
// Используем CycleRepository для работы с периодами
|
||||||
|
// cyclePeriodDao.insertPeriod(period)
|
||||||
|
// TODO: Добавить интеграцию с CycleRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentCyclePeriod(): Flow<CyclePeriodEntity?> {
|
suspend fun updatePeriod(periodId: Long, endDate: LocalDate?, flow: String, symptoms: List<String>) {
|
||||||
return cyclePeriodDao.getCurrentPeriod()
|
// TODO: Реализовать через CycleRepository
|
||||||
|
// val existingPeriod = cyclePeriodDao.getPeriodById(periodId)
|
||||||
|
// existingPeriod?.let {
|
||||||
|
// val updatedPeriod = it.copy(
|
||||||
|
// endDate = endDate,
|
||||||
|
// flow = flow,
|
||||||
|
// symptoms = symptoms
|
||||||
|
// )
|
||||||
|
// cyclePeriodDao.updatePeriod(updatedPeriod)
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getRecentPeriods(): Flow<List<CyclePeriodEntity>> {
|
fun getPeriods(): Flow<List<CyclePeriodEntity>> {
|
||||||
return cyclePeriodDao.getRecentPeriods(6)
|
// TODO: Реализовать через CycleRepository
|
||||||
|
return flowOf(emptyList())
|
||||||
|
// return cyclePeriodDao.getAllPeriods()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deletePeriod(periodId: Long) {
|
||||||
|
// TODO: Реализовать через CycleRepository
|
||||||
|
// cyclePeriodDao.deletePeriodById(periodId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// НАСТРОЙКИ
|
// НАСТРОЙКИ
|
||||||
// =================
|
// =================
|
||||||
|
|
||||||
fun getSettings(): Flow<AppSettings> {
|
fun getAppSettings(): Flow<AppSettings> {
|
||||||
// TODO: Реализовать получение настроек из БД
|
// TODO: Реализовать получение настроек из БД
|
||||||
return flowOf(
|
return flowOf(
|
||||||
AppSettings(
|
AppSettings(
|
||||||
isWaterReminderEnabled = true,
|
notificationsEnabled = true,
|
||||||
isCycleReminderEnabled = true,
|
darkModeEnabled = false
|
||||||
isSleepReminderEnabled = true,
|
|
||||||
cycleLength = 28,
|
|
||||||
periodLength = 5,
|
|
||||||
waterGoal = 2.5f,
|
|
||||||
stepsGoal = 10000,
|
|
||||||
sleepGoal = 8.0f,
|
|
||||||
isDarkTheme = false
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateWaterReminderSetting(enabled: Boolean) {
|
suspend fun updateAppSettings(settings: AppSettings) {
|
||||||
// TODO: Реализовать обновление настройки напоминаний о воде
|
// TODO: Реализовать обновление настроек
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateCycleReminderSetting(enabled: Boolean) {
|
|
||||||
// TODO: Реализовать обновление настройки напоминаний о цикле
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateSleepReminderSetting(enabled: Boolean) {
|
|
||||||
// TODO: Реализовать обновление настройки напоминаний о сне
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateCycleLength(length: Int) {
|
|
||||||
// TODO: Реализовать обновление длины цикла
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updatePeriodLength(length: Int) {
|
|
||||||
// TODO: Реализовать обновление длины менструации
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateStepsGoal(goal: Int) {
|
|
||||||
// TODO: Реализовать обновление цели по шагам
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateSleepGoal(goal: Float) {
|
|
||||||
// TODO: Реализовать обновление цели по сну
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateThemeSetting(isDark: Boolean) {
|
|
||||||
// TODO: Реализовать обновление темы
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// УПРАВЛЕНИЕ ДАННЫМИ
|
// АНАЛИТИКА И ОТЧЕТЫ
|
||||||
// =================
|
// =================
|
||||||
|
|
||||||
suspend fun exportUserData() {
|
fun getDashboardData(date: LocalDate): Flow<DashboardData> {
|
||||||
// TODO: Реализовать экспорт данных пользователя
|
return flow {
|
||||||
}
|
emit(
|
||||||
|
DashboardData(
|
||||||
suspend fun importUserData() {
|
date = date,
|
||||||
// TODO: Реализовать импорт данных пользователя
|
waterIntake = 1.2f,
|
||||||
}
|
steps = 6500,
|
||||||
|
calories = 1850,
|
||||||
suspend fun clearAllUserData() {
|
workouts = 1,
|
||||||
// TODO: Реализовать очистку всех данных пользователя
|
cycleDay = null
|
||||||
}
|
)
|
||||||
|
|
||||||
// =================
|
|
||||||
// ЗДОРОВЬЕ
|
|
||||||
// =================
|
|
||||||
|
|
||||||
fun getTodayHealthData(): Flow<HealthRecordEntity?> {
|
|
||||||
// TODO: Реализовать получение данных о здоровье за сегодня
|
|
||||||
return flowOf(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateHealthRecord(record: HealthRecord) {
|
|
||||||
// TODO: Реализовать обновление записи о здоровье
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================
|
|
||||||
// DASHBOARD
|
|
||||||
// =================
|
|
||||||
|
|
||||||
fun getDashboardData(): Flow<DashboardData> {
|
|
||||||
// TODO: Реализовать получение данных для главного экрана
|
|
||||||
return flowOf(
|
|
||||||
DashboardData(
|
|
||||||
user = User(),
|
|
||||||
todayHealth = null,
|
|
||||||
sleepData = null,
|
|
||||||
cycleData = null,
|
|
||||||
recentWorkouts = emptyList()
|
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// =================
|
|
||||||
// УСТАРЕВШИЕ МЕТОДЫ (для совместимости)
|
|
||||||
// =================
|
|
||||||
|
|
||||||
suspend fun addWater(amount: Int, date: LocalDate = LocalDate.now()) {
|
|
||||||
waterLogDao.insertWaterLog(
|
|
||||||
WaterLogEntity(date = date, amount = amount)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getTodayWaterIntake(date: LocalDate = LocalDate.now()): Int {
|
|
||||||
return waterLogDao.getTotalWaterForDate(date) ?: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getWaterLogsForDate(date: LocalDate): Flow<List<WaterLogEntity>> {
|
|
||||||
return waterLogDao.getWaterLogsForDate(date)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вспомогательные data классы
|
|
||||||
data class DashboardData(
|
data class DashboardData(
|
||||||
val user: User,
|
|
||||||
val todayHealth: HealthRecord?,
|
|
||||||
val sleepData: SleepLogEntity?,
|
|
||||||
val cycleData: CyclePeriodEntity?,
|
|
||||||
val recentWorkouts: List<WorkoutSession>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class HealthRecord(
|
|
||||||
val id: Long = 0,
|
|
||||||
val date: LocalDate,
|
val date: LocalDate,
|
||||||
val bloodPressureSystolic: Int = 0,
|
val waterIntake: Float,
|
||||||
val bloodPressureDiastolic: Int = 0,
|
val steps: Int,
|
||||||
val heartRate: Int = 0,
|
val calories: Int,
|
||||||
val weight: Float = 0f,
|
val workouts: Int,
|
||||||
val mood: String = "neutral", // Добавляем поле настроения
|
val cycleDay: Int?
|
||||||
val energyLevel: Int = 5, // Добавляем уровень энергии (1-10)
|
|
||||||
val stressLevel: Int = 5, // Добавляем уровень стресса (1-10)
|
|
||||||
val notes: String = ""
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package kr.smartsoltech.wellshe.data.storage
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для управления токенами авторизации
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class TokenManager @Inject constructor() {
|
||||||
|
|
||||||
|
// Токен авторизации
|
||||||
|
private var accessToken: String? = null
|
||||||
|
|
||||||
|
// Токен обновления
|
||||||
|
private var refreshToken: String? = null
|
||||||
|
|
||||||
|
// Время истечения токена
|
||||||
|
private var expiresAt: Long = 0
|
||||||
|
|
||||||
|
// Тип токена (например, "Bearer")
|
||||||
|
private var tokenType: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить токены авторизации
|
||||||
|
*/
|
||||||
|
fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Int) {
|
||||||
|
this.accessToken = accessToken
|
||||||
|
this.refreshToken = refreshToken
|
||||||
|
this.expiresAt = Date().time + (expiresIn * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить только токен доступа
|
||||||
|
*/
|
||||||
|
fun updateAccessToken(accessToken: String, expiresIn: Int) {
|
||||||
|
this.accessToken = accessToken
|
||||||
|
this.expiresAt = Date().time + (expiresIn * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить токен доступа
|
||||||
|
*/
|
||||||
|
fun saveAccessToken(accessToken: String) {
|
||||||
|
this.accessToken = accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить тип токена
|
||||||
|
*/
|
||||||
|
fun saveTokenType(tokenType: String) {
|
||||||
|
this.tokenType = tokenType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить тип токена
|
||||||
|
*/
|
||||||
|
fun getTokenType(): String? {
|
||||||
|
return tokenType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очистить все токены
|
||||||
|
*/
|
||||||
|
fun clearTokens() {
|
||||||
|
accessToken = null
|
||||||
|
refreshToken = null
|
||||||
|
tokenType = null
|
||||||
|
expiresAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить токен авторизации
|
||||||
|
*/
|
||||||
|
fun getAccessToken(): String? {
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить токен обновления
|
||||||
|
*/
|
||||||
|
fun getRefreshToken(): String? {
|
||||||
|
return refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверить, истек ли токен авторизации
|
||||||
|
*/
|
||||||
|
fun isAccessTokenExpired(): Boolean {
|
||||||
|
return Date().time > expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить токен авторизации (для обратной совместимости)
|
||||||
|
*/
|
||||||
|
fun saveAuthToken(token: String) {
|
||||||
|
accessToken = token
|
||||||
|
expiresAt = Date().time + (3600 * 1000) // По умолчанию 1 час
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import dagger.hilt.InstallIn
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import kr.smartsoltech.wellshe.data.AppDatabase
|
import kr.smartsoltech.wellshe.data.AppDatabase
|
||||||
import kr.smartsoltech.wellshe.data.datastore.DataStoreManager
|
|
||||||
import kr.smartsoltech.wellshe.data.dao.*
|
import kr.smartsoltech.wellshe.data.dao.*
|
||||||
|
import kr.smartsoltech.wellshe.data.repo.*
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -18,31 +18,19 @@ object AppModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideDataStoreManager(@ApplicationContext context: Context): DataStoreManager =
|
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
|
||||||
DataStoreManager(context)
|
return Room.databaseBuilder(
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
|
||||||
Room.databaseBuilder(
|
|
||||||
context,
|
context,
|
||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"well_she_db"
|
"wellshe_database"
|
||||||
).fallbackToDestructiveMigration().build()
|
).fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
// DAO providers
|
// DAO Providers
|
||||||
@Provides
|
@Provides
|
||||||
fun provideWaterLogDao(database: AppDatabase): WaterLogDao = database.waterLogDao()
|
fun provideWaterLogDao(database: AppDatabase): WaterLogDao = database.waterLogDao()
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun provideCyclePeriodDao(database: AppDatabase): CyclePeriodDao = database.cyclePeriodDao()
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun provideSleepLogDao(database: AppDatabase): SleepLogDao = database.sleepLogDao()
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun provideHealthRecordDao(database: AppDatabase): HealthRecordDao = database.healthRecordDao()
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideWorkoutDao(database: AppDatabase): WorkoutDao = database.workoutDao()
|
fun provideWorkoutDao(database: AppDatabase): WorkoutDao = database.workoutDao()
|
||||||
|
|
||||||
@@ -55,21 +43,106 @@ object AppModule {
|
|||||||
@Provides
|
@Provides
|
||||||
fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao()
|
fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao()
|
||||||
|
|
||||||
// Repository
|
@Provides
|
||||||
|
fun provideCyclePeriodDao(database: AppDatabase): CyclePeriodDao = database.cyclePeriodDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideHealthRecordDao(database: AppDatabase): HealthRecordDao = database.healthRecordDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideBeverageLogDao(database: AppDatabase): BeverageLogDao = database.beverageLogDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideBeverageLogNutrientDao(database: AppDatabase): BeverageLogNutrientDao = database.beverageLogNutrientDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideBeverageServingNutrientDao(database: AppDatabase): BeverageServingNutrientDao = database.beverageServingNutrientDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideWeightLogDao(database: AppDatabase): WeightLogDao = database.weightLogDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideWorkoutSessionDao(database: AppDatabase): WorkoutSessionDao = database.workoutSessionDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideWorkoutSessionParamDao(database: AppDatabase): WorkoutSessionParamDao = database.workoutSessionParamDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideWorkoutEventDao(database: AppDatabase): WorkoutEventDao = database.workoutEventDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideExerciseDao(database: AppDatabase): ExerciseDao = database.exerciseDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideExerciseFormulaDao(database: AppDatabase): ExerciseFormulaDao = database.exerciseFormulaDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideExerciseFormulaVarDao(database: AppDatabase): ExerciseFormulaVarDao = database.exerciseFormulaVarDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideBeverageDao(database: AppDatabase): BeverageDao = database.beverageDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideBeverageServingDao(database: AppDatabase): BeverageServingDao = database.beverageServingDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideExerciseParamDao(database: AppDatabase): ExerciseParamDao = database.exerciseParamDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideNutrientDao(database: AppDatabase): NutrientDao = database.nutrientDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideCatalogVersionDao(database: AppDatabase): CatalogVersionDao = database.catalogVersionDao()
|
||||||
|
|
||||||
|
// Repository/Service Providers
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideWellSheRepository(
|
fun provideWeightRepository(weightLogDao: WeightLogDao): WeightRepository {
|
||||||
|
return WeightRepository(weightLogDao)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDrinkLogger(
|
||||||
waterLogDao: WaterLogDao,
|
waterLogDao: WaterLogDao,
|
||||||
cyclePeriodDao: CyclePeriodDao,
|
beverageLogDao: BeverageLogDao,
|
||||||
sleepLogDao: SleepLogDao,
|
beverageLogNutrientDao: BeverageLogNutrientDao,
|
||||||
healthRecordDao: HealthRecordDao,
|
servingNutrientDao: BeverageServingNutrientDao
|
||||||
workoutDao: WorkoutDao,
|
): DrinkLogger {
|
||||||
calorieDao: CalorieDao,
|
return DrinkLogger(waterLogDao, beverageLogDao, beverageLogNutrientDao, servingNutrientDao)
|
||||||
stepsDao: StepsDao,
|
}
|
||||||
userProfileDao: UserProfileDao
|
|
||||||
): kr.smartsoltech.wellshe.data.repository.WellSheRepository =
|
@Provides
|
||||||
kr.smartsoltech.wellshe.data.repository.WellSheRepository(
|
@Singleton
|
||||||
waterLogDao, cyclePeriodDao, sleepLogDao, healthRecordDao,
|
fun provideWorkoutService(
|
||||||
workoutDao, calorieDao, stepsDao, userProfileDao
|
sessionDao: WorkoutSessionDao,
|
||||||
)
|
paramDao: WorkoutSessionParamDao,
|
||||||
|
eventDao: WorkoutEventDao,
|
||||||
|
weightRepo: WeightRepository,
|
||||||
|
formulaDao: ExerciseFormulaDao,
|
||||||
|
formulaVarDao: ExerciseFormulaVarDao,
|
||||||
|
exerciseDao: ExerciseDao
|
||||||
|
): WorkoutService {
|
||||||
|
return WorkoutService(sessionDao, paramDao, eventDao, weightRepo, formulaDao, formulaVarDao, exerciseDao)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideBeverageCatalogRepository(
|
||||||
|
beverageDao: BeverageDao,
|
||||||
|
servingDao: BeverageServingDao,
|
||||||
|
servingNutrientDao: BeverageServingNutrientDao
|
||||||
|
): BeverageCatalogRepository {
|
||||||
|
return BeverageCatalogRepository(beverageDao, servingDao, servingNutrientDao)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideExerciseCatalogRepository(
|
||||||
|
exerciseDao: ExerciseDao,
|
||||||
|
paramDao: ExerciseParamDao,
|
||||||
|
formulaDao: ExerciseFormulaDao
|
||||||
|
): ExerciseCatalogRepository {
|
||||||
|
return ExerciseCatalogRepository(exerciseDao, paramDao, formulaDao)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
88
app/src/main/java/kr/smartsoltech/wellshe/di/AuthModule.kt
Normal file
88
app/src/main/java/kr/smartsoltech/wellshe/di/AuthModule.kt
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package kr.smartsoltech.wellshe.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.network.AuthService
|
||||||
|
import kr.smartsoltech.wellshe.data.network.RetrofitFactory
|
||||||
|
import kr.smartsoltech.wellshe.data.network.RetrofitProvider
|
||||||
|
import kr.smartsoltech.wellshe.data.preferences.ServerPreferences
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.AuthRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.GetUserProfileUseCase
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.LoginUseCase
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.LogoutUseCase
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.RegisterUseCase
|
||||||
|
import kr.smartsoltech.wellshe.domain.auth.RefreshTokenUseCase
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AuthModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthTokenRepository(@ApplicationContext context: Context): AuthTokenRepository {
|
||||||
|
return AuthTokenRepository(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideTokenManager(): TokenManager {
|
||||||
|
return TokenManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRetrofitProvider(
|
||||||
|
serverPreferences: ServerPreferences,
|
||||||
|
retrofitFactory: RetrofitFactory
|
||||||
|
): RetrofitProvider {
|
||||||
|
return RetrofitProvider(serverPreferences, retrofitFactory)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideAuthService(retrofitProvider: RetrofitProvider): AuthService {
|
||||||
|
// Каждый раз получаем актуальный Retrofit, который может иметь новый baseUrl
|
||||||
|
return retrofitProvider.getRetrofit().create(AuthService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthRepository(
|
||||||
|
authService: AuthService,
|
||||||
|
authTokenRepository: AuthTokenRepository,
|
||||||
|
tokenManager: TokenManager
|
||||||
|
): AuthRepository {
|
||||||
|
return AuthRepository(authService, authTokenRepository, tokenManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideLoginUseCase(authRepository: AuthRepository, tokenManager: TokenManager): LoginUseCase {
|
||||||
|
return LoginUseCase(authRepository, tokenManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideRegisterUseCase(authRepository: AuthRepository): RegisterUseCase {
|
||||||
|
return RegisterUseCase(authRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideLogoutUseCase(authRepository: AuthRepository, tokenManager: TokenManager): LogoutUseCase {
|
||||||
|
return LogoutUseCase(authRepository, tokenManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideGetUserProfileUseCase(authRepository: AuthRepository, tokenManager: TokenManager): GetUserProfileUseCase {
|
||||||
|
return GetUserProfileUseCase(authRepository, tokenManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideRefreshTokenUseCase(authRepository: AuthRepository, tokenManager: TokenManager): RefreshTokenUseCase {
|
||||||
|
return RefreshTokenUseCase(authRepository, tokenManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/src/main/java/kr/smartsoltech/wellshe/di/CycleModule.kt
Normal file
59
app/src/main/java/kr/smartsoltech/wellshe/di/CycleModule.kt
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package kr.smartsoltech.wellshe.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.CycleForecastDao
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.CycleHistoryDao
|
||||||
|
import kr.smartsoltech.wellshe.data.dao.CycleSettingsDao
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.CycleRepository
|
||||||
|
import kr.smartsoltech.wellshe.domain.services.CycleSettingsExportService
|
||||||
|
import kr.smartsoltech.wellshe.workers.CycleNotificationManager
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object CycleModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideCycleRepository(
|
||||||
|
settingsDao: CycleSettingsDao,
|
||||||
|
historyDao: CycleHistoryDao,
|
||||||
|
forecastDao: CycleForecastDao
|
||||||
|
): CycleRepository = CycleRepository(settingsDao, historyDao, forecastDao)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideCycleSettingsExportService(): CycleSettingsExportService =
|
||||||
|
CycleSettingsExportService()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideWorkManager(@ApplicationContext context: Context): WorkManager =
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideCycleNotificationManager(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
workManager: WorkManager
|
||||||
|
): CycleNotificationManager = CycleNotificationManager(context, workManager)
|
||||||
|
|
||||||
|
// DAO providers
|
||||||
|
@Provides
|
||||||
|
fun provideCycleSettingsDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleSettingsDao =
|
||||||
|
database.cycleSettingsDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideCycleHistoryDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleHistoryDao =
|
||||||
|
database.cycleHistoryDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideCycleForecastDao(database: kr.smartsoltech.wellshe.data.AppDatabase): CycleForecastDao =
|
||||||
|
database.cycleForecastDao()
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package kr.smartsoltech.wellshe.di
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.network.ApiClient
|
||||||
|
import kr.smartsoltech.wellshe.data.network.AuthInterceptor
|
||||||
|
import kr.smartsoltech.wellshe.data.network.RetrofitFactory
|
||||||
|
import kr.smartsoltech.wellshe.data.preferences.ServerPreferences
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object NetworkModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideGson(): Gson {
|
||||||
|
return GsonBuilder()
|
||||||
|
.setLenient()
|
||||||
|
.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthInterceptor(authTokenRepository: AuthTokenRepository): AuthInterceptor {
|
||||||
|
return AuthInterceptor(authTokenRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRetrofitFactory(
|
||||||
|
gson: Gson,
|
||||||
|
authTokenRepository: AuthTokenRepository
|
||||||
|
): RetrofitFactory {
|
||||||
|
return RetrofitFactory(gson, authTokenRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideApiClient(serverPreferences: ServerPreferences): ApiClient {
|
||||||
|
return ApiClient(serverPreferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.smartsoltech.wellshe.di
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Provider
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фабрика для создания ViewModel с внедрением зависимостей через Hilt
|
||||||
|
*/
|
||||||
|
class ViewModelFactory @Inject constructor(
|
||||||
|
private val creators: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
val creator = creators[modelClass] ?: creators.entries.firstOrNull {
|
||||||
|
modelClass.isAssignableFrom(it.key)
|
||||||
|
}?.value ?: throw IllegalArgumentException("unknown model class $modelClass")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
creator.get() as T
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw RuntimeException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,34 @@
|
|||||||
package kr.smartsoltech.wellshe.domain.analytics
|
package kr.smartsoltech.wellshe.domain.analytics
|
||||||
|
|
||||||
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
import kr.smartsoltech.wellshe.data.entity.CyclePeriodEntity
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleStatsEntity
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
data class CycleForecast(
|
||||||
|
val nextStart: Long?,
|
||||||
|
val fertileWindow: Pair<Long, Long>?,
|
||||||
|
val confidence: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CycleStats(
|
||||||
|
val avgCycle: Int,
|
||||||
|
val variance: Double,
|
||||||
|
val lutealLen: Int
|
||||||
|
)
|
||||||
|
|
||||||
object CycleAnalytics {
|
object CycleAnalytics {
|
||||||
/**
|
/**
|
||||||
* Прогноз следующей менструации и фертильного окна
|
* Прогноз следующей менструации и фертильного окна
|
||||||
* @param periods список последних периодов
|
* @param periods список последних периодов
|
||||||
* @param stats статистика цикла (вычисляется автоматически)
|
* @param statsEntity статистика цикла из базы (опционально)
|
||||||
* @return прогноз: дата, фертильное окно, доверие
|
* @return прогноз: дата, фертильное окно, доверие
|
||||||
*/
|
*/
|
||||||
fun forecast(periods: List<CyclePeriodEntity>, stats: CycleStats? = null): CycleForecast {
|
fun forecast(periods: List<CyclePeriodEntity>, statsEntity: CycleStatsEntity? = null): CycleForecast {
|
||||||
if (periods.isEmpty()) return CycleForecast(null, null, "низкая")
|
if (periods.isEmpty()) return CycleForecast(null, null, "низкая")
|
||||||
|
|
||||||
val calculatedStats = stats ?: calculateStats(periods)
|
val calculatedStats = calculateStats(periods)
|
||||||
|
|
||||||
val lastPeriod = periods.first()
|
val lastPeriod = periods.first()
|
||||||
val lastStartDate = lastPeriod.startDate
|
val lastStartDate = lastPeriod.startDate
|
||||||
val lastStartTs = lastStartDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000
|
val lastStartTs = lastStartDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000
|
||||||
@@ -49,27 +64,53 @@ object CycleAnalytics {
|
|||||||
val cycleLengths = periods.take(periods.size - 1).mapIndexed { index, period ->
|
val cycleLengths = periods.take(periods.size - 1).mapIndexed { index, period ->
|
||||||
val nextPeriod = periods[index + 1]
|
val nextPeriod = periods[index + 1]
|
||||||
java.time.temporal.ChronoUnit.DAYS.between(nextPeriod.startDate, period.startDate).toInt()
|
java.time.temporal.ChronoUnit.DAYS.between(nextPeriod.startDate, period.startDate).toInt()
|
||||||
|
}.filter { it > 0 }
|
||||||
|
|
||||||
|
if (cycleLengths.isEmpty()) {
|
||||||
|
return CycleStats(avgCycle = 28, variance = 5.0, lutealLen = 14)
|
||||||
}
|
}
|
||||||
|
|
||||||
val avgCycle = cycleLengths.average().toInt()
|
val avgCycle = cycleLengths.average().toInt()
|
||||||
val variance = cycleLengths.map { (it - avgCycle) * (it - avgCycle) }.average()
|
val variance = if (cycleLengths.size > 1) {
|
||||||
|
cycleLengths.map { (it - avgCycle) * (it - avgCycle) }.average()
|
||||||
|
} else {
|
||||||
|
5.0
|
||||||
|
}
|
||||||
|
|
||||||
return CycleStats(
|
// Примерная лютеиновая фаза (обычно 14 дней)
|
||||||
avgCycle = avgCycle,
|
val lutealLen = 14
|
||||||
variance = variance,
|
|
||||||
lutealLen = 14 // стандартная лютеиновая фаза
|
return CycleStats(avgCycle, variance, lutealLen)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Анализ регулярности цикла
|
||||||
|
*/
|
||||||
|
fun analyzeRegularity(periods: List<CyclePeriodEntity>): String {
|
||||||
|
val stats = calculateStats(periods)
|
||||||
|
return when {
|
||||||
|
stats.variance < 2 -> "Очень регулярный"
|
||||||
|
stats.variance < 5 -> "Регулярный"
|
||||||
|
stats.variance < 10 -> "Умеренно регулярный"
|
||||||
|
else -> "Нерегулярный"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Предсказание следующих дат
|
||||||
|
*/
|
||||||
|
fun predictNextPeriods(periods: List<CyclePeriodEntity>, count: Int = 3): List<LocalDate> {
|
||||||
|
if (periods.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val stats = calculateStats(periods)
|
||||||
|
val lastPeriod = periods.first()
|
||||||
|
val predictions = mutableListOf<LocalDate>()
|
||||||
|
|
||||||
|
for (i in 1..count) {
|
||||||
|
val nextDate = lastPeriod.startDate.plusDays((stats.avgCycle * i).toLong())
|
||||||
|
predictions.add(nextDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return predictions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class CycleForecast(
|
|
||||||
val nextStart: Long?,
|
|
||||||
val fertileWindow: Pair<Long, Long>?,
|
|
||||||
val confidence: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CycleStats(
|
|
||||||
val avgCycle: Int,
|
|
||||||
val variance: Double,
|
|
||||||
val lutealLen: Int
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package kr.smartsoltech.wellshe.domain.analytics
|
|
||||||
|
|
||||||
import kr.smartsoltech.wellshe.data.entity.SleepLogEntity
|
|
||||||
|
|
||||||
object SleepAnalytics {
|
|
||||||
/**
|
|
||||||
* Расчёт долга сна и недельного тренда
|
|
||||||
*/
|
|
||||||
fun sleepDebt(logs: List<SleepLogEntity>, targetHours: Int = 8): Int {
|
|
||||||
val total = logs.sumOf { it.duration.toDouble() }
|
|
||||||
val expected = logs.size * targetHours
|
|
||||||
return (expected - total).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.auth
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.AuthRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||||
|
import kr.smartsoltech.wellshe.model.auth.UserProfile
|
||||||
|
import kr.smartsoltech.wellshe.util.Result
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для регистрации нового пользователя
|
||||||
|
*/
|
||||||
|
class RegisterUseCase(private val authRepository: AuthRepository) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
email: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
phone: String
|
||||||
|
): Result<Boolean> {
|
||||||
|
val result = authRepository.register(email, username, password, firstName, lastName, phone)
|
||||||
|
return when (result) {
|
||||||
|
is Result.Success -> Result.Success(true)
|
||||||
|
is Result.Error -> Result.Error(result.exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для авторизации пользователя
|
||||||
|
*/
|
||||||
|
class LoginUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
|
||||||
|
suspend operator fun invoke(identifier: String, password: String, isEmail: Boolean): Result<Boolean> {
|
||||||
|
val result = authRepository.login(identifier, password, isEmail)
|
||||||
|
return when (result) {
|
||||||
|
is Result.Success -> {
|
||||||
|
val response = result.data
|
||||||
|
tokenManager.saveTokens(response.accessToken, response.refreshToken, response.expiresIn)
|
||||||
|
Result.Success(true)
|
||||||
|
}
|
||||||
|
is Result.Error -> Result.Error(result.exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для выхода пользователя из системы
|
||||||
|
*/
|
||||||
|
class LogoutUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
|
||||||
|
suspend operator fun invoke(): Result<Boolean> {
|
||||||
|
val accessToken = tokenManager.getAccessToken()
|
||||||
|
if (accessToken == null) {
|
||||||
|
tokenManager.clearTokens()
|
||||||
|
return Result.Success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = authRepository.logout(accessToken)
|
||||||
|
tokenManager.clearTokens() // Очищаем токены даже при ошибке запроса
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для получения профиля пользователя
|
||||||
|
*/
|
||||||
|
class GetUserProfileUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
|
||||||
|
suspend operator fun invoke(): Result<UserProfile> {
|
||||||
|
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||||
|
|
||||||
|
return if (tokenManager.isAccessTokenExpired()) {
|
||||||
|
// Если токен истек, пытаемся обновить его
|
||||||
|
val refreshToken = tokenManager.getRefreshToken() ?: return Result.Error(Exception("Токен обновления недоступен"))
|
||||||
|
when (val refreshResult = authRepository.refreshToken(refreshToken)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
tokenManager.updateAccessToken(refreshResult.data.accessToken, refreshResult.data.expiresIn)
|
||||||
|
// Получаем профиль с обновленным токеном
|
||||||
|
authRepository.getUserProfile(refreshResult.data.accessToken)
|
||||||
|
}
|
||||||
|
is Result.Error -> Result.Error(refreshResult.exception)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Получаем профиль с текущим токеном
|
||||||
|
authRepository.getUserProfile(accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для обновления токена доступа
|
||||||
|
*/
|
||||||
|
class RefreshTokenUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
|
||||||
|
suspend operator fun invoke(): Result<Boolean> {
|
||||||
|
val refreshToken = tokenManager.getRefreshToken() ?: return Result.Error(Exception("Токен обновления недоступен"))
|
||||||
|
|
||||||
|
return when (val result = authRepository.refreshToken(refreshToken)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
tokenManager.updateAccessToken(result.data.accessToken, result.data.expiresIn)
|
||||||
|
Result.Success(true)
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
// Если ошибка обновления, то считаем, что пользователь не авторизован
|
||||||
|
tokenManager.clearTokens()
|
||||||
|
Result.Error(result.exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.emergency
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.data.repository.EmergencyRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||||
|
import kr.smartsoltech.wellshe.model.emergency.*
|
||||||
|
import kr.smartsoltech.wellshe.util.Result
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для создания экстренного оповещения
|
||||||
|
*/
|
||||||
|
class CreateEmergencyAlertUseCase(
|
||||||
|
private val emergencyRepository: EmergencyRepository,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
message: String? = null,
|
||||||
|
batteryLevel: Int? = null,
|
||||||
|
contactIds: List<String>? = null
|
||||||
|
): Result<EmergencyAlertResponse> {
|
||||||
|
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||||
|
|
||||||
|
return emergencyRepository.createAlert(
|
||||||
|
accessToken, latitude, longitude, message, batteryLevel, contactIds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для получения статуса экстренного оповещения
|
||||||
|
*/
|
||||||
|
class GetAlertStatusUseCase(
|
||||||
|
private val emergencyRepository: EmergencyRepository,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(alertId: String): Result<EmergencyAlertStatus> {
|
||||||
|
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||||
|
|
||||||
|
return emergencyRepository.getAlertStatus(accessToken, alertId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для обновления местоположения при активном оповещении
|
||||||
|
*/
|
||||||
|
class UpdateLocationUseCase(
|
||||||
|
private val emergencyRepository: EmergencyRepository,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
alertId: String,
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
accuracy: Float? = null,
|
||||||
|
batteryLevel: Int? = null
|
||||||
|
): Result<LocationUpdateResponse> {
|
||||||
|
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||||
|
|
||||||
|
return emergencyRepository.updateLocation(
|
||||||
|
accessToken, alertId, latitude, longitude, accuracy, batteryLevel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case для отмены экстренного оповещения
|
||||||
|
*/
|
||||||
|
class CancelAlertUseCase(
|
||||||
|
private val emergencyRepository: EmergencyRepository,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
alertId: String,
|
||||||
|
reason: String? = null,
|
||||||
|
details: String? = null
|
||||||
|
): Result<AlertCancelResponse> {
|
||||||
|
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
|
||||||
|
|
||||||
|
return emergencyRepository.cancelAlert(accessToken, alertId, reason, details)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,6 @@
|
|||||||
package kr.smartsoltech.wellshe.domain.model
|
package kr.smartsoltech.wellshe.domain.model
|
||||||
|
|
||||||
data class AppSettings(
|
data class AppSettings(
|
||||||
val id: Long = 0,
|
val notificationsEnabled: Boolean = true,
|
||||||
val isWaterReminderEnabled: Boolean = true,
|
val darkModeEnabled: Boolean = false
|
||||||
val waterReminderInterval: Int = 2, // часы
|
|
||||||
val isCycleReminderEnabled: Boolean = true,
|
|
||||||
val isSleepReminderEnabled: Boolean = true,
|
|
||||||
val sleepReminderTime: String = "22:00",
|
|
||||||
val wakeUpReminderTime: String = "07:00",
|
|
||||||
val cycleLength: Int = 28,
|
|
||||||
val periodLength: Int = 5,
|
|
||||||
val waterGoal: Float = 2.5f,
|
|
||||||
val stepsGoal: Int = 10000,
|
|
||||||
val sleepGoal: Float = 8.0f,
|
|
||||||
val isDarkTheme: Boolean = false,
|
|
||||||
val language: String = "ru",
|
|
||||||
val isFirstLaunch: Boolean = true
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ data class User(
|
|||||||
val dailyWaterGoal: Float = 2.5f, // в литрах
|
val dailyWaterGoal: Float = 2.5f, // в литрах
|
||||||
val dailyStepsGoal: Int = 10000,
|
val dailyStepsGoal: Int = 10000,
|
||||||
val dailyCaloriesGoal: Int = 2000,
|
val dailyCaloriesGoal: Int = 2000,
|
||||||
val dailySleepGoal: Float = 8.0f, // в часах
|
|
||||||
val cycleLength: Int = 28, // дней
|
val cycleLength: Int = 28, // дней
|
||||||
val periodLength: Int = 5, // дней
|
val periodLength: Int = 5, // дней
|
||||||
val lastPeriodStart: LocalDate? = null,
|
val lastPeriodStart: LocalDate? = null,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.models
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доменная модель для истории циклов.
|
||||||
|
*/
|
||||||
|
data class CycleHistory(
|
||||||
|
val id: Long = 0,
|
||||||
|
val periodStart: LocalDate,
|
||||||
|
val periodEnd: LocalDate? = null,
|
||||||
|
val ovulationDate: LocalDate? = null,
|
||||||
|
val notes: String = "",
|
||||||
|
val atypical: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доменная модель для прогнозов цикла.
|
||||||
|
*/
|
||||||
|
data class CycleForecast(
|
||||||
|
val nextPeriodStart: LocalDate? = null,
|
||||||
|
val nextOvulation: LocalDate? = null,
|
||||||
|
val fertileStart: LocalDate? = null,
|
||||||
|
val fertileEnd: LocalDate? = null,
|
||||||
|
val pmsStart: LocalDate? = null,
|
||||||
|
val isReliable: Boolean = true, // Флаг для пониженной точности
|
||||||
|
val currentCyclePhase: CyclePhase = CyclePhase.UNKNOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фаза менструального цикла.
|
||||||
|
*/
|
||||||
|
enum class CyclePhase {
|
||||||
|
MENSTRUATION, // Менструация
|
||||||
|
FOLLICULAR, // Фолликулярная фаза (после менструации до фертильного окна)
|
||||||
|
FERTILE, // Фертильное окно
|
||||||
|
OVULATION, // День овуляции
|
||||||
|
LUTEAL, // Лютеиновая фаза (после овуляции)
|
||||||
|
PMS, // ПМС (последние дни перед менструацией)
|
||||||
|
UNKNOWN; // Неизвестная фаза (например, при недостатке данных)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Определяет текущую фазу цикла на основе прогноза и текущей даты.
|
||||||
|
*/
|
||||||
|
fun determinePhase(
|
||||||
|
today: LocalDate = LocalDate.now(),
|
||||||
|
nextPeriodStart: LocalDate?,
|
||||||
|
lastPeriodStart: LocalDate?,
|
||||||
|
fertileStart: LocalDate?,
|
||||||
|
fertileEnd: LocalDate?,
|
||||||
|
ovulationDate: LocalDate?,
|
||||||
|
pmsStart: LocalDate?,
|
||||||
|
periodLengthDays: Int = 5
|
||||||
|
): CyclePhase {
|
||||||
|
if (lastPeriodStart == null || nextPeriodStart == null) return UNKNOWN
|
||||||
|
|
||||||
|
// Определяем конец последней менструации
|
||||||
|
val lastPeriodEnd = lastPeriodStart.plusDays(periodLengthDays.toLong() - 1)
|
||||||
|
|
||||||
|
return when {
|
||||||
|
// Период менструации
|
||||||
|
(today.isEqual(lastPeriodStart) || today.isAfter(lastPeriodStart)) &&
|
||||||
|
(today.isEqual(lastPeriodEnd) || today.isBefore(lastPeriodEnd)) -> MENSTRUATION
|
||||||
|
|
||||||
|
// День овуляции
|
||||||
|
ovulationDate != null && today.isEqual(ovulationDate) -> OVULATION
|
||||||
|
|
||||||
|
// Фертильное окно
|
||||||
|
fertileStart != null && fertileEnd != null &&
|
||||||
|
(today.isEqual(fertileStart) || today.isAfter(fertileStart)) &&
|
||||||
|
(today.isEqual(fertileEnd) || today.isBefore(fertileEnd)) &&
|
||||||
|
(ovulationDate == null || !today.isEqual(ovulationDate)) -> FERTILE
|
||||||
|
|
||||||
|
// ПМС
|
||||||
|
pmsStart != null &&
|
||||||
|
(today.isEqual(pmsStart) || today.isAfter(pmsStart)) &&
|
||||||
|
today.isBefore(nextPeriodStart) -> PMS
|
||||||
|
|
||||||
|
// Лютеиновая фаза (после овуляции/фертильного окна до ПМС)
|
||||||
|
ovulationDate != null && fertileEnd != null && pmsStart != null &&
|
||||||
|
today.isAfter(fertileEnd) &&
|
||||||
|
today.isBefore(pmsStart) -> LUTEAL
|
||||||
|
|
||||||
|
// Фолликулярная фаза (после менструации до фертильного окна)
|
||||||
|
lastPeriodEnd != null && fertileStart != null &&
|
||||||
|
today.isAfter(lastPeriodEnd) &&
|
||||||
|
today.isBefore(fertileStart) -> FOLLICULAR
|
||||||
|
|
||||||
|
// Если не удалось определить фазу
|
||||||
|
else -> UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.models
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доменная модель для настроек цикла.
|
||||||
|
*/
|
||||||
|
data class CycleSettings(
|
||||||
|
// Основные параметры цикла
|
||||||
|
val baselineCycleLength: Int = 28,
|
||||||
|
val cycleVariabilityDays: Int = 3,
|
||||||
|
val periodLengthDays: Int = 5,
|
||||||
|
val lutealPhaseDays: String = "auto", // "auto" или число (8-17)
|
||||||
|
val lastPeriodStart: LocalDate? = null,
|
||||||
|
|
||||||
|
// Метод определения овуляции
|
||||||
|
val ovulationMethod: OvulationMethod = OvulationMethod.AUTO,
|
||||||
|
val allowManualOvulation: Boolean = false,
|
||||||
|
|
||||||
|
// Статусы влияющие на точность
|
||||||
|
val hormonalContraception: HormonalContraceptionType = HormonalContraceptionType.NONE,
|
||||||
|
val isPregnant: Boolean = false,
|
||||||
|
val isPostpartum: Boolean = false,
|
||||||
|
val isLactating: Boolean = false,
|
||||||
|
val perimenopause: Boolean = false,
|
||||||
|
|
||||||
|
// Настройки истории и исключения выбросов
|
||||||
|
val historyWindowCycles: Int = 6,
|
||||||
|
val excludeOutliers: Boolean = true,
|
||||||
|
|
||||||
|
// Сенсоры и единицы измерения
|
||||||
|
val tempUnit: TemperatureUnit = TemperatureUnit.CELSIUS,
|
||||||
|
val bbtTimeWindow: String = "06:00-10:00",
|
||||||
|
val timezone: String = "Asia/Seoul",
|
||||||
|
|
||||||
|
// Уведомления
|
||||||
|
val periodReminderDaysBefore: Int = 2,
|
||||||
|
val ovulationReminderDaysBefore: Int = 1,
|
||||||
|
val pmsWindowDays: Int = 3,
|
||||||
|
val deviationAlertDays: Int = 5,
|
||||||
|
val fertileWindowMode: FertileWindowMode = FertileWindowMode.BALANCED
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Метод определения овуляции
|
||||||
|
*/
|
||||||
|
enum class OvulationMethod {
|
||||||
|
AUTO, BBT, LH_TEST, CERVICAL_MUCUS, MEDICAL;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): OvulationMethod = when (value.lowercase()) {
|
||||||
|
"bbt" -> BBT
|
||||||
|
"lh_test" -> LH_TEST
|
||||||
|
"cervical_mucus" -> CERVICAL_MUCUS
|
||||||
|
"medical" -> MEDICAL
|
||||||
|
else -> AUTO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toStorageString(): String = this.name.lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип гормональной контрацепции
|
||||||
|
*/
|
||||||
|
enum class HormonalContraceptionType {
|
||||||
|
NONE, COC, IUD, IMPLANT, OTHER;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): HormonalContraceptionType = when (value.lowercase()) {
|
||||||
|
"coc" -> COC
|
||||||
|
"iud" -> IUD
|
||||||
|
"implant" -> IMPLANT
|
||||||
|
"other" -> OTHER
|
||||||
|
else -> NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toStorageString(): String = this.name.lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единицы измерения температуры
|
||||||
|
*/
|
||||||
|
enum class TemperatureUnit {
|
||||||
|
CELSIUS, FAHRENHEIT;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): TemperatureUnit = when (value.uppercase()) {
|
||||||
|
"F" -> FAHRENHEIT
|
||||||
|
else -> CELSIUS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toStorageString(): String = when (this) {
|
||||||
|
CELSIUS -> "C"
|
||||||
|
FAHRENHEIT -> "F"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Режим определения фертильного окна
|
||||||
|
*/
|
||||||
|
enum class FertileWindowMode {
|
||||||
|
CONSERVATIVE, BALANCED, BROAD;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): FertileWindowMode = when (value.lowercase()) {
|
||||||
|
"conservative" -> CONSERVATIVE
|
||||||
|
"broad" -> BROAD
|
||||||
|
else -> BALANCED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toStorageString(): String = this.name.lowercase()
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.models
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.ui.cycle.settings.FertileWindowMode
|
||||||
|
import kr.smartsoltech.wellshe.ui.cycle.settings.HormonalContraception
|
||||||
|
import kr.smartsoltech.wellshe.ui.cycle.settings.OvulationMethod
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
// Типы событий изменения настроек для старого интерфейса
|
||||||
|
sealed class BasicSettingChange {
|
||||||
|
data class CycleLengthChanged(val days: Int) : BasicSettingChange()
|
||||||
|
data class CycleVariabilityChanged(val days: Int) : BasicSettingChange()
|
||||||
|
data class PeriodLengthChanged(val days: Int) : BasicSettingChange()
|
||||||
|
data class LutealPhaseChanged(val days: String) : BasicSettingChange() // "auto" или число
|
||||||
|
data class LastPeriodStartChanged(val date: LocalDate) : BasicSettingChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class StatusChange {
|
||||||
|
data class HormonalContraceptionChanged(val type: HormonalContraception) : StatusChange()
|
||||||
|
data class PregnancyStatusChanged(val isPregnant: Boolean) : StatusChange()
|
||||||
|
data class PostpartumStatusChanged(val isPostpartum: Boolean) : StatusChange()
|
||||||
|
data class LactatingStatusChanged(val isLactating: Boolean) : StatusChange()
|
||||||
|
data class PerimenopauseStatusChanged(val perimenopause: Boolean) : StatusChange()
|
||||||
|
|
||||||
|
// Вложенные объекты для удобного создания событий
|
||||||
|
object Pregnant {
|
||||||
|
fun changed(value: Boolean) = PregnancyStatusChanged(value)
|
||||||
|
}
|
||||||
|
object Postpartum {
|
||||||
|
fun changed(value: Boolean) = PostpartumStatusChanged(value)
|
||||||
|
}
|
||||||
|
object Lactating {
|
||||||
|
fun changed(value: Boolean) = LactatingStatusChanged(value)
|
||||||
|
}
|
||||||
|
object Perimenopause {
|
||||||
|
fun changed(value: Boolean) = PerimenopauseStatusChanged(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class HistorySetting {
|
||||||
|
data class HistoryWindowChanged(val cycles: Int) : HistorySetting()
|
||||||
|
data class ExcludeOutliersChanged(val exclude: Boolean) : HistorySetting()
|
||||||
|
|
||||||
|
// Вложенные объекты для более удобного обращения
|
||||||
|
object BaselineCycleLength {
|
||||||
|
fun changed(value: Int) = BasicSettingChange.CycleLengthChanged(value)
|
||||||
|
}
|
||||||
|
object CycleVariability {
|
||||||
|
fun changed(value: Int) = BasicSettingChange.CycleVariabilityChanged(value)
|
||||||
|
}
|
||||||
|
object PeriodLength {
|
||||||
|
fun changed(value: Int) = BasicSettingChange.PeriodLengthChanged(value)
|
||||||
|
}
|
||||||
|
object LutealPhase {
|
||||||
|
fun changed(value: String) = BasicSettingChange.LutealPhaseChanged(value)
|
||||||
|
}
|
||||||
|
object LastPeriodStart {
|
||||||
|
fun changed(value: LocalDate) = BasicSettingChange.LastPeriodStartChanged(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вложенные классы для UI
|
||||||
|
class WindowCycles(val cycles: Int) : HistorySetting()
|
||||||
|
class ExcludeOutliers(val exclude: Boolean) : HistorySetting()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SensorSetting {
|
||||||
|
data class TemperatureUnitChanged(val unit: TemperatureUnit) : SensorSetting()
|
||||||
|
data class BbtTimeWindowChanged(val timeWindow: String) : SensorSetting()
|
||||||
|
data class TimezoneChanged(val timezone: String) : SensorSetting()
|
||||||
|
|
||||||
|
// Вложенные классы для UI
|
||||||
|
class TempUnit(val unit: TemperatureUnit) : SensorSetting()
|
||||||
|
class BbtTimeWindow(val timeWindow: String) : SensorSetting()
|
||||||
|
class Timezone(val timezone: String) : SensorSetting()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class NotificationSetting {
|
||||||
|
data class PeriodReminderDaysChanged(val days: Int) : NotificationSetting()
|
||||||
|
data class OvulationReminderDaysChanged(val days: Int) : NotificationSetting()
|
||||||
|
data class PmsWindowDaysChanged(val days: Int) : NotificationSetting()
|
||||||
|
data class DeviationAlertDaysChanged(val days: Int) : NotificationSetting()
|
||||||
|
data class FertileWindowModeChanged(val mode: FertileWindowMode) : NotificationSetting()
|
||||||
|
|
||||||
|
// Вложенные классы для UI
|
||||||
|
class PeriodReminder(val days: Int) : NotificationSetting()
|
||||||
|
class OvulationReminder(val days: Int) : NotificationSetting()
|
||||||
|
class PmsWindow(val days: Int) : NotificationSetting()
|
||||||
|
class DeviationAlert(val days: Int) : NotificationSetting()
|
||||||
|
class FertileWindowMode(val mode: kr.smartsoltech.wellshe.domain.models.FertileWindowMode) : NotificationSetting()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функции преобразования для всех типов
|
||||||
|
fun ovulationMethodFromString(value: String): OvulationMethod {
|
||||||
|
return when (value) {
|
||||||
|
"bbt" -> OvulationMethod.BBT
|
||||||
|
"lh_test" -> OvulationMethod.LH_TEST
|
||||||
|
"cervical_mucus" -> OvulationMethod.CERVICAL_MUCUS
|
||||||
|
"medical" -> OvulationMethod.MEDICAL
|
||||||
|
else -> OvulationMethod.AUTO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fertileWindowModeFromString(value: String): FertileWindowMode {
|
||||||
|
return when (value) {
|
||||||
|
"conservative" -> FertileWindowMode.CONSERVATIVE
|
||||||
|
"broad" -> FertileWindowMode.BROAD
|
||||||
|
else -> FertileWindowMode.BALANCED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hormonalContraceptionTypeFromString(value: String): HormonalContraception {
|
||||||
|
return when (value) {
|
||||||
|
"coc" -> HormonalContraception.COC
|
||||||
|
"iud" -> HormonalContraception.IUD
|
||||||
|
"implant" -> HormonalContraception.IMPLANT
|
||||||
|
"other" -> HormonalContraception.OTHER
|
||||||
|
else -> HormonalContraception.NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun temperatureUnitFromString(value: String): TemperatureUnit {
|
||||||
|
return when (value.uppercase()) {
|
||||||
|
"F" -> TemperatureUnit.FAHRENHEIT
|
||||||
|
else -> TemperatureUnit.CELSIUS
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Этот файл больше не используется, все классы перенесены в CycleSettingsEvents.kt
|
||||||
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package kr.smartsoltech.wellshe.domain.services
|
||||||
|
|
||||||
|
import com.squareup.moshi.JsonAdapter
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
|
||||||
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
|
import kr.smartsoltech.wellshe.data.entity.CycleSettingsEntity
|
||||||
|
import kr.smartsoltech.wellshe.domain.models.*
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для импорта и экспорта настроек цикла в JSON формате
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class CycleSettingsExportService @Inject constructor() {
|
||||||
|
|
||||||
|
// Создаем адаптер для LocalDate вне класса LocalDateAdapter
|
||||||
|
private val localDateAdapter = object : JsonAdapter<LocalDate>() {
|
||||||
|
override fun fromJson(reader: com.squareup.moshi.JsonReader): LocalDate? {
|
||||||
|
return try {
|
||||||
|
val dateString = reader.nextString()
|
||||||
|
LocalDate.parse(dateString)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toJson(writer: com.squareup.moshi.JsonWriter, value: LocalDate?) {
|
||||||
|
if (value == null) {
|
||||||
|
writer.nullValue()
|
||||||
|
} else {
|
||||||
|
writer.value(value.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настройка Moshi для сериализации/десериализации
|
||||||
|
private val moshi: Moshi = Moshi.Builder()
|
||||||
|
.add(KotlinJsonAdapterFactory())
|
||||||
|
.add(Date::class.java, Rfc3339DateJsonAdapter())
|
||||||
|
.add(LocalDate::class.java, localDateAdapter)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Адаптер для сериализации/десериализации настроек цикла
|
||||||
|
private val settingsAdapter: JsonAdapter<CycleSettingsJsonDto> = moshi.adapter(CycleSettingsJsonDto::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экспортирует настройки в формат JSON
|
||||||
|
*/
|
||||||
|
fun exportSettingsToJson(settings: CycleSettingsEntity): String {
|
||||||
|
val jsonDto = convertToJsonDto(settings)
|
||||||
|
return settingsAdapter.toJson(jsonDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Импортирует настройки из JSON
|
||||||
|
* @return Импортированные настройки или null в случае ошибки
|
||||||
|
*/
|
||||||
|
fun importSettingsFromJson(json: String): CycleSettingsEntity? {
|
||||||
|
return try {
|
||||||
|
val jsonDto = settingsAdapter.fromJson(json)
|
||||||
|
jsonDto?.let { convertToEntity(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует Entity в DTO для экспорта в JSON
|
||||||
|
*/
|
||||||
|
private fun convertToJsonDto(entity: CycleSettingsEntity): CycleSettingsJsonDto {
|
||||||
|
return CycleSettingsJsonDto(
|
||||||
|
baselineCycleLength = entity.baselineCycleLength,
|
||||||
|
cycleVariabilityDays = entity.cycleVariabilityDays,
|
||||||
|
periodLengthDays = entity.periodLengthDays,
|
||||||
|
lutealPhaseDays = entity.lutealPhaseDays,
|
||||||
|
lastPeriodStart = entity.lastPeriodStart,
|
||||||
|
ovulationMethod = entity.ovulationMethod,
|
||||||
|
allowManualOvulation = entity.allowManualOvulation,
|
||||||
|
hormonalContraception = entity.hormonalContraception,
|
||||||
|
isPregnant = entity.isPregnant,
|
||||||
|
isPostpartum = entity.isPostpartum,
|
||||||
|
isLactating = entity.isLactating,
|
||||||
|
perimenopause = entity.perimenopause,
|
||||||
|
historyWindowCycles = entity.historyWindowCycles,
|
||||||
|
excludeOutliers = entity.excludeOutliers,
|
||||||
|
tempUnit = entity.tempUnit,
|
||||||
|
bbtTimeWindow = entity.bbtTimeWindow,
|
||||||
|
timezone = entity.timezone,
|
||||||
|
periodReminderDaysBefore = entity.periodReminderDaysBefore,
|
||||||
|
ovulationReminderDaysBefore = entity.ovulationReminderDaysBefore,
|
||||||
|
pmsWindowDays = entity.pmsWindowDays,
|
||||||
|
deviationAlertDays = entity.deviationAlertDays,
|
||||||
|
fertileWindowMode = entity.fertileWindowMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует DTO в Entity
|
||||||
|
*/
|
||||||
|
private fun convertToEntity(dto: CycleSettingsJsonDto): CycleSettingsEntity {
|
||||||
|
return CycleSettingsEntity(
|
||||||
|
id = 1, // Singleton ID
|
||||||
|
baselineCycleLength = dto.baselineCycleLength.coerceIn(18, 60),
|
||||||
|
cycleVariabilityDays = dto.cycleVariabilityDays.coerceIn(0, 10),
|
||||||
|
periodLengthDays = dto.periodLengthDays.coerceIn(1, 10),
|
||||||
|
lutealPhaseDays = dto.lutealPhaseDays, // Валидация будет в ViewModel
|
||||||
|
lastPeriodStart = dto.lastPeriodStart,
|
||||||
|
ovulationMethod = dto.ovulationMethod,
|
||||||
|
allowManualOvulation = dto.allowManualOvulation,
|
||||||
|
hormonalContraception = dto.hormonalContraception,
|
||||||
|
isPregnant = dto.isPregnant,
|
||||||
|
isPostpartum = dto.isPostpartum,
|
||||||
|
isLactating = dto.isLactating,
|
||||||
|
perimenopause = dto.perimenopause,
|
||||||
|
historyWindowCycles = dto.historyWindowCycles,
|
||||||
|
excludeOutliers = dto.excludeOutliers,
|
||||||
|
tempUnit = dto.tempUnit,
|
||||||
|
bbtTimeWindow = dto.bbtTimeWindow,
|
||||||
|
timezone = dto.timezone,
|
||||||
|
periodReminderDaysBefore = dto.periodReminderDaysBefore.coerceIn(0, 7),
|
||||||
|
ovulationReminderDaysBefore = dto.ovulationReminderDaysBefore.coerceIn(0, 7),
|
||||||
|
pmsWindowDays = dto.pmsWindowDays.coerceIn(1, 7),
|
||||||
|
deviationAlertDays = dto.deviationAlertDays.coerceIn(1, 14),
|
||||||
|
fertileWindowMode = dto.fertileWindowMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO для сериализации/десериализации настроек цикла в JSON
|
||||||
|
*/
|
||||||
|
data class CycleSettingsJsonDto(
|
||||||
|
// Основные параметры цикла
|
||||||
|
val baselineCycleLength: Int = 28,
|
||||||
|
val cycleVariabilityDays: Int = 3,
|
||||||
|
val periodLengthDays: Int = 5,
|
||||||
|
val lutealPhaseDays: String = "auto",
|
||||||
|
val lastPeriodStart: LocalDate? = null,
|
||||||
|
|
||||||
|
// Метод определения овуляции
|
||||||
|
val ovulationMethod: String = "auto",
|
||||||
|
val allowManualOvulation: Boolean = false,
|
||||||
|
|
||||||
|
// Статусы влияющие на точность
|
||||||
|
val hormonalContraception: String = "none",
|
||||||
|
val isPregnant: Boolean = false,
|
||||||
|
val isPostpartum: Boolean = false,
|
||||||
|
val isLactating: Boolean = false,
|
||||||
|
val perimenopause: Boolean = false,
|
||||||
|
|
||||||
|
// Настройки истории и исключения выбросов
|
||||||
|
val historyWindowCycles: Int = 6,
|
||||||
|
val excludeOutliers: Boolean = true,
|
||||||
|
|
||||||
|
// Сенсоры и единицы измерения
|
||||||
|
val tempUnit: String = "C",
|
||||||
|
val bbtTimeWindow: String = "06:00-10:00",
|
||||||
|
val timezone: String = "Asia/Seoul",
|
||||||
|
|
||||||
|
// Уведомления
|
||||||
|
val periodReminderDaysBefore: Int = 2,
|
||||||
|
val ovulationReminderDaysBefore: Int = 1,
|
||||||
|
val pmsWindowDays: Int = 3,
|
||||||
|
val deviationAlertDays: Int = 5,
|
||||||
|
val fertileWindowMode: String = "balanced"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.data.api
|
||||||
|
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
// ========== API МОДЕЛИ ДАННЫХ ==========
|
||||||
|
|
||||||
|
data class CreateEmergencyRequest(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val alert_type: String = "general",
|
||||||
|
val message: String? = null,
|
||||||
|
val address: String? = null,
|
||||||
|
val contact_emergency_services: Boolean = true,
|
||||||
|
val notify_emergency_contacts: Boolean = true,
|
||||||
|
val battery_level: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyEventResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val alert_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val status: String,
|
||||||
|
val is_resolved: Boolean,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String?,
|
||||||
|
val resolved_at: String?,
|
||||||
|
val user: ApiUserInfo?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NearbyEventsResponse(
|
||||||
|
val events: List<NearbyEmergencyEvent>,
|
||||||
|
val total: Int,
|
||||||
|
val radius_km: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NearbyEmergencyEvent(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val alert_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val distance_km: Double,
|
||||||
|
val created_at: String,
|
||||||
|
val user: ApiUserInfo?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyEventDetailResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val alert_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val status: String,
|
||||||
|
val is_resolved: Boolean,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String?,
|
||||||
|
val resolved_at: String?,
|
||||||
|
val user: ApiUserInfo,
|
||||||
|
val responses: List<EventResponse>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventResponseRequest(
|
||||||
|
val response_type: String,
|
||||||
|
val message: String? = null,
|
||||||
|
val eta_minutes: Int? = null,
|
||||||
|
val location_latitude: Double? = null,
|
||||||
|
val location_longitude: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventResponseResponse(
|
||||||
|
val id: Int,
|
||||||
|
val event_id: Int,
|
||||||
|
val user_id: Int,
|
||||||
|
val response_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val eta_minutes: Int?,
|
||||||
|
val location_latitude: Double?,
|
||||||
|
val location_longitude: Double?,
|
||||||
|
val created_at: String,
|
||||||
|
val user: ApiUserInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventHistoryResponse(
|
||||||
|
val events: List<EmergencyEventResponse>,
|
||||||
|
val total: Int,
|
||||||
|
val page: Int,
|
||||||
|
val per_page: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NearbyUsersResponse(
|
||||||
|
val users: List<ApiUserInfo>,
|
||||||
|
val total: Int,
|
||||||
|
val radius_km: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventsStatsResponse(
|
||||||
|
val total_events: Int,
|
||||||
|
val active_events: Int,
|
||||||
|
val resolved_events: Int,
|
||||||
|
val my_events: Int,
|
||||||
|
val my_responses: Int,
|
||||||
|
val average_response_time_minutes: Double?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ApiUserInfo(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val email: String?,
|
||||||
|
val phone: String?,
|
||||||
|
val profile_image_url: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventResponse(
|
||||||
|
val id: Int,
|
||||||
|
val user_id: Int,
|
||||||
|
val response_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val eta_minutes: Int?,
|
||||||
|
val created_at: String,
|
||||||
|
val user: ApiUserInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateReportRequest(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val report_type: String,
|
||||||
|
val description: String,
|
||||||
|
val address: String? = null,
|
||||||
|
val is_anonymous: Boolean = false,
|
||||||
|
val severity: Int = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReportResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int?,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val report_type: String,
|
||||||
|
val description: String,
|
||||||
|
val is_anonymous: Boolean,
|
||||||
|
val severity: Int,
|
||||||
|
val status: String = "pending",
|
||||||
|
val created_at: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateSafetyCheckRequest(
|
||||||
|
val message: String? = null,
|
||||||
|
val location_latitude: Double? = null,
|
||||||
|
val location_longitude: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SafetyCheckResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int,
|
||||||
|
val message: String?,
|
||||||
|
val location_latitude: Double?,
|
||||||
|
val location_longitude: Double?,
|
||||||
|
val created_at: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateAlertRequest(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val alert_type: String = "general",
|
||||||
|
val message: String? = null,
|
||||||
|
val address: String? = null,
|
||||||
|
val contact_emergency_services: Boolean = true,
|
||||||
|
val notify_emergency_contacts: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdateAlertRequest(
|
||||||
|
val message: String? = null,
|
||||||
|
val is_resolved: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WebSocketConnectionsResponse(
|
||||||
|
val total_connections: Int,
|
||||||
|
val active_connections: Int,
|
||||||
|
val connections: List<ConnectionInfo>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ConnectionInfo(
|
||||||
|
val user_id: Int,
|
||||||
|
val connected_at: String,
|
||||||
|
val last_ping: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserWebSocketInfoResponse(
|
||||||
|
val user_id: Int,
|
||||||
|
val is_connected: Boolean,
|
||||||
|
val connected_at: String?,
|
||||||
|
val last_ping: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WebSocketPingResponse(
|
||||||
|
val total_pinged: Int,
|
||||||
|
val responses: Int,
|
||||||
|
val disconnected: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WebSocketStatsResponse(
|
||||||
|
val total_connections: Int,
|
||||||
|
val active_connections: Int,
|
||||||
|
val total_messages_sent: Int,
|
||||||
|
val uptime_seconds: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BroadcastResponse(
|
||||||
|
val message_id: String,
|
||||||
|
val recipients: Int,
|
||||||
|
val delivered: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== API ИНТЕРФЕЙС ==========
|
||||||
|
|
||||||
|
interface EmergencyApiService {
|
||||||
|
|
||||||
|
// Основные операции с alerts/events (соответствуют реальному API)
|
||||||
|
@POST("api/v1/emergency/events")
|
||||||
|
suspend fun createEmergencyEvent(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Body request: CreateEmergencyRequest
|
||||||
|
): Response<EmergencyEventResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/events/nearby")
|
||||||
|
suspend fun getNearbyEvents(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Query("latitude") latitude: Double,
|
||||||
|
@Query("longitude") longitude: Double,
|
||||||
|
@Query("radius") radius: Double = 5.0
|
||||||
|
): Response<NearbyEventsResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/events/{eventId}")
|
||||||
|
suspend fun getEventDetails(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("eventId") eventId: Int
|
||||||
|
): Response<EmergencyEventDetailResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/emergency/events/{eventId}/respond")
|
||||||
|
suspend fun respondToEvent(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("eventId") eventId: Int,
|
||||||
|
@Body response: EventResponseRequest
|
||||||
|
): Response<EventResponseResponse>
|
||||||
|
|
||||||
|
@PUT("api/v1/emergency/events/{eventId}/resolve")
|
||||||
|
suspend fun updateEventStatus(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("eventId") eventId: Int
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/events/my")
|
||||||
|
suspend fun getEventHistory(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<EventHistoryResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/alerts/nearby")
|
||||||
|
suspend fun getNearbyUsers(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Query("latitude") latitude: Double,
|
||||||
|
@Query("longitude") longitude: Double,
|
||||||
|
@Query("radius_km") radiusKm: Double = 5.0
|
||||||
|
): Response<NearbyUsersResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/stats")
|
||||||
|
suspend fun getEmergencyStats(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<EventsStatsResponse>
|
||||||
|
|
||||||
|
// ========== ДОПОЛНИТЕЛЬНЫЕ ЭНДПОИНТЫ ДЛЯ 100% ПОКРЫТИЯ ==========
|
||||||
|
|
||||||
|
// Reports API
|
||||||
|
@POST("api/v1/report")
|
||||||
|
suspend fun createEmergencyReport(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Body request: CreateReportRequest
|
||||||
|
): Response<ReportResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/reports")
|
||||||
|
suspend fun getEmergencyReports(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<List<ReportResponse>>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/reports")
|
||||||
|
suspend fun getEmergencyReportsAdmin(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<List<EmergencyEventResponse>>
|
||||||
|
|
||||||
|
// Safety Check API
|
||||||
|
@POST("api/v1/safety-check")
|
||||||
|
suspend fun createSafetyCheck(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Body request: CreateSafetyCheckRequest
|
||||||
|
): Response<SafetyCheckResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/safety-checks")
|
||||||
|
suspend fun getSafetyChecks(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<List<SafetyCheckResponse>>
|
||||||
|
|
||||||
|
// Alert Management API
|
||||||
|
@POST("api/v1/alert")
|
||||||
|
suspend fun createAlert(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Body request: CreateAlertRequest
|
||||||
|
): Response<EmergencyEventResponse>
|
||||||
|
|
||||||
|
@PUT("api/v1/alert/{alert_id}")
|
||||||
|
suspend fun updateAlert(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: Int,
|
||||||
|
@Body request: UpdateAlertRequest
|
||||||
|
): Response<EmergencyEventResponse>
|
||||||
|
|
||||||
|
@PUT("api/v1/alert/{alert_id}/resolve")
|
||||||
|
suspend fun resolveAlert(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: Int
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/v1/alert/{alert_id}/respond")
|
||||||
|
suspend fun respondToAlert(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: Int,
|
||||||
|
@Body request: EventResponseRequest
|
||||||
|
): Response<EventResponseResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/alert/{alert_id}/responses")
|
||||||
|
suspend fun getAlertResponses(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: Int
|
||||||
|
): Response<List<EventResponseResponse>>
|
||||||
|
|
||||||
|
@GET("api/v1/alerts/my")
|
||||||
|
suspend fun getMyAlerts(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<List<EmergencyEventResponse>>
|
||||||
|
|
||||||
|
@GET("api/v1/alerts/active")
|
||||||
|
suspend fun getActiveAlerts(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<List<EmergencyEventResponse>>
|
||||||
|
|
||||||
|
// WebSocket Management API
|
||||||
|
@GET("api/v1/websocket/connections")
|
||||||
|
suspend fun getWebsocketConnections(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<WebSocketConnectionsResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/websocket/connections/{user_id}")
|
||||||
|
suspend fun getUserWebsocketInfo(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("user_id") userId: Int
|
||||||
|
): Response<UserWebSocketInfoResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/websocket/ping")
|
||||||
|
suspend fun pingWebsocketConnections(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<WebSocketPingResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/websocket/stats")
|
||||||
|
suspend fun getWebsocketStats(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<WebSocketStatsResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/websocket/broadcast")
|
||||||
|
suspend fun broadcastTestMessage(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Query("message") message: String
|
||||||
|
): Response<BroadcastResponse>
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.data.api
|
||||||
|
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Service соответствующий реальному Emergency Service
|
||||||
|
* Основан на OpenAPI спецификации http://localhost:8002/openapi.json
|
||||||
|
*/
|
||||||
|
interface RealEmergencyApiService {
|
||||||
|
|
||||||
|
// ========== ОСНОВНЫЕ ALERT ОПЕРАЦИИ ==========
|
||||||
|
|
||||||
|
@POST("api/v1/alert")
|
||||||
|
suspend fun createAlert(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Body request: CreateAlertRequest
|
||||||
|
): Response<AlertResponse>
|
||||||
|
|
||||||
|
@PUT("api/v1/alert/{alert_id}")
|
||||||
|
suspend fun updateAlert(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: Int,
|
||||||
|
@Body request: UpdateAlertRequest
|
||||||
|
): Response<AlertResponse>
|
||||||
|
|
||||||
|
@PUT("api/v1/alert/{alert_id}/resolve")
|
||||||
|
suspend fun resolveAlert(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: Int
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
// ========== ПОЛУЧЕНИЕ ALERTS ==========
|
||||||
|
|
||||||
|
@GET("api/v1/alerts/my")
|
||||||
|
suspend fun getMyAlerts(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<List<AlertResponse>>
|
||||||
|
|
||||||
|
@GET("api/v1/alerts/active")
|
||||||
|
suspend fun getActiveAlerts(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<List<AlertResponse>>
|
||||||
|
|
||||||
|
@GET("api/v1/alerts/nearby")
|
||||||
|
suspend fun getNearbyAlerts(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Query("latitude") latitude: Double,
|
||||||
|
@Query("longitude") longitude: Double,
|
||||||
|
@Query("radius_km") radiusKm: Double = 10.0
|
||||||
|
): Response<List<NearbyAlertResponse>>
|
||||||
|
|
||||||
|
// ========== EMERGENCY EVENTS (Mobile Compatibility) ==========
|
||||||
|
|
||||||
|
@POST("api/v1/emergency/events")
|
||||||
|
suspend fun createEmergencyEvent(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Body request: CreateAlertRequest
|
||||||
|
): Response<AlertResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/events")
|
||||||
|
suspend fun getEmergencyEvents(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<List<AlertResponse>>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/events/my")
|
||||||
|
suspend fun getMyEmergencyEvents(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<List<AlertResponse>>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/events/nearby")
|
||||||
|
suspend fun getNearbyEmergencyEvents(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Query("latitude") latitude: Double,
|
||||||
|
@Query("longitude") longitude: Double,
|
||||||
|
@Query("radius") radius: Double = 5.0
|
||||||
|
): Response<List<NearbyAlertResponse>>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/events/{event_id}")
|
||||||
|
suspend fun getEmergencyEventDetails(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("event_id") eventId: Int
|
||||||
|
): Response<EmergencyEventDetailsResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/events/{event_id}/brief")
|
||||||
|
suspend fun getEmergencyEventBrief(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("event_id") eventId: Int
|
||||||
|
): Response<AlertResponse>
|
||||||
|
|
||||||
|
@PUT("api/v1/emergency/events/{event_id}/resolve")
|
||||||
|
suspend fun resolveEmergencyEvent(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("event_id") eventId: Int
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
// ========== RESPONSES ==========
|
||||||
|
|
||||||
|
@POST("api/v1/alert/{alert_id}/respond")
|
||||||
|
suspend fun respondToAlert(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: Int,
|
||||||
|
@Body request: ResponseCreateRequest
|
||||||
|
): Response<ResponseResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/emergency/events/{event_id}/respond")
|
||||||
|
suspend fun respondToEmergencyEvent(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("event_id") eventId: Int,
|
||||||
|
@Body request: ResponseCreateRequest
|
||||||
|
): Response<ResponseResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/alert/{alert_id}/responses")
|
||||||
|
suspend fun getAlertResponses(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("alert_id") alertId: Int
|
||||||
|
): Response<List<ResponseResponse>>
|
||||||
|
|
||||||
|
// ========== REPORTS ==========
|
||||||
|
|
||||||
|
@POST("api/v1/report")
|
||||||
|
suspend fun createEmergencyReport(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Body request: CreateReportRequest
|
||||||
|
): Response<ReportResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/reports")
|
||||||
|
suspend fun getEmergencyReports(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<List<ReportResponse>>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/reports")
|
||||||
|
suspend fun getEmergencyReportsAdmin(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<List<AlertResponse>>
|
||||||
|
|
||||||
|
// ========== SAFETY CHECK ==========
|
||||||
|
|
||||||
|
@POST("api/v1/safety-check")
|
||||||
|
suspend fun createSafetyCheck(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Body request: CreateSafetyCheckRequest
|
||||||
|
): Response<SafetyCheckResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/safety-checks")
|
||||||
|
suspend fun getSafetyChecks(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<List<SafetyCheckResponse>>
|
||||||
|
|
||||||
|
// ========== STATISTICS ==========
|
||||||
|
|
||||||
|
@GET("api/v1/stats")
|
||||||
|
suspend fun getEmergencyStats(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<EmergencyStatsResponse>
|
||||||
|
|
||||||
|
// ========== WEBSOCKET INFO ==========
|
||||||
|
|
||||||
|
@GET("api/v1/websocket/connections")
|
||||||
|
suspend fun getWebsocketConnections(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<WebSocketConnectionsResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/websocket/connections/{user_id}")
|
||||||
|
suspend fun getUserWebsocketInfo(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Path("user_id") userId: Int
|
||||||
|
): Response<UserWebSocketInfoResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/websocket/ping")
|
||||||
|
suspend fun pingWebsocketConnections(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<WebSocketPingResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/websocket/stats")
|
||||||
|
suspend fun getWebsocketStats(
|
||||||
|
@Header("Authorization") token: String
|
||||||
|
): Response<WebSocketStatsResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/websocket/broadcast")
|
||||||
|
suspend fun broadcastTestMessage(
|
||||||
|
@Header("Authorization") token: String,
|
||||||
|
@Query("message") message: String
|
||||||
|
): Response<BroadcastResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ДОПОЛНИТЕЛЬНЫЕ МОДЕЛИ ДЛЯ RealEmergencyApiService ==========
|
||||||
|
|
||||||
|
data class AlertResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val alert_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val is_resolved: Boolean = false,
|
||||||
|
val resolved_at: String?,
|
||||||
|
val resolved_notes: String?,
|
||||||
|
val notified_users_count: Int = 0,
|
||||||
|
val responded_users_count: Int = 0,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String?,
|
||||||
|
val user_name: String?,
|
||||||
|
val user_phone: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NearbyAlertResponse(
|
||||||
|
val id: Int,
|
||||||
|
val alert_type: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val distance_km: Double,
|
||||||
|
val created_at: String,
|
||||||
|
val responded_users_count: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyEventDetailsResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val alert_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val status: String,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String?,
|
||||||
|
val resolved_at: String?,
|
||||||
|
val user: UserInfoResponse,
|
||||||
|
val responses: List<ResponseResponse> = emptyList(),
|
||||||
|
val notifications_sent: Int = 0,
|
||||||
|
val websocket_notifications_sent: Int = 0,
|
||||||
|
val push_notifications_sent: Int = 0,
|
||||||
|
val contact_emergency_services: Boolean = true,
|
||||||
|
val notify_emergency_contacts: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ResponseCreateRequest(
|
||||||
|
val response_type: String,
|
||||||
|
val message: String? = null,
|
||||||
|
val eta_minutes: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ResponseResponse(
|
||||||
|
val id: Int,
|
||||||
|
val alert_id: Int,
|
||||||
|
val responder_id: Int,
|
||||||
|
val response_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val eta_minutes: Int?,
|
||||||
|
val created_at: String,
|
||||||
|
val responder_name: String?,
|
||||||
|
val responder_phone: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyStatsResponse(
|
||||||
|
val total_alerts: Int = 0,
|
||||||
|
val active_alerts: Int = 0,
|
||||||
|
val resolved_alerts: Int = 0,
|
||||||
|
val total_responders: Int = 0,
|
||||||
|
val avg_response_time_minutes: Double = 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserInfoResponse(
|
||||||
|
val id: Int,
|
||||||
|
val username: String,
|
||||||
|
val full_name: String?,
|
||||||
|
val phone: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.data.api.models
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
// ========== ОСНОВНЫЕ API МОДЕЛИ ==========
|
||||||
|
|
||||||
|
data class CreateEmergencyRequest(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val alert_type: String = "general",
|
||||||
|
val message: String? = null,
|
||||||
|
val address: String? = null,
|
||||||
|
val contact_emergency_services: Boolean = true,
|
||||||
|
val notify_emergency_contacts: Boolean = true,
|
||||||
|
val battery_level: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyEventResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val alert_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val status: String,
|
||||||
|
val is_resolved: Boolean,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String?,
|
||||||
|
val resolved_at: String?,
|
||||||
|
val user: ApiUserInfo?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NearbyEventsResponse(
|
||||||
|
val events: List<NearbyEmergencyEvent>,
|
||||||
|
val total: Int,
|
||||||
|
val radius_km: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NearbyEmergencyEvent(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val alert_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val distance_km: Double,
|
||||||
|
val created_at: String,
|
||||||
|
val user: ApiUserInfo?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyEventDetailResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val alert_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val status: String,
|
||||||
|
val is_resolved: Boolean,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String?,
|
||||||
|
val resolved_at: String?,
|
||||||
|
val user: ApiUserInfo,
|
||||||
|
val responses: List<EventResponse>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventResponseRequest(
|
||||||
|
val response_type: String,
|
||||||
|
val message: String? = null,
|
||||||
|
val eta_minutes: Int? = null,
|
||||||
|
val location_latitude: Double? = null,
|
||||||
|
val location_longitude: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventResponseResponse(
|
||||||
|
val id: Int,
|
||||||
|
val event_id: Int,
|
||||||
|
val user_id: Int,
|
||||||
|
val response_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val eta_minutes: Int?,
|
||||||
|
val location_latitude: Double?,
|
||||||
|
val location_longitude: Double?,
|
||||||
|
val created_at: String,
|
||||||
|
val user: ApiUserInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventHistoryResponse(
|
||||||
|
val events: List<EmergencyEventResponse>,
|
||||||
|
val total: Int,
|
||||||
|
val page: Int,
|
||||||
|
val per_page: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NearbyUsersResponse(
|
||||||
|
val users: List<ApiUserInfo>,
|
||||||
|
val total: Int,
|
||||||
|
val radius_km: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventsStatsResponse(
|
||||||
|
val total_events: Int,
|
||||||
|
val active_events: Int,
|
||||||
|
val resolved_events: Int,
|
||||||
|
val my_events: Int,
|
||||||
|
val my_responses: Int,
|
||||||
|
val average_response_time_minutes: Double?
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== ВСПОМОГАТЕЛЬНЫЕ МОДЕЛИ ==========
|
||||||
|
|
||||||
|
data class ApiUserInfo(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val email: String?,
|
||||||
|
val phone: String?,
|
||||||
|
val profile_image_url: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventResponse(
|
||||||
|
val id: Int,
|
||||||
|
val user_id: Int,
|
||||||
|
val response_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val eta_minutes: Int?,
|
||||||
|
val created_at: String,
|
||||||
|
val user: ApiUserInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== ДОПОЛНИТЕЛЬНЫЕ МОДЕЛИ (уже определены в EmergencyApiService.kt) ==========
|
||||||
|
|
||||||
|
data class CreateReportRequest(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val report_type: String,
|
||||||
|
val description: String,
|
||||||
|
val address: String? = null,
|
||||||
|
val is_anonymous: Boolean = false,
|
||||||
|
val severity: Int = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReportResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int?,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val report_type: String,
|
||||||
|
val description: String,
|
||||||
|
val is_anonymous: Boolean,
|
||||||
|
val severity: Int,
|
||||||
|
val status: String = "pending",
|
||||||
|
val created_at: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateSafetyCheckRequest(
|
||||||
|
val message: String? = null,
|
||||||
|
val location_latitude: Double? = null,
|
||||||
|
val location_longitude: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SafetyCheckResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int,
|
||||||
|
val message: String?,
|
||||||
|
val location_latitude: Double?,
|
||||||
|
val location_longitude: Double?,
|
||||||
|
val created_at: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateAlertRequest(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val alert_type: String = "general",
|
||||||
|
val message: String? = null,
|
||||||
|
val address: String? = null,
|
||||||
|
val contact_emergency_services: Boolean = true,
|
||||||
|
val notify_emergency_contacts: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdateAlertRequest(
|
||||||
|
val message: String? = null,
|
||||||
|
val is_resolved: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WebSocketConnectionsResponse(
|
||||||
|
val total_connections: Int,
|
||||||
|
val active_connections: Int,
|
||||||
|
val connections: List<ConnectionInfo>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ConnectionInfo(
|
||||||
|
val user_id: Int,
|
||||||
|
val connected_at: String,
|
||||||
|
val last_ping: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserWebSocketInfoResponse(
|
||||||
|
val user_id: Int,
|
||||||
|
val is_connected: Boolean,
|
||||||
|
val connected_at: String?,
|
||||||
|
val last_ping: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WebSocketPingResponse(
|
||||||
|
val total_pinged: Int,
|
||||||
|
val responses: Int,
|
||||||
|
val disconnected: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WebSocketStatsResponse(
|
||||||
|
val total_connections: Int,
|
||||||
|
val active_connections: Int,
|
||||||
|
val total_messages_sent: Int,
|
||||||
|
val uptime_seconds: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BroadcastResponse(
|
||||||
|
val message_id: String,
|
||||||
|
val recipients: Int,
|
||||||
|
val delivered: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== ДОПОЛНИТЕЛЬНЫЕ МОДЕЛИ ДЛЯ RealEmergencyApiService ==========
|
||||||
|
|
||||||
|
data class AlertResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val alert_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val is_resolved: Boolean = false,
|
||||||
|
val resolved_at: String?,
|
||||||
|
val resolved_notes: String?,
|
||||||
|
val notified_users_count: Int = 0,
|
||||||
|
val responded_users_count: Int = 0,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String?,
|
||||||
|
val user_name: String?,
|
||||||
|
val user_phone: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NearbyAlertResponse(
|
||||||
|
val id: Int,
|
||||||
|
val alert_type: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val distance_km: Double,
|
||||||
|
val created_at: String,
|
||||||
|
val responded_users_count: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyEventDetailsResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val alert_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val status: String,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String?,
|
||||||
|
val resolved_at: String?,
|
||||||
|
val user: UserInfoResponse,
|
||||||
|
val responses: List<ResponseResponse> = emptyList(),
|
||||||
|
val notifications_sent: Int = 0,
|
||||||
|
val websocket_notifications_sent: Int = 0,
|
||||||
|
val push_notifications_sent: Int = 0,
|
||||||
|
val contact_emergency_services: Boolean = true,
|
||||||
|
val notify_emergency_contacts: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ResponseCreateRequest(
|
||||||
|
val response_type: String,
|
||||||
|
val message: String? = null,
|
||||||
|
val eta_minutes: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ResponseResponse(
|
||||||
|
val id: Int,
|
||||||
|
val alert_id: Int,
|
||||||
|
val responder_id: Int,
|
||||||
|
val response_type: String,
|
||||||
|
val message: String?,
|
||||||
|
val eta_minutes: Int?,
|
||||||
|
val created_at: String,
|
||||||
|
val responder_name: String?,
|
||||||
|
val responder_phone: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyStatsResponse(
|
||||||
|
val total_alerts: Int = 0,
|
||||||
|
val active_alerts: Int = 0,
|
||||||
|
val resolved_alerts: Int = 0,
|
||||||
|
val total_responders: Int = 0,
|
||||||
|
val avg_response_time_minutes: Double = 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserInfoResponse(
|
||||||
|
val id: Int,
|
||||||
|
val username: String,
|
||||||
|
val full_name: String?,
|
||||||
|
val phone: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.data.auth
|
||||||
|
|
||||||
|
interface AuthManager {
|
||||||
|
suspend fun getToken(): String?
|
||||||
|
suspend fun getCurrentUserId(): String?
|
||||||
|
suspend fun isAuthenticated(): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthManagerImpl : AuthManager {
|
||||||
|
override suspend fun getToken(): String? {
|
||||||
|
// TODO: Implement proper token retrieval
|
||||||
|
return "demo_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getCurrentUserId(): String? {
|
||||||
|
// TODO: Implement proper user ID retrieval
|
||||||
|
return "demo_user_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun isAuthenticated(): Boolean {
|
||||||
|
return getToken() != null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.data.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.entities.EmergencyEventEntity
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.entities.EmergencyResponseEntity
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface EmergencyDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM emergency_events WHERE status = 'ACTIVE' ORDER BY createdAt DESC")
|
||||||
|
fun getActiveEvents(): Flow<List<EmergencyEventEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM emergency_events WHERE status = 'ACTIVE' ORDER BY createdAt DESC")
|
||||||
|
fun observeActiveEvents(): Flow<List<EmergencyEventEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM emergency_events ORDER BY createdAt DESC")
|
||||||
|
fun observeEvents(): Flow<List<EmergencyEventEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM emergency_events WHERE userId = :userId ORDER BY createdAt DESC LIMIT :limit")
|
||||||
|
suspend fun getUserEvents(userId: String, limit: Int = 20): List<EmergencyEventEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM emergency_events WHERE id = :eventId")
|
||||||
|
suspend fun getEventById(eventId: String): EmergencyEventEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM emergency_events WHERE isLocal = 1 AND syncedAt IS NULL")
|
||||||
|
suspend fun getUnsyncedEvents(): List<EmergencyEventEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertEvent(event: EmergencyEventEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateEvent(event: EmergencyEventEntity)
|
||||||
|
|
||||||
|
@Query("UPDATE emergency_events SET syncedAt = :syncedAt, isLocal = 0 WHERE id = :localId")
|
||||||
|
suspend fun markEventAsSynced(localId: String, syncedAt: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE emergency_events SET status = :status WHERE id = :eventId")
|
||||||
|
suspend fun updateEventStatus(eventId: String, status: EmergencyStatus)
|
||||||
|
|
||||||
|
@Query("DELETE FROM emergency_events WHERE status = 'EXPIRED' AND expiresAt < :currentTime")
|
||||||
|
suspend fun deleteExpiredEvents(currentTime: Long = System.currentTimeMillis())
|
||||||
|
|
||||||
|
// Emergency Responses
|
||||||
|
@Query("SELECT * FROM emergency_responses WHERE eventId = :eventId ORDER BY createdAt DESC")
|
||||||
|
suspend fun getEventResponses(eventId: String): List<EmergencyResponseEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertResponse(response: EmergencyResponseEntity)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.data.entities
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyType
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity(tableName = "emergency_events")
|
||||||
|
data class EmergencyEventEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val userId: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val eventType: EmergencyType,
|
||||||
|
val message: String?,
|
||||||
|
val status: EmergencyStatus,
|
||||||
|
val severity: Int,
|
||||||
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
val updatedAt: Long = System.currentTimeMillis(),
|
||||||
|
val expiresAt: Long = System.currentTimeMillis() + (30 * 60 * 1000), // 30 минут
|
||||||
|
val syncedAt: Long? = null,
|
||||||
|
val isLocal: Boolean = true,
|
||||||
|
val distanceMeters: Double? = null,
|
||||||
|
@Embedded(prefix = "user_")
|
||||||
|
val userInfo: UserInfo? = null,
|
||||||
|
val nearbyUsersNotified: Int? = null,
|
||||||
|
val responseCount: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserInfo(
|
||||||
|
val firstName: String,
|
||||||
|
val age: Int?,
|
||||||
|
val avatarUrl: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "emergency_responses")
|
||||||
|
data class EmergencyResponseEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val eventId: String,
|
||||||
|
val responderId: String,
|
||||||
|
val responseType: String,
|
||||||
|
val message: String?,
|
||||||
|
val estimatedArrival: Long?,
|
||||||
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
@Embedded(prefix = "responder_")
|
||||||
|
val responderInfo: UserInfo? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.data.mappers
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.api.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.entities.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.models.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class EmergencyMapper @Inject constructor() {
|
||||||
|
|
||||||
|
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault())
|
||||||
|
|
||||||
|
// API mappings
|
||||||
|
fun toApiRequest(request: kr.smartsoltech.wellshe.emergency.domain.models.CreateEmergencyRequest): kr.smartsoltech.wellshe.emergency.data.api.CreateEmergencyRequest {
|
||||||
|
return kr.smartsoltech.wellshe.emergency.data.api.CreateEmergencyRequest(
|
||||||
|
latitude = request.latitude,
|
||||||
|
longitude = request.longitude,
|
||||||
|
alert_type = when(request.eventType) {
|
||||||
|
EmergencyType.SOS -> "general"
|
||||||
|
EmergencyType.MEDICAL -> "medical"
|
||||||
|
EmergencyType.FIRE -> "fire"
|
||||||
|
EmergencyType.ACCIDENT -> "accident"
|
||||||
|
else -> "general"
|
||||||
|
},
|
||||||
|
message = request.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toEmergencyEventResponse(response: kr.smartsoltech.wellshe.emergency.data.api.EmergencyEventResponse): kr.smartsoltech.wellshe.emergency.domain.models.EmergencyEventResponse {
|
||||||
|
android.util.Log.d("EmergencyMapper", "Raw server response: id=${response.id}, uuid=${response.uuid}")
|
||||||
|
|
||||||
|
return kr.smartsoltech.wellshe.emergency.domain.models.EmergencyEventResponse(
|
||||||
|
eventId = response.uuid, // Используем UUID как eventId
|
||||||
|
status = if (response.is_resolved) "resolved" else "active",
|
||||||
|
nearbyUsersNotified = 0, // TODO: добавить в API модель
|
||||||
|
estimatedResponseTime = null,
|
||||||
|
createdAt = response.created_at,
|
||||||
|
expiresAt = (System.currentTimeMillis() + 1800000).toString() // 30 минут
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toEmergencyEvent(apiEvent: kr.smartsoltech.wellshe.emergency.data.api.NearbyEmergencyEvent): EmergencyEvent {
|
||||||
|
return EmergencyEvent(
|
||||||
|
id = apiEvent.id.toString(),
|
||||||
|
userId = "",
|
||||||
|
latitude = apiEvent.latitude,
|
||||||
|
longitude = apiEvent.longitude,
|
||||||
|
eventType = mapAlertTypeToEmergencyType(apiEvent.alert_type),
|
||||||
|
message = apiEvent.message ?: "",
|
||||||
|
status = EmergencyStatus.ACTIVE,
|
||||||
|
severity = 5,
|
||||||
|
createdAt = parseDate(apiEvent.created_at),
|
||||||
|
updatedAt = parseDate(apiEvent.created_at),
|
||||||
|
expiresAt = parseDate(apiEvent.created_at) + (30 * 60 * 1000),
|
||||||
|
distanceMeters = apiEvent.distance_km * 1000,
|
||||||
|
responseCount = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toEmergencyEventDetail(response: kr.smartsoltech.wellshe.emergency.data.api.EmergencyEventDetailResponse): EmergencyEventDetail {
|
||||||
|
return EmergencyEventDetail(
|
||||||
|
id = response.uuid,
|
||||||
|
userId = response.user_id.toString(),
|
||||||
|
latitude = response.latitude,
|
||||||
|
longitude = response.longitude,
|
||||||
|
eventType = mapAlertTypeToEmergencyType(response.alert_type),
|
||||||
|
message = response.message ?: "",
|
||||||
|
status = mapAlertStatusToEmergencyStatus(response.status),
|
||||||
|
severity = 5,
|
||||||
|
createdAt = parseDate(response.created_at),
|
||||||
|
updatedAt = parseDate(response.updated_at ?: response.created_at),
|
||||||
|
expiresAt = parseDate(response.created_at) + (30 * 60 * 1000),
|
||||||
|
responses = response.responses.map { toEventResponse(it) },
|
||||||
|
metadata = emptyMap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity mappings
|
||||||
|
fun toEmergencyEventEntity(request: kr.smartsoltech.wellshe.emergency.domain.models.CreateEmergencyRequest, userId: String): EmergencyEventEntity {
|
||||||
|
return EmergencyEventEntity(
|
||||||
|
userId = userId,
|
||||||
|
latitude = request.latitude,
|
||||||
|
longitude = request.longitude,
|
||||||
|
eventType = request.eventType,
|
||||||
|
message = request.message,
|
||||||
|
status = EmergencyStatus.ACTIVE,
|
||||||
|
severity = request.severity,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
expiresAt = System.currentTimeMillis() + (30 * 60 * 1000), // 30 minutes
|
||||||
|
isLocal = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toEmergencyEvent(entity: EmergencyEventEntity): EmergencyEvent {
|
||||||
|
return EmergencyEvent(
|
||||||
|
id = entity.id,
|
||||||
|
userId = entity.userId,
|
||||||
|
latitude = entity.latitude,
|
||||||
|
longitude = entity.longitude,
|
||||||
|
eventType = entity.eventType,
|
||||||
|
message = entity.message,
|
||||||
|
status = entity.status,
|
||||||
|
severity = entity.severity,
|
||||||
|
createdAt = entity.createdAt,
|
||||||
|
updatedAt = entity.updatedAt,
|
||||||
|
expiresAt = entity.expiresAt,
|
||||||
|
distanceMeters = entity.distanceMeters,
|
||||||
|
userInfo = entity.userInfo?.let {
|
||||||
|
kr.smartsoltech.wellshe.emergency.domain.models.UserInfo(
|
||||||
|
firstName = it.firstName,
|
||||||
|
age = it.age,
|
||||||
|
avatarUrl = it.avatarUrl
|
||||||
|
)
|
||||||
|
},
|
||||||
|
responseCount = entity.responseCount,
|
||||||
|
nearbyUsersNotified = entity.nearbyUsersNotified,
|
||||||
|
isLocal = entity.isLocal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toCreateRequest(event: EmergencyEvent): kr.smartsoltech.wellshe.emergency.domain.models.CreateEmergencyRequest {
|
||||||
|
return kr.smartsoltech.wellshe.emergency.domain.models.CreateEmergencyRequest(
|
||||||
|
latitude = event.latitude,
|
||||||
|
longitude = event.longitude,
|
||||||
|
eventType = event.eventType,
|
||||||
|
message = event.message,
|
||||||
|
severity = event.severity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toEventResponseRequest(response: kr.smartsoltech.wellshe.emergency.domain.models.EventResponse): kr.smartsoltech.wellshe.emergency.data.api.EventResponseRequest {
|
||||||
|
return kr.smartsoltech.wellshe.emergency.data.api.EventResponseRequest(
|
||||||
|
response_type = response.responseType.name.lowercase(),
|
||||||
|
message = response.message,
|
||||||
|
eta_minutes = response.estimatedArrival?.let { (it / 60000).toInt() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toEmergencyResponseEntity(response: kr.smartsoltech.wellshe.emergency.domain.models.EventResponse, eventId: String): EmergencyResponseEntity {
|
||||||
|
return EmergencyResponseEntity(
|
||||||
|
eventId = eventId,
|
||||||
|
responderId = response.responderId,
|
||||||
|
responseType = response.responseType.name,
|
||||||
|
message = response.message,
|
||||||
|
estimatedArrival = response.estimatedArrival,
|
||||||
|
responderInfo = response.responderInfo?.let { domainUserInfo ->
|
||||||
|
kr.smartsoltech.wellshe.emergency.data.entities.UserInfo(
|
||||||
|
firstName = domainUserInfo.firstName,
|
||||||
|
age = domainUserInfo.age,
|
||||||
|
avatarUrl = domainUserInfo.avatarUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toEventResponse(entity: EmergencyResponseEntity): kr.smartsoltech.wellshe.emergency.domain.models.EventResponse {
|
||||||
|
return kr.smartsoltech.wellshe.emergency.domain.models.EventResponse(
|
||||||
|
id = entity.id,
|
||||||
|
eventId = entity.eventId,
|
||||||
|
responderId = entity.responderId,
|
||||||
|
responseType = ResponseType.valueOf(entity.responseType),
|
||||||
|
message = entity.message,
|
||||||
|
estimatedArrival = entity.estimatedArrival,
|
||||||
|
createdAt = entity.createdAt,
|
||||||
|
responderInfo = entity.responderInfo?.let { entityUserInfo ->
|
||||||
|
kr.smartsoltech.wellshe.emergency.domain.models.UserInfo(
|
||||||
|
firstName = entityUserInfo.firstName,
|
||||||
|
age = entityUserInfo.age,
|
||||||
|
avatarUrl = entityUserInfo.avatarUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toUserInfo(apiUserInfo: kr.smartsoltech.wellshe.emergency.data.api.ApiUserInfo): kr.smartsoltech.wellshe.emergency.domain.models.UserInfo {
|
||||||
|
return kr.smartsoltech.wellshe.emergency.domain.models.UserInfo(
|
||||||
|
firstName = apiUserInfo.name,
|
||||||
|
age = null, // API не предоставляет возраст
|
||||||
|
avatarUrl = apiUserInfo.profile_image_url
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toNearbyUsers(response: kr.smartsoltech.wellshe.emergency.data.api.NearbyUsersResponse): List<kr.smartsoltech.wellshe.emergency.domain.models.NearbyUser> {
|
||||||
|
return response.users.map { nearbyUser ->
|
||||||
|
kr.smartsoltech.wellshe.emergency.domain.models.NearbyUser(
|
||||||
|
userId = nearbyUser.id.toString(),
|
||||||
|
firstName = nearbyUser.name,
|
||||||
|
age = null,
|
||||||
|
avatarUrl = nearbyUser.profile_image_url,
|
||||||
|
distanceMeters = 0.0, // TODO: вычислить расстояние
|
||||||
|
isOnline = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toEventResponse(apiResponse: kr.smartsoltech.wellshe.emergency.data.api.EventResponse): kr.smartsoltech.wellshe.emergency.domain.models.EventResponse {
|
||||||
|
return kr.smartsoltech.wellshe.emergency.domain.models.EventResponse(
|
||||||
|
id = apiResponse.id.toString(),
|
||||||
|
eventId = "",
|
||||||
|
responderId = apiResponse.user_id.toString(),
|
||||||
|
responseType = mapResponseType(apiResponse.response_type),
|
||||||
|
message = apiResponse.message,
|
||||||
|
estimatedArrival = apiResponse.eta_minutes?.let { it * 60 * 1000L },
|
||||||
|
createdAt = parseDate(apiResponse.created_at),
|
||||||
|
responderInfo = kr.smartsoltech.wellshe.emergency.domain.models.UserInfo(
|
||||||
|
firstName = apiResponse.user.name,
|
||||||
|
age = null,
|
||||||
|
avatarUrl = apiResponse.user.profile_image_url
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDate(timestamp: Long): String {
|
||||||
|
return dateFormat.format(Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Делаем методы публичными для использования в репозитории
|
||||||
|
fun mapAlertTypeToEmergencyType(alertType: String): EmergencyType {
|
||||||
|
return when(alertType) {
|
||||||
|
"medical" -> EmergencyType.MEDICAL
|
||||||
|
"fire" -> EmergencyType.FIRE
|
||||||
|
"accident" -> EmergencyType.ACCIDENT
|
||||||
|
"violence" -> EmergencyType.SOS
|
||||||
|
"harassment" -> EmergencyType.SOS
|
||||||
|
else -> EmergencyType.SOS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseDate(dateString: String): Long {
|
||||||
|
return try {
|
||||||
|
dateFormat.parse(dateString)?.time ?: System.currentTimeMillis()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapAlertStatusToEmergencyStatus(status: String): EmergencyStatus {
|
||||||
|
return when(status) {
|
||||||
|
"active" -> EmergencyStatus.ACTIVE
|
||||||
|
"resolved" -> EmergencyStatus.RESOLVED
|
||||||
|
"cancelled" -> EmergencyStatus.CANCELLED
|
||||||
|
else -> EmergencyStatus.ACTIVE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapResponseType(responseType: String): ResponseType {
|
||||||
|
return when(responseType) {
|
||||||
|
"help_on_way" -> ResponseType.ON_WAY
|
||||||
|
"calling_emergency" -> ResponseType.CALLED_POLICE
|
||||||
|
"safe_location" -> ResponseType.SAFE_NOW
|
||||||
|
"cant_help" -> ResponseType.CANNOT_HELP
|
||||||
|
else -> ResponseType.ON_WAY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.data.models
|
||||||
|
|
||||||
|
enum class EmergencyType {
|
||||||
|
SOS,
|
||||||
|
HARASSMENT,
|
||||||
|
STALKING,
|
||||||
|
MEDICAL,
|
||||||
|
FIRE,
|
||||||
|
ACCIDENT,
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class EmergencyStatus {
|
||||||
|
ACTIVE,
|
||||||
|
HANDLED,
|
||||||
|
CLOSED,
|
||||||
|
EXPIRED,
|
||||||
|
RESOLVED,
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ResponseType {
|
||||||
|
ON_WAY,
|
||||||
|
CALLED_POLICE,
|
||||||
|
SAFE_NOW,
|
||||||
|
CANNOT_HELP,
|
||||||
|
HELP_ON_WAY,
|
||||||
|
CONTACTED_AUTHORITIES,
|
||||||
|
FALSE_ALARM
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ConnectionStatus {
|
||||||
|
CONNECTED,
|
||||||
|
CONNECTING,
|
||||||
|
DISCONNECTED,
|
||||||
|
RECONNECTING
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.data.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.api.EmergencyApiService
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.api.EventResponseRequest
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.dao.EmergencyDao
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.websocket.EmergencyWebSocketManager
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.mappers.EmergencyMapper
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.models.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.auth.AuthManager
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class EmergencyRepositoryImpl @Inject constructor(
|
||||||
|
private val apiService: EmergencyApiService,
|
||||||
|
private val dao: EmergencyDao,
|
||||||
|
private val webSocketManager: EmergencyWebSocketManager,
|
||||||
|
private val mapper: EmergencyMapper,
|
||||||
|
private val authManager: AuthManager
|
||||||
|
) : EmergencyRepository {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "EmergencyRepository"
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createEmergencyEvent(request: CreateEmergencyRequest): Result<EmergencyEventResponse> {
|
||||||
|
return try {
|
||||||
|
Log.d(TAG, "Creating emergency event: ${request.eventType} at lat=${request.latitude}, lng=${request.longitude}")
|
||||||
|
|
||||||
|
val token = authManager.getToken()
|
||||||
|
if (token.isNullOrEmpty()) {
|
||||||
|
Log.e(TAG, "No auth token available for emergency request")
|
||||||
|
return Result.failure(Exception("No auth token available"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Using auth token for emergency request: ${token.take(6)}...")
|
||||||
|
|
||||||
|
val apiRequest = mapper.toApiRequest(request)
|
||||||
|
Log.d(TAG, "Mapped API request: $apiRequest")
|
||||||
|
|
||||||
|
Log.d(TAG, "Sending emergency event to server...")
|
||||||
|
val response = apiService.createEmergencyEvent("Bearer $token", apiRequest)
|
||||||
|
|
||||||
|
Log.d(TAG, "Server response: code=${response.code()}, isSuccessful=${response.isSuccessful}")
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()
|
||||||
|
if (body != null) {
|
||||||
|
Log.i(TAG, "Emergency event created successfully: ${body}")
|
||||||
|
val mappedResponse = mapper.toEmergencyEventResponse(body)
|
||||||
|
Log.d(TAG, "Mapped response: $mappedResponse")
|
||||||
|
Result.success(mappedResponse)
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Server response body is null")
|
||||||
|
Result.failure(Exception("Server response body is null"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string()
|
||||||
|
Log.e(TAG, "API Error: ${response.code()}, message: ${response.message()}, body: $errorBody")
|
||||||
|
Result.failure(Exception("API Error: ${response.code()} - ${response.message()}${if (errorBody != null) ": $errorBody" else ""}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Exception creating emergency event: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getNearbyEvents(latitude: Double, longitude: Double, radius: Int): Result<List<EmergencyEvent>> {
|
||||||
|
return try {
|
||||||
|
val token = authManager.getToken() ?: return Result.failure(Exception("No auth token"))
|
||||||
|
|
||||||
|
val response = apiService.getNearbyEvents("Bearer $token", latitude, longitude, radius.toDouble())
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()!!
|
||||||
|
val events = body.events.map { mapper.toEmergencyEvent(it) }
|
||||||
|
Result.success(events)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("API Error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getNearbyUsers(latitude: Double, longitude: Double, radius: Int): Result<List<NearbyUser>> {
|
||||||
|
return try {
|
||||||
|
Log.d(TAG, "Getting nearby users at lat=$latitude, lng=$longitude, radius=${radius}m")
|
||||||
|
|
||||||
|
val token = authManager.getToken()
|
||||||
|
if (token.isNullOrEmpty()) {
|
||||||
|
Log.e(TAG, "No auth token available for nearby users request")
|
||||||
|
return Result.failure(Exception("No auth token available"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val radiusKm = radius / 1000.0
|
||||||
|
Log.d(TAG, "Sending nearby users request to server with radius_km=$radiusKm...")
|
||||||
|
|
||||||
|
val response = apiService.getNearbyUsers("Bearer $token", latitude, longitude, radiusKm)
|
||||||
|
|
||||||
|
Log.d(TAG, "Nearby users response: code=${response.code()}, isSuccessful=${response.isSuccessful}")
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()
|
||||||
|
if (body != null) {
|
||||||
|
Log.i(TAG, "Found ${body.users.size} nearby users")
|
||||||
|
val nearbyUsers = mapper.toNearbyUsers(body)
|
||||||
|
Result.success(nearbyUsers)
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Nearby users response body is null")
|
||||||
|
Result.success(emptyList())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string()
|
||||||
|
Log.e(TAG, "Nearby users API Error: ${response.code()}, message: ${response.message()}, body: $errorBody")
|
||||||
|
Result.success(emptyList())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Exception getting nearby users: ${e.message}", e)
|
||||||
|
Result.success(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getEventDetails(eventId: String): Result<EmergencyEventDetail> {
|
||||||
|
return try {
|
||||||
|
val token = authManager.getToken() ?: return Result.failure(Exception("No auth token"))
|
||||||
|
|
||||||
|
val response = apiService.getEventDetails("Bearer $token", eventId.toInt())
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()!!
|
||||||
|
Result.success(mapper.toEmergencyEventDetail(body))
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("API Error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveLocalEmergencyEvent(request: CreateEmergencyRequest): EmergencyEvent {
|
||||||
|
val entity = mapper.toEmergencyEventEntity(request, getCurrentUserId())
|
||||||
|
dao.insertEvent(entity)
|
||||||
|
return mapper.toEmergencyEvent(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun markEventAsSynced(localId: String, remoteId: String) {
|
||||||
|
dao.markEventAsSynced(localId, System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getUnsyncedEvents(): List<EmergencyEvent> {
|
||||||
|
return dao.getUnsyncedEvents().map { mapper.toEmergencyEvent(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun syncLocalEvents(): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val unsyncedEvents = getUnsyncedEvents()
|
||||||
|
unsyncedEvents.forEach { event ->
|
||||||
|
val request = mapper.toCreateRequest(event)
|
||||||
|
createEmergencyEvent(request).fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
markEventAsSynced(event.id, response.eventId)
|
||||||
|
},
|
||||||
|
onFailure = { /* Keep local event for next sync attempt */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateEventStatus(eventId: String, status: EmergencyStatus, note: String?): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val token = authManager.getToken() ?: return Result.failure(Exception("No auth token"))
|
||||||
|
|
||||||
|
val response = apiService.updateEventStatus("Bearer $token", eventId.toInt())
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
dao.updateEventStatus(eventId, status)
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("API Error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun respondToEvent(eventId: String, response: EventResponse): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val token = authManager.getToken() ?: return Result.failure(Exception("No auth token"))
|
||||||
|
val request = mapper.toEventResponseRequest(response)
|
||||||
|
|
||||||
|
val apiResponse = apiService.respondToEvent("Bearer $token", eventId.toInt(), request)
|
||||||
|
if (apiResponse.isSuccessful) {
|
||||||
|
val responseEntity = mapper.toEmergencyResponseEntity(response, eventId)
|
||||||
|
dao.insertResponse(responseEntity)
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("API Error: ${apiResponse.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getEventResponses(eventId: String): Result<List<EventResponse>> {
|
||||||
|
return try {
|
||||||
|
val responses = dao.getEventResponses(eventId)
|
||||||
|
Result.success(responses.map { mapper.toEventResponse(it) })
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getUserEventHistory(limit: Int, offset: Int): Result<List<EmergencyEvent>> {
|
||||||
|
return try {
|
||||||
|
val token = authManager.getToken() ?: return Result.failure(Exception("No auth token"))
|
||||||
|
|
||||||
|
val response = apiService.getEventHistory("Bearer $token")
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()!!
|
||||||
|
val events = body.events.map { historyEvent ->
|
||||||
|
EmergencyEvent(
|
||||||
|
id = historyEvent.uuid,
|
||||||
|
userId = historyEvent.user_id.toString(),
|
||||||
|
latitude = historyEvent.latitude,
|
||||||
|
longitude = historyEvent.longitude,
|
||||||
|
eventType = mapper.mapAlertTypeToEmergencyType(historyEvent.alert_type),
|
||||||
|
message = historyEvent.message,
|
||||||
|
status = if (historyEvent.is_resolved) EmergencyStatus.RESOLVED else EmergencyStatus.ACTIVE,
|
||||||
|
severity = 5,
|
||||||
|
createdAt = mapper.parseDate(historyEvent.created_at),
|
||||||
|
updatedAt = mapper.parseDate(historyEvent.updated_at ?: historyEvent.created_at),
|
||||||
|
expiresAt = mapper.parseDate(historyEvent.created_at) + (30 * 60 * 1000),
|
||||||
|
responseCount = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Result.success(events)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("API Error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getActiveEvents(): Flow<List<EmergencyEvent>> {
|
||||||
|
return dao.observeActiveEvents().map { entities ->
|
||||||
|
entities.map { mapper.toEmergencyEvent(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getConnectionStatus(): Flow<ConnectionStatus> {
|
||||||
|
return webSocketManager.connectionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun connectWebSocket(userId: String, token: String) {
|
||||||
|
webSocketManager.connect(userId, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disconnectWebSocket() {
|
||||||
|
webSocketManager.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getEventResponses(): Flow<EventResponseNotification> {
|
||||||
|
return webSocketManager.eventResponses
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getEmergencyStats(): Result<EventStats> {
|
||||||
|
return try {
|
||||||
|
Result.success(EventStats(
|
||||||
|
totalEvents = 0,
|
||||||
|
activeEvents = 0,
|
||||||
|
resolvedEvents = 0,
|
||||||
|
escalatedEvents = 0,
|
||||||
|
cancelledEvents = 0,
|
||||||
|
averageResponseTime = 0.0,
|
||||||
|
period = "last_30_days"
|
||||||
|
))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub implementations for new methods
|
||||||
|
override suspend fun createEmergencyReport(request: CreateReportRequest): Result<EmergencyReport> {
|
||||||
|
return Result.failure(Exception("Not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getEmergencyReports(): Result<List<EmergencyReport>> {
|
||||||
|
return Result.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getEmergencyReportsAdmin(): Result<List<EmergencyEvent>> {
|
||||||
|
return Result.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createSafetyCheck(request: CreateSafetyCheckRequest): Result<SafetyCheck> {
|
||||||
|
return Result.failure(Exception("Not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSafetyChecks(): Result<List<SafetyCheck>> {
|
||||||
|
return Result.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createAlert(latitude: Double, longitude: Double, alertType: String, message: String?): Result<EmergencyEventResponse> {
|
||||||
|
return Result.failure(Exception("Not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateAlert(alertId: String, message: String?, isResolved: Boolean?): Result<EmergencyEventResponse> {
|
||||||
|
return Result.failure(Exception("Not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun resolveAlert(alertId: String): Result<Unit> {
|
||||||
|
return Result.failure(Exception("Not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMyAlerts(): Result<List<EmergencyEvent>> {
|
||||||
|
return Result.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getActiveAlerts(): Result<List<EmergencyEvent>> {
|
||||||
|
return Result.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAlertResponses(alertId: String): Result<List<EventResponse>> {
|
||||||
|
return Result.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getWebSocketStats(): Result<WebSocketStats> {
|
||||||
|
return Result.success(WebSocketStats(0, 0, 0, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getConnectionStats(): Result<ConnectionStats> {
|
||||||
|
return Result.success(ConnectionStats(0, 0, emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pingWebSocketConnections(): Result<Unit> {
|
||||||
|
return Result.success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun broadcastTestMessage(message: String): Result<Unit> {
|
||||||
|
return Result.success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getNearbyEventsForMap(latitude: Double, longitude: Double, radiusKm: Double): Result<List<MapEvent>> {
|
||||||
|
return Result.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getEventsInRadius(latitude: Double, longitude: Double, radiusKm: Double): Result<List<EmergencyEvent>> {
|
||||||
|
return Result.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCurrentUserId(): String {
|
||||||
|
return authManager.getCurrentUserId() ?: "anonymous"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.data.websocket
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import okhttp3.*
|
||||||
|
import org.json.JSONObject
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.BuildConfig
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class EmergencyWebSocketManager @Inject constructor(
|
||||||
|
private val okHttpClient: OkHttpClient,
|
||||||
|
private val gson: Gson
|
||||||
|
) {
|
||||||
|
private var webSocket: WebSocket? = null
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
|
private var currentUserId: String? = null
|
||||||
|
private var authToken: String? = null
|
||||||
|
private var triedWithTokenQuery: Boolean = false
|
||||||
|
|
||||||
|
private val _emergencyAlerts = MutableSharedFlow<EmergencyAlert>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 10,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
|
)
|
||||||
|
val emergencyAlerts: SharedFlow<EmergencyAlert> = _emergencyAlerts.asSharedFlow()
|
||||||
|
|
||||||
|
private val _eventUpdates = MutableSharedFlow<EventUpdate>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 10,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
|
)
|
||||||
|
val eventUpdates: SharedFlow<EventUpdate> = _eventUpdates.asSharedFlow()
|
||||||
|
|
||||||
|
private val _eventResponses = MutableSharedFlow<EventResponseNotification>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 10,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
|
)
|
||||||
|
val eventResponses: SharedFlow<EventResponseNotification> = _eventResponses.asSharedFlow()
|
||||||
|
|
||||||
|
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||||
|
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow()
|
||||||
|
|
||||||
|
fun connect(userId: String, token: String, useQueryToken: Boolean = false) {
|
||||||
|
disconnect()
|
||||||
|
|
||||||
|
currentUserId = userId
|
||||||
|
authToken = token
|
||||||
|
triedWithTokenQuery = useQueryToken
|
||||||
|
|
||||||
|
// Safety: do not attempt to connect with placeholder/test tokens
|
||||||
|
if (token.startsWith("temp_token_for_")) {
|
||||||
|
Log.w("EmergencyWS", "Refusing to connect to WebSocket: placeholder token detected for user=$userId. Token: $token")
|
||||||
|
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val wsUrl = if (useQueryToken) {
|
||||||
|
// token in query param (less secure) — used only as a debug fallback
|
||||||
|
BuildConfig.EMERGENCY_WS_BASE + "emergency/ws/$userId?token=${token}"
|
||||||
|
} else {
|
||||||
|
BuildConfig.EMERGENCY_WS_BASE + "emergency/ws/$userId"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the websocket URL and a masked token for debugging 403 errors
|
||||||
|
try {
|
||||||
|
val maskedToken = if (token.length > 6) token.substring(0, 6) + "..." else "***"
|
||||||
|
Log.d("EmergencyWS", "Connecting to WebSocket: $wsUrl , token=$maskedToken , useQueryToken=$useQueryToken")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d("EmergencyWS", "Connecting to WebSocket (unable to mask token)")
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestBuilder = Request.Builder()
|
||||||
|
.url(wsUrl)
|
||||||
|
|
||||||
|
if (!useQueryToken) {
|
||||||
|
// prefer Authorization header; add Origin because some servers check it
|
||||||
|
requestBuilder
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.addHeader("Origin", BuildConfig.EMERGENCY_API_BASE.removeSuffix("/"))
|
||||||
|
Log.d("EmergencyWS", "Using Authorization header for token (masked) and Origin header")
|
||||||
|
} else {
|
||||||
|
Log.d("EmergencyWS", "Using query parameter for token (masked)")
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = requestBuilder.build()
|
||||||
|
|
||||||
|
_connectionStatus.value = ConnectionStatus.CONNECTING
|
||||||
|
webSocket = okHttpClient.newWebSocket(request, EmergencyWebSocketListener())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
webSocket?.close(1000, "Manual disconnect")
|
||||||
|
webSocket = null
|
||||||
|
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
if (currentUserId == null || authToken == null) return
|
||||||
|
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
var delay = 5000L
|
||||||
|
while (_connectionStatus.value == ConnectionStatus.DISCONNECTED && isActive) {
|
||||||
|
delay(delay)
|
||||||
|
if (_connectionStatus.value == ConnectionStatus.DISCONNECTED) {
|
||||||
|
Log.d("EmergencyWS", "Attempting to reconnect...")
|
||||||
|
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||||
|
connect(currentUserId!!, authToken!!)
|
||||||
|
delay = minOf(delay * 2, 30000L) // Exponential backoff, max 30s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class EmergencyWebSocketListener : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||||
|
Log.d("EmergencyWS", "WebSocket connected successfully")
|
||||||
|
|
||||||
|
// Reset reconnect delay on successful connection
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
try {
|
||||||
|
val json = JSONObject(text)
|
||||||
|
val messageType = json.getString("type")
|
||||||
|
|
||||||
|
when (messageType) {
|
||||||
|
"emergency_alert" -> {
|
||||||
|
val alert = gson.fromJson(text, EmergencyAlert::class.java)
|
||||||
|
_emergencyAlerts.tryEmit(alert)
|
||||||
|
Log.d("EmergencyWS", "Emergency alert received: ${'$'}{alert.eventId}")
|
||||||
|
}
|
||||||
|
"event_update" -> {
|
||||||
|
val update = gson.fromJson(text, EventUpdate::class.java)
|
||||||
|
_eventUpdates.tryEmit(update)
|
||||||
|
Log.d("EmergencyWS", "Event update received: ${'$'}{update.eventId}")
|
||||||
|
}
|
||||||
|
"event_response" -> {
|
||||||
|
val response = gson.fromJson(text, EventResponseNotification::class.java)
|
||||||
|
_eventResponses.tryEmit(response)
|
||||||
|
Log.d("EmergencyWS", "Event response received for: ${'$'}{response.eventId}")
|
||||||
|
}
|
||||||
|
"ping" -> {
|
||||||
|
webSocket.send("pong")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.w("EmergencyWS", "Unknown message type: $messageType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("EmergencyWS", "Error parsing WebSocket message: $text", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
Log.e("EmergencyWS", "WebSocket connection failed", t)
|
||||||
|
response?.let {
|
||||||
|
try {
|
||||||
|
Log.e("EmergencyWS", "WebSocket HTTP response: code=${it.code}, message=${it.message}")
|
||||||
|
Log.e("EmergencyWS", "WebSocket response headers: ${it.headers}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("EmergencyWS", "Failed to log response details", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
|
// If server refused with 403 and we haven't tried token-in-query yet, try once (debug-only)
|
||||||
|
try {
|
||||||
|
if (response?.code == 403 && !triedWithTokenQuery && BuildConfig.DEBUG && currentUserId != null && authToken != null) {
|
||||||
|
Log.d("EmergencyWS", "403 received — retrying WebSocket with token in query (debug-only)")
|
||||||
|
// slight delay before retry
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
delay(500)
|
||||||
|
connect(currentUserId!!, authToken!!, useQueryToken = true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("EmergencyWS", "Error during 403 retry logic", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
Log.d("EmergencyWS", "WebSocket closed: code=$code, reason=$reason")
|
||||||
|
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
|
|
||||||
|
// Only attempt reconnect if it wasn't a manual disconnect
|
||||||
|
if (code != 1000) {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket message models
|
||||||
|
data class EmergencyAlert(
|
||||||
|
val type: String = "emergency_alert",
|
||||||
|
val eventId: String,
|
||||||
|
val distanceMeters: Int,
|
||||||
|
val eventType: String,
|
||||||
|
val severity: Int,
|
||||||
|
val message: String,
|
||||||
|
val userInfo: WsUserInfo,
|
||||||
|
val createdAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventUpdate(
|
||||||
|
val type: String = "event_update",
|
||||||
|
val eventId: String,
|
||||||
|
val status: String,
|
||||||
|
val updateMessage: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventResponseNotification(
|
||||||
|
val type: String = "event_response",
|
||||||
|
val eventId: String,
|
||||||
|
val responder: WsUserInfo,
|
||||||
|
val responseType: String,
|
||||||
|
val message: String?,
|
||||||
|
val estimatedArrival: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WsUserInfo(
|
||||||
|
val userId: String,
|
||||||
|
val firstName: String,
|
||||||
|
val age: Int?,
|
||||||
|
val avatarUrl: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.debug.stub
|
||||||
|
|
||||||
|
// Заглушка в main-источнике, чтобы не конфликтовать с реализацией в src/debug
|
||||||
|
object AuthTesterStub {
|
||||||
|
fun runFullTest(): Boolean {
|
||||||
|
// no-op stub in a different package/name to avoid redeclaration in debug builds
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.api.EmergencyApiService
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.dao.EmergencyDao
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.mappers.EmergencyMapper
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.repository.EmergencyRepositoryImpl
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.auth.AuthManager
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.auth.AuthManagerImpl
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.websocket.EmergencyWebSocketManager
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.usecases.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.utils.*
|
||||||
|
import kr.smartsoltech.wellshe.data.AppDatabase
|
||||||
|
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.BuildConfig
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object EmergencyModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideEmergencyDao(database: AppDatabase): EmergencyDao = database.emergencyDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideOkHttpClient(): OkHttpClient {
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideEmergencyApiService(okHttpClient: OkHttpClient, gson: Gson): EmergencyApiService {
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl(BuildConfig.EMERGENCY_API_BASE) // Emergency Service URL from BuildConfig
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
|
.build()
|
||||||
|
.create(EmergencyApiService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideEmergencyWebSocketManager(
|
||||||
|
okHttpClient: OkHttpClient,
|
||||||
|
gson: Gson
|
||||||
|
): EmergencyWebSocketManager {
|
||||||
|
return EmergencyWebSocketManager(okHttpClient, gson)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideEmergencyMapper(): EmergencyMapper = EmergencyMapper()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthManager(): AuthManager {
|
||||||
|
return AuthManagerImpl()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideEmergencyRepository(
|
||||||
|
apiService: EmergencyApiService,
|
||||||
|
dao: EmergencyDao,
|
||||||
|
webSocketManager: EmergencyWebSocketManager,
|
||||||
|
mapper: EmergencyMapper,
|
||||||
|
authManager: AuthManager
|
||||||
|
): EmergencyRepository {
|
||||||
|
return EmergencyRepositoryImpl(apiService, dao, webSocketManager, mapper, authManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Cases
|
||||||
|
@Provides
|
||||||
|
fun provideCreateEmergencyEventUseCase(
|
||||||
|
repository: EmergencyRepository,
|
||||||
|
connectivityManager: ConnectivityManager,
|
||||||
|
deviceInfoProvider: DeviceInfoProvider
|
||||||
|
): CreateEmergencyEventUseCase {
|
||||||
|
return CreateEmergencyEventUseCase(repository, connectivityManager, deviceInfoProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideGetNearbyEventsUseCase(repository: EmergencyRepository): GetNearbyEventsUseCase {
|
||||||
|
return GetNearbyEventsUseCase(repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideGetNearbyUsersUseCase(repository: EmergencyRepository): GetNearbyUsersUseCase {
|
||||||
|
return GetNearbyUsersUseCase(repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideRespondToEventUseCase(repository: EmergencyRepository): RespondToEventUseCase {
|
||||||
|
return RespondToEventUseCase(repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideUpdateEventStatusUseCase(repository: EmergencyRepository): UpdateEventStatusUseCase {
|
||||||
|
return UpdateEventStatusUseCase(repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideSyncLocalEventsUseCase(repository: EmergencyRepository): SyncLocalEventsUseCase {
|
||||||
|
return SyncLocalEventsUseCase(repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideLocationManager(@ApplicationContext context: Context): LocationManager {
|
||||||
|
return LocationManager(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideConnectivityManager(@ApplicationContext context: Context): ConnectivityManager {
|
||||||
|
return ConnectivityManager(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDeviceInfoProvider(@ApplicationContext context: Context): DeviceInfoProvider {
|
||||||
|
return DeviceInfoProvider(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePermissionManager(@ApplicationContext context: Context): PermissionManager {
|
||||||
|
return PermissionManager(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideEmergencyNotificationManager(@ApplicationContext context: Context): EmergencyNotificationManager {
|
||||||
|
return EmergencyNotificationManager(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.domain.models
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyType
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.ResponseType
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus
|
||||||
|
|
||||||
|
// Domain models
|
||||||
|
data class EmergencyEvent(
|
||||||
|
val id: String,
|
||||||
|
val userId: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val eventType: EmergencyType,
|
||||||
|
val message: String?,
|
||||||
|
val status: EmergencyStatus,
|
||||||
|
val severity: Int,
|
||||||
|
val createdAt: Long,
|
||||||
|
val updatedAt: Long,
|
||||||
|
val expiresAt: Long,
|
||||||
|
val distanceMeters: Double? = null,
|
||||||
|
val userInfo: UserInfo? = null,
|
||||||
|
val responseCount: Int = 0,
|
||||||
|
val nearbyUsersNotified: Int? = null,
|
||||||
|
val isLocal: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyEventDetail(
|
||||||
|
val id: String,
|
||||||
|
val userId: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val eventType: EmergencyType,
|
||||||
|
val message: String,
|
||||||
|
val status: EmergencyStatus,
|
||||||
|
val severity: Int,
|
||||||
|
val createdAt: Long,
|
||||||
|
val updatedAt: Long,
|
||||||
|
val expiresAt: Long,
|
||||||
|
val responses: List<EventResponse>,
|
||||||
|
val metadata: Map<String, Any>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventResponse(
|
||||||
|
val id: String,
|
||||||
|
val eventId: String,
|
||||||
|
val responderId: String,
|
||||||
|
val responseType: ResponseType,
|
||||||
|
val message: String?,
|
||||||
|
val estimatedArrival: Long?,
|
||||||
|
val createdAt: Long,
|
||||||
|
val responderInfo: UserInfo?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserInfo(
|
||||||
|
val firstName: String,
|
||||||
|
val age: Int?,
|
||||||
|
val avatarUrl: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NearbyUser(
|
||||||
|
val userId: String,
|
||||||
|
val firstName: String,
|
||||||
|
val age: Int?,
|
||||||
|
val avatarUrl: String?,
|
||||||
|
val distanceMeters: Double,
|
||||||
|
val isOnline: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateEmergencyRequest(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val eventType: EmergencyType,
|
||||||
|
val message: String?,
|
||||||
|
val severity: Int,
|
||||||
|
val metadata: Map<String, Any> = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyEventResponse(
|
||||||
|
val eventId: String,
|
||||||
|
val status: String,
|
||||||
|
val nearbyUsersNotified: Int,
|
||||||
|
val estimatedResponseTime: String?,
|
||||||
|
val createdAt: String,
|
||||||
|
val expiresAt: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromLocal(localEvent: EmergencyEvent): EmergencyEventResponse {
|
||||||
|
return EmergencyEventResponse(
|
||||||
|
eventId = localEvent.id,
|
||||||
|
status = "pending_sync",
|
||||||
|
nearbyUsersNotified = 0,
|
||||||
|
estimatedResponseTime = null,
|
||||||
|
createdAt = localEvent.createdAt.toString(),
|
||||||
|
expiresAt = localEvent.expiresAt.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export WebSocket models for domain layer
|
||||||
|
typealias EventResponseNotification = kr.smartsoltech.wellshe.emergency.data.websocket.EventResponseNotification
|
||||||
|
|
||||||
|
// Расширенные domain модели для новой функциональности
|
||||||
|
|
||||||
|
// 1. Управление жизненным циклом событий
|
||||||
|
data class EventUpdateDomain(
|
||||||
|
val message: String?,
|
||||||
|
val priority: String?, // low, medium, high, critical
|
||||||
|
val additionalInfo: Map<String, Any>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventNote(
|
||||||
|
val id: String,
|
||||||
|
val eventId: String,
|
||||||
|
val authorId: String,
|
||||||
|
val content: String,
|
||||||
|
val noteType: String, // public, private, system
|
||||||
|
val createdAt: Long,
|
||||||
|
val updatedAt: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventTimeline(
|
||||||
|
val eventId: String,
|
||||||
|
val timeline: List<TimelineEntry>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TimelineEntry(
|
||||||
|
val id: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val status: String,
|
||||||
|
val message: String?,
|
||||||
|
val userInfo: UserInfo?,
|
||||||
|
val eventType: String = "status_change"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Система взаимодействия участников
|
||||||
|
data class EventParticipant(
|
||||||
|
val eventId: String,
|
||||||
|
val userId: String,
|
||||||
|
val userInfo: UserInfo,
|
||||||
|
val role: ParticipantRole, // creator, responder, observer, invited
|
||||||
|
val joinedAt: Long,
|
||||||
|
val status: ParticipantStatus // active, left, removed
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ParticipantRole {
|
||||||
|
CREATOR, RESPONDER, OBSERVER, INVITED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ParticipantStatus {
|
||||||
|
ACTIVE, LEFT, REMOVED, PENDING
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LocationShare(
|
||||||
|
val id: String,
|
||||||
|
val eventId: String,
|
||||||
|
val userId: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val timestamp: Long,
|
||||||
|
val accuracy: Float?,
|
||||||
|
val isRealTime: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Система коммуникации
|
||||||
|
data class EventMessageDomain(
|
||||||
|
val id: String,
|
||||||
|
val eventId: String,
|
||||||
|
val senderId: String,
|
||||||
|
val content: String,
|
||||||
|
val messageType: MessageType, // text, location, image, system
|
||||||
|
val timestamp: Long,
|
||||||
|
val senderInfo: UserInfo?,
|
||||||
|
val metadata: Map<String, Any> = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class MessageType {
|
||||||
|
TEXT, LOCATION, IMAGE, SYSTEM, BROADCAST
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BroadcastMessage(
|
||||||
|
val id: String,
|
||||||
|
val eventId: String,
|
||||||
|
val senderId: String,
|
||||||
|
val message: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val recipientCount: Int,
|
||||||
|
val deliveredCount: Int,
|
||||||
|
val payload: Map<String, Any> = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 4. Мониторинг и аналитика
|
||||||
|
data class EventStats(
|
||||||
|
val totalEvents: Int,
|
||||||
|
val activeEvents: Int,
|
||||||
|
val resolvedEvents: Int,
|
||||||
|
val escalatedEvents: Int,
|
||||||
|
val cancelledEvents: Int,
|
||||||
|
val averageResponseTime: Double,
|
||||||
|
val period: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventHeatmap(
|
||||||
|
val center: LocationData,
|
||||||
|
val radiusKm: Double,
|
||||||
|
val events: List<HeatmapEventPoint>,
|
||||||
|
val period: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LocationData(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HeatmapEventPoint(
|
||||||
|
val eventId: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val intensity: Double,
|
||||||
|
val eventType: EmergencyType,
|
||||||
|
val timestamp: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ResponseTimeAnalytics(
|
||||||
|
val averageResponseTime: Double,
|
||||||
|
val medianResponseTime: Double,
|
||||||
|
val distribution: List<ResponseTimeBucket>,
|
||||||
|
val period: String,
|
||||||
|
val eventType: EmergencyType?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ResponseTimeBucket(
|
||||||
|
val timeRangeMinutes: String,
|
||||||
|
val eventCount: Int,
|
||||||
|
val percentage: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EventFeedback(
|
||||||
|
val id: String,
|
||||||
|
val eventId: String,
|
||||||
|
val userId: String,
|
||||||
|
val rating: Int, // 1-5
|
||||||
|
val comments: String?,
|
||||||
|
val createdAt: Long,
|
||||||
|
val categories: List<String> = emptyList() // helpful, fast, professional, etc.
|
||||||
|
)
|
||||||
|
|
||||||
|
// Расширенные типы приоритетов
|
||||||
|
enum class EventPriority {
|
||||||
|
LOW, MEDIUM, HIGH, CRITICAL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расширенные статусы событий
|
||||||
|
enum class ExtendedEventStatus {
|
||||||
|
CREATED, ACTIVE, ESCALATED, RESOLVED, CANCELLED, EXPIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== НОВЫЕ DOMAIN МОДЕЛИ ДЛЯ 100% ФУНКЦИОНАЛЬНОСТИ ==========
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
data class EmergencyReport(
|
||||||
|
val id: String,
|
||||||
|
val userId: String?,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val reportType: String,
|
||||||
|
val description: String,
|
||||||
|
val isAnonymous: Boolean,
|
||||||
|
val severity: Int,
|
||||||
|
val status: String,
|
||||||
|
val createdAt: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateReportRequest(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val reportType: String,
|
||||||
|
val description: String,
|
||||||
|
val address: String? = null,
|
||||||
|
val isAnonymous: Boolean = false,
|
||||||
|
val severity: Int = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// Safety Check
|
||||||
|
data class SafetyCheck(
|
||||||
|
val id: String,
|
||||||
|
val userId: String,
|
||||||
|
val message: String?,
|
||||||
|
val latitude: Double?,
|
||||||
|
val longitude: Double?,
|
||||||
|
val createdAt: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateSafetyCheckRequest(
|
||||||
|
val message: String? = null,
|
||||||
|
val latitude: Double? = null,
|
||||||
|
val longitude: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// WebSocket Management
|
||||||
|
data class WebSocketStats(
|
||||||
|
val totalConnections: Int,
|
||||||
|
val activeConnections: Int,
|
||||||
|
val totalMessagesSent: Int,
|
||||||
|
val uptimeSeconds: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ConnectionStats(
|
||||||
|
val totalConnections: Int,
|
||||||
|
val activeConnections: Int,
|
||||||
|
val connections: List<UserConnection>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserConnection(
|
||||||
|
val userId: String,
|
||||||
|
val connectedAt: Long,
|
||||||
|
val lastPing: Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
// Map-related models
|
||||||
|
data class MapEvent(
|
||||||
|
val id: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val eventType: EmergencyType,
|
||||||
|
val severity: Int,
|
||||||
|
val createdAt: Long,
|
||||||
|
val status: EmergencyStatus,
|
||||||
|
val distance: Double? = null,
|
||||||
|
val title: String,
|
||||||
|
val description: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.domain.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.models.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus
|
||||||
|
|
||||||
|
interface EmergencyRepository {
|
||||||
|
|
||||||
|
// Emergency Events
|
||||||
|
suspend fun createEmergencyEvent(request: CreateEmergencyRequest): Result<EmergencyEventResponse>
|
||||||
|
suspend fun getNearbyEvents(latitude: Double, longitude: Double, radius: Int): Result<List<EmergencyEvent>>
|
||||||
|
suspend fun getNearbyUsers(latitude: Double, longitude: Double, radius: Int): Result<List<NearbyUser>>
|
||||||
|
suspend fun getEventDetails(eventId: String): Result<EmergencyEventDetail>
|
||||||
|
suspend fun updateEventStatus(eventId: String, status: EmergencyStatus, note: String?): Result<Unit>
|
||||||
|
suspend fun getUserEventHistory(limit: Int, offset: Int): Result<List<EmergencyEvent>>
|
||||||
|
|
||||||
|
// Local Events
|
||||||
|
suspend fun saveLocalEmergencyEvent(request: CreateEmergencyRequest): EmergencyEvent
|
||||||
|
suspend fun markEventAsSynced(localId: String, remoteId: String)
|
||||||
|
suspend fun getUnsyncedEvents(): List<EmergencyEvent>
|
||||||
|
suspend fun syncLocalEvents(): Result<Unit>
|
||||||
|
|
||||||
|
// Event Responses
|
||||||
|
suspend fun respondToEvent(eventId: String, response: EventResponse): Result<Unit>
|
||||||
|
suspend fun getEventResponses(eventId: String): Result<List<EventResponse>>
|
||||||
|
|
||||||
|
// Real-time streams
|
||||||
|
fun getActiveEvents(): Flow<List<EmergencyEvent>>
|
||||||
|
fun getEventResponses(): Flow<EventResponseNotification>
|
||||||
|
|
||||||
|
// Connection management
|
||||||
|
fun connectWebSocket(userId: String, token: String)
|
||||||
|
fun disconnectWebSocket()
|
||||||
|
fun getConnectionStatus(): Flow<ConnectionStatus>
|
||||||
|
|
||||||
|
// Упрощенный набор методов, соответствующих реальному API
|
||||||
|
suspend fun getEmergencyStats(): Result<EventStats>
|
||||||
|
|
||||||
|
// ========== НОВЫЕ МЕТОДЫ ДЛЯ 100% ФУНКЦИОНАЛЬНОСТИ ==========
|
||||||
|
|
||||||
|
// Reports Management
|
||||||
|
suspend fun createEmergencyReport(request: CreateReportRequest): Result<EmergencyReport>
|
||||||
|
suspend fun getEmergencyReports(): Result<List<EmergencyReport>>
|
||||||
|
suspend fun getEmergencyReportsAdmin(): Result<List<EmergencyEvent>>
|
||||||
|
|
||||||
|
// Safety Check
|
||||||
|
suspend fun createSafetyCheck(request: CreateSafetyCheckRequest): Result<SafetyCheck>
|
||||||
|
suspend fun getSafetyChecks(): Result<List<SafetyCheck>>
|
||||||
|
|
||||||
|
// Enhanced Alert Management
|
||||||
|
suspend fun createAlert(latitude: Double, longitude: Double, alertType: String, message: String?): Result<EmergencyEventResponse>
|
||||||
|
suspend fun updateAlert(alertId: String, message: String?, isResolved: Boolean?): Result<EmergencyEventResponse>
|
||||||
|
suspend fun resolveAlert(alertId: String): Result<Unit>
|
||||||
|
suspend fun getMyAlerts(): Result<List<EmergencyEvent>>
|
||||||
|
suspend fun getActiveAlerts(): Result<List<EmergencyEvent>>
|
||||||
|
suspend fun getAlertResponses(alertId: String): Result<List<EventResponse>>
|
||||||
|
|
||||||
|
// WebSocket Management
|
||||||
|
suspend fun getWebSocketStats(): Result<WebSocketStats>
|
||||||
|
suspend fun getConnectionStats(): Result<ConnectionStats>
|
||||||
|
suspend fun pingWebSocketConnections(): Result<Unit>
|
||||||
|
suspend fun broadcastTestMessage(message: String): Result<Unit>
|
||||||
|
|
||||||
|
// Map and Location
|
||||||
|
suspend fun getNearbyEventsForMap(latitude: Double, longitude: Double, radiusKm: Double = 10.0): Result<List<MapEvent>>
|
||||||
|
suspend fun getEventsInRadius(latitude: Double, longitude: Double, radiusKm: Double = 1.0): Result<List<EmergencyEvent>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.domain.usecases
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.models.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
|
||||||
|
import kr.smartsoltech.wellshe.emergency.utils.ConnectivityManager
|
||||||
|
import kr.smartsoltech.wellshe.emergency.utils.DeviceInfoProvider
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CreateEmergencyEventUseCase @Inject constructor(
|
||||||
|
private val repository: EmergencyRepository,
|
||||||
|
private val connectivityManager: ConnectivityManager,
|
||||||
|
private val deviceInfoProvider: DeviceInfoProvider
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(request: CreateEmergencyRequest): Result<EmergencyEventResponse> {
|
||||||
|
return try {
|
||||||
|
// Check connectivity
|
||||||
|
if (!connectivityManager.isNetworkAvailable()) {
|
||||||
|
// Save locally if no connection
|
||||||
|
val localEvent = repository.saveLocalEmergencyEvent(request)
|
||||||
|
return Result.success(EmergencyEventResponse.fromLocal(localEvent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create remote event
|
||||||
|
repository.createEmergencyEvent(request)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Fallback to local storage
|
||||||
|
val localEvent = repository.saveLocalEmergencyEvent(request)
|
||||||
|
Result.success(EmergencyEventResponse.fromLocal(localEvent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.domain.usecases
|
||||||
|
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.models.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetNearbyEventsUseCase @Inject constructor(
|
||||||
|
private val repository: EmergencyRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
radius: Int
|
||||||
|
): Result<List<EmergencyEvent>> {
|
||||||
|
return repository.getNearbyEvents(latitude, longitude, radius)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GetNearbyUsersUseCase @Inject constructor(
|
||||||
|
private val repository: EmergencyRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
radius: Int
|
||||||
|
): Result<List<NearbyUser>> {
|
||||||
|
return repository.getNearbyUsers(latitude, longitude, radius)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RespondToEventUseCase @Inject constructor(
|
||||||
|
private val repository: EmergencyRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
eventId: String,
|
||||||
|
response: EventResponse
|
||||||
|
): Result<Unit> {
|
||||||
|
return repository.respondToEvent(eventId, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateEventStatusUseCase @Inject constructor(
|
||||||
|
private val repository: EmergencyRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
eventId: String,
|
||||||
|
status: kr.smartsoltech.wellshe.emergency.data.models.EmergencyStatus,
|
||||||
|
note: String?
|
||||||
|
): Result<Unit> {
|
||||||
|
return repository.updateEventStatus(eventId, status, note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncLocalEventsUseCase @Inject constructor(
|
||||||
|
private val repository: EmergencyRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): Result<Unit> {
|
||||||
|
return repository.syncLocalEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.domain.usecases
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.models.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ObserveEmergencyAlertsUseCase @Inject constructor(
|
||||||
|
private val repository: EmergencyRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(): Flow<List<EmergencyEvent>> {
|
||||||
|
return repository.getActiveEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,824 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.presentation.screens
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kr.smartsoltech.wellshe.BuildConfig
|
||||||
|
import kr.smartsoltech.wellshe.emergency.debug.AuthTester
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.models.EmergencyEvent
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.EmergencyType
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.ResponseType
|
||||||
|
import kr.smartsoltech.wellshe.emergency.presentation.viewmodels.EmergencyViewModel
|
||||||
|
import kr.smartsoltech.wellshe.emergency.presentation.viewmodels.EmergencyUiState
|
||||||
|
import com.google.accompanist.permissions.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Composable
|
||||||
|
fun EmergencyScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: EmergencyViewModel = hiltViewModel(),
|
||||||
|
onNavigateToMap: () -> Unit = {},
|
||||||
|
onNavigateToHistory: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val uiState by viewModel.uiState.collectAsState(initial = EmergencyUiState())
|
||||||
|
val emergencyEvents by viewModel.emergencyEvents.collectAsState(initial = emptyList())
|
||||||
|
val connectionStatus by viewModel.connectionStatus.collectAsState(initial = kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus.DISCONNECTED)
|
||||||
|
|
||||||
|
// Запрашиваем только foreground-пермишены здесь; background location требует отдельного UX/потока
|
||||||
|
val locationPermissions = rememberMultiplePermissionsState(
|
||||||
|
listOf(
|
||||||
|
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
android.Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val powerManager = context.getSystemService(PowerManager::class.java)
|
||||||
|
val isIgnoringBatteryOptimizations = powerManager?.isIgnoringBatteryOptimizations(context.packageName) == true
|
||||||
|
|
||||||
|
LaunchedEffect(locationPermissions.allPermissionsGranted) {
|
||||||
|
viewModel.onPermissionsResult(locationPermissions.allPermissionsGranted)
|
||||||
|
}
|
||||||
|
|
||||||
|
val openAppSettings: () -> Unit = {
|
||||||
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", context.packageName, null)
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync permissions state on start and when returning to the screen (e.g., user changed them in system settings)
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val hasLocationNow = ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
android.Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
viewModel.onPermissionsResult(hasLocationNow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe resume to re-check permissions if user returned from system settings
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
val hasLocationNow = ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
android.Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
viewModel.onPermissionsResult(hasLocationNow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
|
||||||
|
EmergencyScreenContent(
|
||||||
|
modifier = modifier,
|
||||||
|
uiState = uiState,
|
||||||
|
emergencyEvents = emergencyEvents,
|
||||||
|
connectionStatus = connectionStatus,
|
||||||
|
onSOSClick = { viewModel.createSOS() },
|
||||||
|
onCustomAlert = { type, message, severity ->
|
||||||
|
viewModel.createCustomAlert(type, message, severity)
|
||||||
|
},
|
||||||
|
onEventRespond = { eventId, responseType ->
|
||||||
|
viewModel.respondToEvent(eventId, responseType)
|
||||||
|
},
|
||||||
|
onRefresh = { viewModel.refreshNearbyEvents() },
|
||||||
|
onClearSOSNotifications = { viewModel.clearSOSNotifications() },
|
||||||
|
onNavigateToMap = onNavigateToMap,
|
||||||
|
onNavigateToHistory = onNavigateToHistory,
|
||||||
|
onRequestPermissions = {
|
||||||
|
if (locationPermissions.shouldShowRationale || !locationPermissions.allPermissionsGranted) {
|
||||||
|
locationPermissions.launchMultiplePermissionRequest()
|
||||||
|
} else {
|
||||||
|
openAppSettings()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequestBatteryOptimization = {
|
||||||
|
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations,
|
||||||
|
onClearSuccess = { viewModel.clearSuccessMessage() },
|
||||||
|
onClearError = { viewModel.clearErrorMessage() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmergencyScreenContent(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
uiState: EmergencyUiState,
|
||||||
|
emergencyEvents: List<EmergencyEvent>,
|
||||||
|
connectionStatus: kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus,
|
||||||
|
onSOSClick: () -> Unit,
|
||||||
|
onCustomAlert: (EmergencyType, String?, Int) -> Unit,
|
||||||
|
onEventRespond: (String, ResponseType) -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onClearSOSNotifications: () -> Unit,
|
||||||
|
onNavigateToMap: () -> Unit,
|
||||||
|
onNavigateToHistory: () -> Unit,
|
||||||
|
onRequestPermissions: () -> Unit,
|
||||||
|
onRequestBatteryOptimization: () -> Unit,
|
||||||
|
isIgnoringBatteryOptimizations: Boolean,
|
||||||
|
onClearSuccess: () -> Unit,
|
||||||
|
onClearError: () -> Unit
|
||||||
|
) {
|
||||||
|
// Вычисляем состояние разрешений и логируем ВНЕ LazyColumn — внутри лямбды контента LazyColumn
|
||||||
|
// нельзя вызывать composition locals, поэтому LocalContext.current нужно вызывать здесь.
|
||||||
|
val localContext = LocalContext.current
|
||||||
|
val hasLocationNow = ContextCompat.checkSelfPermission(
|
||||||
|
localContext,
|
||||||
|
android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(
|
||||||
|
localContext,
|
||||||
|
android.Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
val effectivePermissionsGranted = uiState.permissionsGranted || hasLocationNow
|
||||||
|
|
||||||
|
// Debug log to show why UI may still require permissions
|
||||||
|
Log.d(
|
||||||
|
"EmergencyScreen",
|
||||||
|
"ui.permissionsGranted=${uiState.permissionsGranted}, hasLocationNow=$hasLocationNow, effectivePermissionsGranted=$effectivePermissionsGranted"
|
||||||
|
)
|
||||||
|
Log.i(
|
||||||
|
"EmergencyScreen",
|
||||||
|
"effectivePermissionsGranted=$effectivePermissionsGranted (uiState.permissionsGranted=${uiState.permissionsGranted}, hasLocationNow=$hasLocationNow)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extra visibility in logs when permissions are missing
|
||||||
|
if (!effectivePermissionsGranted) {
|
||||||
|
Log.w(
|
||||||
|
"EmergencyScreen",
|
||||||
|
"Permissions not granted — PermissionRequestCard will be shown. Check app/system settings or request flow."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вынесем scope сюда — вызов rememberCoroutineScope() должен происходить в контексте @Composable, но не внутри LazyListScope (контента LazyColumn)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Connection Status Indicator
|
||||||
|
item {
|
||||||
|
ConnectionStatusIndicator(connectionStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!effectivePermissionsGranted) {
|
||||||
|
item {
|
||||||
|
PermissionRequestCard(onRequestPermissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Battery Optimization Request
|
||||||
|
if (!isIgnoringBatteryOptimizations) {
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Для корректной работы SOS и фоновых событий отключите оптимизацию батареи для приложения",
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Button(onClick = onRequestBatteryOptimization, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Отключить оптимизацию батареи")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SOS Button - главный элемент
|
||||||
|
item {
|
||||||
|
SOSButton(
|
||||||
|
isPressed = uiState.sosButtonPressed,
|
||||||
|
isLoading = uiState.isCreatingEmergency,
|
||||||
|
enabled = effectivePermissionsGranted,
|
||||||
|
onClick = onSOSClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick Actions
|
||||||
|
item {
|
||||||
|
QuickActionsRow(
|
||||||
|
onMapClick = onNavigateToMap,
|
||||||
|
onHistoryClick = onNavigateToHistory,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
onClearNotifications = onClearSOSNotifications,
|
||||||
|
isLoading = uiState.isLoadingNearbyEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom Alert Options
|
||||||
|
item {
|
||||||
|
CustomAlertSection(
|
||||||
|
enabled = effectivePermissionsGranted && !uiState.isCreatingEmergency,
|
||||||
|
onCustomAlert = onCustomAlert
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active Emergency Events
|
||||||
|
if (emergencyEvents.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "🚨 Экстренные ситуации рядом (${emergencyEvents.size})",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(emergencyEvents, key = { it.id }) { event ->
|
||||||
|
EmergencyEventCard(
|
||||||
|
event = event,
|
||||||
|
onRespond = { responseType -> onEventRespond(event.id, responseType) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (!uiState.isLoadingNearbyEvents) {
|
||||||
|
item {
|
||||||
|
EmptyStateCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading indicator
|
||||||
|
if (uiState.isLoadingNearbyEvents) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: кнопка запуска теста правильной аутентификации и WebSocket
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val result = AuthTester.runFullTest(localContext)
|
||||||
|
Log.i("EmergencyScreen", "AuthTester.runFullTest result=$result")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("EmergencyScreen", "AuthTester error: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("DEBUG: Run auth & WS test")
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(onClick = onClearSOSNotifications, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("DEBUG: Clear SOS notifications")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle UI Effects
|
||||||
|
HandleUIEffects(uiState, onClearSuccess, onClearError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SOSButton(
|
||||||
|
isPressed: Boolean,
|
||||||
|
isLoading: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (isPressed) 0.95f else 1f,
|
||||||
|
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||||
|
label = "sosButtonScale"
|
||||||
|
)
|
||||||
|
|
||||||
|
val glowAnimation by rememberInfiniteTransition(label = "sosGlow").animateFloat(
|
||||||
|
initialValue = 0.8f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "sosGlowAlpha"
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled && !isLoading,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(140.dp)
|
||||||
|
.scale(scale),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (isPressed) Color(0xFFB71C1C) else Color.Red,
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContainerColor = Color.Gray
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
elevation = ButtonDefaults.buttonElevation(
|
||||||
|
defaultElevation = if (isPressed) 4.dp else 12.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
strokeWidth = 4.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = Color.White.copy(alpha = if (enabled) glowAnimation else 0.5f)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "SOS",
|
||||||
|
fontSize = 36.sp,
|
||||||
|
fontWeight = FontWeight.ExtraBold,
|
||||||
|
letterSpacing = 4.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (enabled) "Нажмите для экстренного вызова" else "Необходимы разрешения",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = Color.White.copy(alpha = 0.9f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConnectionStatusIndicator(
|
||||||
|
status: kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val (color, text, icon) = when (status) {
|
||||||
|
kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus.CONNECTED ->
|
||||||
|
Triple(Color.Green, "Подключено", Icons.Default.Wifi)
|
||||||
|
kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus.CONNECTING ->
|
||||||
|
Triple(Color(0xFFFF9800), "Подключение...", Icons.Default.WifiFind)
|
||||||
|
kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus.DISCONNECTED ->
|
||||||
|
Triple(Color.Red, "Отключено", Icons.Default.WifiOff)
|
||||||
|
kr.smartsoltech.wellshe.emergency.data.models.ConnectionStatus.RECONNECTING ->
|
||||||
|
Triple(Color(0xFFFF9800), "Переподключение...", Icons.Default.Refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = color.copy(alpha = 0.1f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Real-time: $text",
|
||||||
|
color = color,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PermissionRequestCard(
|
||||||
|
onRequestPermissions: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.LocationOff,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Требуются разрешения",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Для работы экстренных сигналов необходим доступ к геолокации",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onRequestPermissions,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Предоставить разрешения")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QuickActionsRow(
|
||||||
|
onMapClick: () -> Unit,
|
||||||
|
onHistoryClick: () -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onClearNotifications: () -> Unit = {},
|
||||||
|
isLoading: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
QuickActionButton(
|
||||||
|
icon = Icons.Default.Map,
|
||||||
|
text = "Карта",
|
||||||
|
onClick = onMapClick,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
QuickActionButton(
|
||||||
|
icon = Icons.Default.History,
|
||||||
|
text = "История",
|
||||||
|
onClick = onHistoryClick,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
QuickActionButton(
|
||||||
|
icon = if (isLoading) Icons.Default.Refresh else Icons.Default.Refresh,
|
||||||
|
text = "Обновить",
|
||||||
|
onClick = onRefresh,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = !isLoading
|
||||||
|
)
|
||||||
|
QuickActionButton(
|
||||||
|
icon = Icons.Default.Clear,
|
||||||
|
text = "Очистить SOS",
|
||||||
|
onClick = onClearNotifications,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QuickActionButton(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier.height(56.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CustomAlertSection(
|
||||||
|
enabled: Boolean,
|
||||||
|
onCustomAlert: (EmergencyType, String?, Int) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Другие типы сигналов",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
CustomAlertButton(
|
||||||
|
text = "Преследование",
|
||||||
|
icon = Icons.AutoMirrored.Filled.DirectionsRun,
|
||||||
|
color = Color(0xFFFF5722),
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = { onCustomAlert(EmergencyType.STALKING, "Меня преследуют", 4) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
CustomAlertButton(
|
||||||
|
text = "Домогательство",
|
||||||
|
icon = Icons.Default.Block,
|
||||||
|
color = Color(0xFFE91E63),
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = { onCustomAlert(EmergencyType.HARASSMENT, "Домогательство", 3) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CustomAlertButton(
|
||||||
|
text: String,
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
color: Color,
|
||||||
|
enabled: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier.height(72.dp),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = color
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = if (enabled) color else Color.Gray
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = if (enabled) color else Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmergencyEventCard(
|
||||||
|
event: EmergencyEvent,
|
||||||
|
onRespond: (ResponseType) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.1f)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (event.eventType) {
|
||||||
|
EmergencyType.SOS -> Icons.Default.Warning
|
||||||
|
EmergencyType.HARASSMENT -> Icons.Default.Block
|
||||||
|
EmergencyType.STALKING -> Icons.AutoMirrored.Filled.DirectionsRun
|
||||||
|
EmergencyType.MEDICAL -> Icons.Default.LocalHospital
|
||||||
|
EmergencyType.FIRE -> Icons.Default.Whatshot
|
||||||
|
EmergencyType.ACCIDENT -> Icons.Default.CarCrash
|
||||||
|
EmergencyType.OTHER -> Icons.AutoMirrored.Filled.Help
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Red,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = event.eventType.name,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
event.userInfo?.let { userInfo ->
|
||||||
|
Text(
|
||||||
|
text = "${userInfo.firstName}${userInfo.age?.let { ", $it лет" } ?: ""}",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.message.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = event.message,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
event.distanceMeters?.let { distance ->
|
||||||
|
Text(
|
||||||
|
text = "${distance.toInt()}м",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(event.createdAt)),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { onRespond(ResponseType.ON_WAY) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Directions,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Помочь", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onRespond(ResponseType.CALLED_POLICE) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Phone,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("112", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmptyStateCard(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Security,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "Все спокойно",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "В данный момент нет активных экстренных ситуаций в вашем районе",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HandleUIEffects(
|
||||||
|
uiState: EmergencyUiState,
|
||||||
|
onClearSuccess: () -> Unit,
|
||||||
|
onClearError: () -> Unit
|
||||||
|
) {
|
||||||
|
LaunchedEffect(uiState.showSuccessMessage) {
|
||||||
|
if (uiState.showSuccessMessage && !uiState.successMessage.isNullOrBlank()) {
|
||||||
|
// Show success message
|
||||||
|
onClearSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.errorMessage) {
|
||||||
|
if (!uiState.errorMessage.isNullOrBlank()) {
|
||||||
|
// Show error message
|
||||||
|
onClearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.presentation.viewmodels
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.models.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.usecases.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.data.models.*
|
||||||
|
import kr.smartsoltech.wellshe.emergency.utils.PermissionManager
|
||||||
|
import kr.smartsoltech.wellshe.emergency.utils.EmergencyNotificationManager
|
||||||
|
import kr.smartsoltech.wellshe.emergency.domain.repository.EmergencyRepository
|
||||||
|
import kr.smartsoltech.wellshe.data.storage.TokenManager
|
||||||
|
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class EmergencyViewModel @Inject constructor(
|
||||||
|
private val createEmergencyUseCase: CreateEmergencyEventUseCase,
|
||||||
|
private val getNearbyEventsUseCase: GetNearbyEventsUseCase,
|
||||||
|
private val respondToEventUseCase: RespondToEventUseCase,
|
||||||
|
private val observeEmergencyAlertsUseCase: ObserveEmergencyAlertsUseCase,
|
||||||
|
private val repository: EmergencyRepository,
|
||||||
|
private val permissionManager: PermissionManager,
|
||||||
|
private val notificationManager: EmergencyNotificationManager,
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val authTokenRepository: AuthTokenRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(EmergencyUiState())
|
||||||
|
val uiState: StateFlow<EmergencyUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _emergencyEvents = MutableStateFlow<List<EmergencyEvent>>(emptyList())
|
||||||
|
val emergencyEvents: StateFlow<List<EmergencyEvent>> = _emergencyEvents.asStateFlow()
|
||||||
|
|
||||||
|
val connectionStatus = repository.getConnectionStatus()
|
||||||
|
|
||||||
|
init {
|
||||||
|
initializeEmergencyModule()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeEmergencyModule() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Проверить разрешения
|
||||||
|
checkRequiredPermissions()
|
||||||
|
|
||||||
|
// Попытка получить реальный токен и подключиться по WebSocket
|
||||||
|
// Предпочитаем TokenManager (свежий in-memory токен), затем DataStore-backed auth token
|
||||||
|
val dsToken = authTokenRepository.authToken.firstOrNull()
|
||||||
|
val tmToken = tokenManager.getAccessToken()
|
||||||
|
|
||||||
|
// Если в DataStore остался временный плейсхолдер вроде "temp_token_for_...", игнорируем его
|
||||||
|
// Игнорируем placeholder в обоих хранилищах
|
||||||
|
val effectiveTmToken = if (!tmToken.isNullOrEmpty() && !tmToken.startsWith("temp_token_for_")) tmToken else null
|
||||||
|
val effectiveDsToken = if (!dsToken.isNullOrEmpty() && !dsToken.startsWith("temp_token_for_")) dsToken else null
|
||||||
|
|
||||||
|
if (tmToken != null && tmToken.startsWith("temp_token_for_")) {
|
||||||
|
Log.w("EmergencyVM", "TokenManager contains placeholder token; ignoring for WS connect: $tmToken")
|
||||||
|
}
|
||||||
|
if (dsToken != null && dsToken.startsWith("temp_token_for_")) {
|
||||||
|
Log.w("EmergencyVM", "DataStore contains placeholder token; ignoring for WS connect: $dsToken")
|
||||||
|
}
|
||||||
|
|
||||||
|
val token = effectiveTmToken ?: effectiveDsToken
|
||||||
|
|
||||||
|
if (!token.isNullOrEmpty()) {
|
||||||
|
Log.d("EmergencyVM", "Connecting WebSocket with masked token: ${token.take(6)}... (source=${if (!tmToken.isNullOrEmpty()) "TokenManager" else "DataStore"})")
|
||||||
|
repository.connectWebSocket("current_user_id", token)
|
||||||
|
} else {
|
||||||
|
Log.d("EmergencyVM", "No access token available — пропускаем авто-подключение WebSocket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписаться на алерты
|
||||||
|
observeEmergencyAlerts()
|
||||||
|
|
||||||
|
// Загрузить ближайшие события
|
||||||
|
refreshNearbyEvents()
|
||||||
|
|
||||||
|
// Синхронизировать локальные события
|
||||||
|
syncLocalEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSOS(message: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (!permissionManager.hasLocationPermission()) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
errorMessage = "Необходимо разрешение на геолокацию"
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isCreatingEmergency = true,
|
||||||
|
sosButtonPressed = true,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d("EmergencyVM", "Calling createEmergencyUseCase...")
|
||||||
|
|
||||||
|
// Создаем правильный объект CreateEmergencyRequest
|
||||||
|
val request = CreateEmergencyRequest(
|
||||||
|
latitude = 0.0, // TODO: получить реальные координаты
|
||||||
|
longitude = 0.0,
|
||||||
|
eventType = EmergencyType.SOS,
|
||||||
|
message = message,
|
||||||
|
severity = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
createEmergencyUseCase(request).fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
Log.i("EmergencyVM", "SOS created successfully: eventId=${response.eventId}, nearbyUsers=${response.nearbyUsersNotified}")
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isCreatingEmergency = false,
|
||||||
|
sosButtonPressed = false,
|
||||||
|
lastEmergencyId = response.eventId,
|
||||||
|
showSuccessMessage = true,
|
||||||
|
successMessage = "SOS отправлен! Уведомлено ${response.nearbyUsersNotified} пользователей"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Показать уведомление
|
||||||
|
try {
|
||||||
|
viewModelScope.launch {
|
||||||
|
notificationManager.showSOSCreatedNotification(response)
|
||||||
|
}
|
||||||
|
Log.d("EmergencyVM", "SOS notification shown")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("EmergencyVM", "Error showing SOS notification: ${e.message}", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновить список событий
|
||||||
|
Log.d("EmergencyVM", "Refreshing nearby events after SOS creation")
|
||||||
|
refreshNearbyEvents()
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
Log.e("EmergencyVM", "Failed to create SOS: ${error.message}", error)
|
||||||
|
|
||||||
|
val userFriendlyMessage = when {
|
||||||
|
error.message?.contains("геолокац") == true -> "Не удалось получить вашу геолокацию"
|
||||||
|
error.message?.contains("auth token") == true -> "Ошибка авторизации. Попробуйте войти заново"
|
||||||
|
error.message?.contains("API Error: 401") == true -> "Сессия истекла. Необходимо войти заново"
|
||||||
|
error.message?.contains("API Error: 403") == true -> "Доступ запрещён. Проверьте права доступа"
|
||||||
|
error.message?.contains("API Error: 500") == true -> "Ошибка сервера. Попробуйте позже"
|
||||||
|
error.message?.contains("network") == true -> "Проблемы с сетью. Проверьте подключение к интернету"
|
||||||
|
else -> "Ошибка отправки SOS: ${error.message}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isCreatingEmergency = false,
|
||||||
|
sosButtonPressed = false,
|
||||||
|
errorMessage = userFriendlyMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createCustomAlert(eventType: EmergencyType, message: String?, severity: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (!permissionManager.hasLocationPermission()) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
errorMessage = "Необходимо разрешение на геолокацию"
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(isCreatingEmergency = true)
|
||||||
|
|
||||||
|
val request = CreateEmergencyRequest(
|
||||||
|
latitude = 0.0, // TODO: получить реальные координаты
|
||||||
|
longitude = 0.0,
|
||||||
|
eventType = eventType,
|
||||||
|
message = message,
|
||||||
|
severity = severity
|
||||||
|
)
|
||||||
|
|
||||||
|
createEmergencyUseCase(request).fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isCreatingEmergency = false,
|
||||||
|
showSuccessMessage = true,
|
||||||
|
successMessage = "Сигнал ${eventType.name} отправлен"
|
||||||
|
)
|
||||||
|
refreshNearbyEvents()
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isCreatingEmergency = false,
|
||||||
|
errorMessage = error.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun respondToEvent(eventId: String, responseType: ResponseType = ResponseType.ON_WAY, message: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val response = EventResponse(
|
||||||
|
id = "",
|
||||||
|
eventId = eventId,
|
||||||
|
responderId = "current_user", // TODO: получить реальный ID
|
||||||
|
responseType = responseType,
|
||||||
|
message = message,
|
||||||
|
estimatedArrival = null,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
responderInfo = null
|
||||||
|
)
|
||||||
|
|
||||||
|
respondToEventUseCase(eventId, response).fold(
|
||||||
|
onSuccess = {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showSuccessMessage = true,
|
||||||
|
successMessage = "Ваш ответ отправлен"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
errorMessage = "Ошибка отправки ответа: ${error.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshNearbyEvents() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoadingNearbyEvents = true)
|
||||||
|
|
||||||
|
// TODO: получить реальные координаты пользователя
|
||||||
|
getNearbyEventsUseCase(0.0, 0.0, 1000).fold(
|
||||||
|
onSuccess = { events ->
|
||||||
|
_emergencyEvents.value = events
|
||||||
|
_uiState.value = _uiState.value.copy(isLoadingNearbyEvents = false)
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoadingNearbyEvents = false,
|
||||||
|
errorMessage = "Ошибка загрузки событий: ${error.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeEmergencyAlerts() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
observeEmergencyAlertsUseCase().collect { events ->
|
||||||
|
_emergencyEvents.value = events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkRequiredPermissions() {
|
||||||
|
val hasLocation = permissionManager.hasLocationPermission()
|
||||||
|
val hasNotifications = permissionManager.hasNotificationPermission()
|
||||||
|
// For enabling SOS we require location permissions (foreground). Notification permission is optional.
|
||||||
|
val permissionsGranted = hasLocation
|
||||||
|
|
||||||
|
// Log for debugging why UI may show permissions required
|
||||||
|
try {
|
||||||
|
Log.d("EmergencyVM", "Permissions check — location=$hasLocation, notifications=$hasNotifications -> ui.permissionsGranted=$permissionsGranted")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// ignore logging errors
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(permissionsGranted = permissionsGranted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncLocalEvents() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.syncLocalEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSuccessMessage() {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showSuccessMessage = false,
|
||||||
|
successMessage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearErrorMessage() {
|
||||||
|
_uiState.value = _uiState.value.copy(errorMessage = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метод для ручной очистки SOS уведомлений
|
||||||
|
fun clearSOSNotifications() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
notificationManager.clearSOSNotifications()
|
||||||
|
Log.d("EmergencyVM", "SOS notifications cleared successfully")
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showSuccessMessage = true,
|
||||||
|
successMessage = "SOS уведомления очищены"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("EmergencyVM", "Error clearing SOS notifications: ${e.message}", e)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
errorMessage = "Ошибка при очистке уведомлений: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метод для очистки всех экстренных уведомлений
|
||||||
|
fun clearAllEmergencyNotifications() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
notificationManager.cancelAllEmergencyNotifications()
|
||||||
|
Log.d("EmergencyVM", "All emergency notifications cleared successfully")
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
showSuccessMessage = true,
|
||||||
|
successMessage = "Все экстренные уведомления очищены"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("EmergencyVM", "Error clearing all emergency notifications: ${e.message}", e)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
errorMessage = "Ошибка при очистке всех уведомлений: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPermissionsResult(granted: Boolean) {
|
||||||
|
_uiState.value = _uiState.value.copy(permissionsGranted = granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class EmergencyUiState(
|
||||||
|
val isCreatingEmergency: Boolean = false,
|
||||||
|
val sosButtonPressed: Boolean = false,
|
||||||
|
val lastEmergencyId: String? = null,
|
||||||
|
val showSuccessMessage: Boolean = false,
|
||||||
|
val successMessage: String? = null,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val isLoadingNearbyEvents: Boolean = false,
|
||||||
|
val permissionsGranted: Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.os.Build
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ConnectivityManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
fun isNetworkAvailable(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
val network = connectivityManager.activeNetwork ?: return false
|
||||||
|
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||||
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val networkInfo = connectivityManager.activeNetworkInfo
|
||||||
|
networkInfo?.isConnected == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNetworkType(): String {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
val network = connectivityManager.activeNetwork ?: return "none"
|
||||||
|
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return "none"
|
||||||
|
|
||||||
|
return when {
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
|
||||||
|
else -> "other"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val networkInfo = connectivityManager.activeNetworkInfo
|
||||||
|
return networkInfo?.typeName?.lowercase() ?: "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isWifiConnected(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
val network = connectivityManager.activeNetwork ?: return false
|
||||||
|
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||||
|
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val networkInfo = connectivityManager.activeNetworkInfo
|
||||||
|
return networkInfo?.type == ConnectivityManager.TYPE_WIFI && networkInfo.isConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package kr.smartsoltech.wellshe.emergency.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.BatteryManager
|
||||||
|
import android.os.Build
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kr.smartsoltech.wellshe.BuildConfig
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class DeviceInfoProvider @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun getBatteryLevel(): Int {
|
||||||
|
val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||||
|
val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
|
||||||
|
val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
|
||||||
|
|
||||||
|
return if (level != -1 && scale != -1) {
|
||||||
|
(level * 100 / scale.toFloat()).toInt()
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAppVersion(): String {
|
||||||
|
return try {
|
||||||
|
BuildConfig.VERSION_NAME
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDeviceInfo(): Map<String, String> {
|
||||||
|
return mapOf(
|
||||||
|
"model" to Build.MODEL,
|
||||||
|
"manufacturer" to Build.MANUFACTURER,
|
||||||
|
"android_version" to Build.VERSION.RELEASE,
|
||||||
|
"sdk_int" to Build.VERSION.SDK_INT.toString(),
|
||||||
|
"device" to Build.DEVICE,
|
||||||
|
"product" to Build.PRODUCT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isLowPowerMode(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
val powerManager = context.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
|
||||||
|
powerManager.isPowerSaveMode
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user