Compare commits
7 Commits
198d43db29
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e9ec5c187 | |||
| 6f969dbd1a | |||
| 86b5df6c10 | |||
| 37cf587ce6 | |||
| 521e127a00 | |||
| 34911e8a82 | |||
| 46ce31ba6a |
26
.idea/appInsightsSettings.xml
generated
Normal file
26
.idea/appInsightsSettings.xml
generated
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AppInsightsSettings">
|
||||||
|
<option name="tabSettings">
|
||||||
|
<map>
|
||||||
|
<entry key="Firebase Crashlytics">
|
||||||
|
<value>
|
||||||
|
<InsightsFilterSettings>
|
||||||
|
<option name="connection">
|
||||||
|
<ConnectionSetting>
|
||||||
|
<option name="appId" value="PLACEHOLDER" />
|
||||||
|
<option name="mobileSdkAppId" value="" />
|
||||||
|
<option name="projectId" value="" />
|
||||||
|
<option name="projectNumber" value="" />
|
||||||
|
</ConnectionSetting>
|
||||||
|
</option>
|
||||||
|
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||||
|
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||||
|
<option name="visibilityType" value="ALL" />
|
||||||
|
</InsightsFilterSettings>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AskMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Ask2AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="EditMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
18
.idea/deploymentTargetSelector.xml
generated
Normal file
18
.idea/deploymentTargetSelector.xml
generated
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetSelector">
|
||||||
|
<selectionStates>
|
||||||
|
<SelectionState runConfigName="app">
|
||||||
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DropdownSelection timestamp="2025-10-07T06:51:31.183962394Z">
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=LGMG600S9b4da66b" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</DropdownSelection>
|
||||||
|
<DialogSelection />
|
||||||
|
</SelectionState>
|
||||||
|
</selectionStates>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
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>
|
||||||
|
|||||||
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@@ -1,4 +1,7 @@
|
|||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
>>>>>>> 198d43db29cb709eaba6466e4d4490c76c01bb2d
|
||||||
<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">
|
||||||
|
|||||||
1
app/API desc
Normal file
1
app/API desc
Normal file
File diff suppressed because one or more lines are too long
@@ -1,21 +1,29 @@
|
|||||||
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.compose)
|
id("com.google.devtools.ksp") version "1.9.20-1.0.14"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.womansafe"
|
namespace = "com.example.womansafe"
|
||||||
compileSdk = 36
|
compileSdk = 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.example.womansafe"
|
applicationId = "com.example.womansafe"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Включаем десугаринг для поддержки Java 8 API на старых устройствах
|
||||||
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -28,32 +36,84 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "11"
|
jvmTarget = "1.8"
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.4"
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2023.08.00"))
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
// Material Icons
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation("androidx.compose.material:material-icons-core")
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
|
||||||
implementation(libs.androidx.compose.ui)
|
// Keyboard options for text fields
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation("androidx.compose.foundation:foundation")
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
|
||||||
implementation(libs.androidx.compose.material3)
|
// Security
|
||||||
testImplementation(libs.junit)
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
androidTestImplementation(libs.androidx.junit)
|
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
// Navigation
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
// ViewModel
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||||
|
|
||||||
|
// Networking
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
|
||||||
|
// Location Services
|
||||||
|
implementation("com.google.android.gms:play-services-location:21.0.1")
|
||||||
|
implementation("com.google.android.gms:play-services-maps:18.2.0")
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
|
||||||
|
|
||||||
|
// Coil для загрузки изображений
|
||||||
|
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||||
|
|
||||||
|
// Room Database
|
||||||
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
|
annotationProcessor("androidx.room:room-compiler:2.6.1")
|
||||||
|
ksp("androidx.room:room-compiler:2.6.1")
|
||||||
|
|
||||||
|
// LiveData
|
||||||
|
implementation("androidx.compose.runtime:runtime-livedata")
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
|
||||||
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
|
||||||
|
// Десугаринг для поддержки Java 8 API (включая java.time) на Android API 24 и ниже
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,17 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Internet permission for API calls -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- Location permissions for emergency functionality -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
|
||||||
|
<!-- Permission to make phone calls for emergency contacts -->
|
||||||
|
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -10,7 +21,10 @@
|
|||||||
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:theme="@style/Theme.WomanSafe">
|
android:theme="@style/Theme.WomanSafe"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -4,44 +4,50 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.material3.Text
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import com.example.womansafe.data.network.NetworkClient
|
||||||
|
import com.example.womansafe.ui.screens.AuthScreen
|
||||||
|
import com.example.womansafe.ui.screens.MainScreen
|
||||||
import com.example.womansafe.ui.theme.WomanSafeTheme
|
import com.example.womansafe.ui.theme.WomanSafeTheme
|
||||||
|
import com.example.womansafe.ui.viewmodel.AuthViewModel
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val authViewModel: AuthViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Инициализируем NetworkClient для работы с сохраненным токеном
|
||||||
|
NetworkClient.initialize(applicationContext)
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
WomanSafeTheme {
|
WomanSafeTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
Surface(
|
||||||
Greeting(
|
modifier = Modifier.fillMaxSize(),
|
||||||
name = "Android",
|
color = MaterialTheme.colorScheme.background
|
||||||
modifier = Modifier.padding(innerPadding)
|
) {
|
||||||
)
|
// Проверяем сохраненный токен и пытаемся выполнить автоматический вход
|
||||||
}
|
LaunchedEffect(Unit) {
|
||||||
}
|
NetworkClient.getAuthToken()?.let { token ->
|
||||||
}
|
// Если токен существует, пытаемся выполнить автоматический вход
|
||||||
|
authViewModel.autoLogin(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
// Показываем либо экран авторизации, либо главный экран
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
if (authViewModel.uiState.isLoggedIn) {
|
||||||
Text(
|
MainScreen(authViewModel = authViewModel)
|
||||||
text = "Hello $name!",
|
} else {
|
||||||
modifier = modifier
|
AuthScreen(viewModel = authViewModel)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@Preview(showBackground = true)
|
}
|
||||||
@Composable
|
|
||||||
fun GreetingPreview() {
|
|
||||||
WomanSafeTheme {
|
|
||||||
Greeting("Android")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.example.womansafe.data.api
|
||||||
|
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleData
|
||||||
|
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API интерфейс для взаимодействия с серверной частью календаря
|
||||||
|
*/
|
||||||
|
interface CalendarApi {
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/cycle-data")
|
||||||
|
suspend fun getCycleData(): Response<CycleData>
|
||||||
|
|
||||||
|
@POST("api/v1/calendar/cycle-data")
|
||||||
|
suspend fun updateCycleData(@Body cycleData: CycleData): Response<CycleData>
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/entries")
|
||||||
|
suspend fun getCalendarEntries(
|
||||||
|
@Query("start_date") startDate: String? = null,
|
||||||
|
@Query("end_date") endDate: String? = null
|
||||||
|
): Response<List<CalendarEntry>>
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/entries/{entry_id}")
|
||||||
|
suspend fun getCalendarEntry(
|
||||||
|
@Path("entry_id") entryId: Long
|
||||||
|
): Response<CalendarEntry>
|
||||||
|
|
||||||
|
@POST("api/v1/calendar/entries")
|
||||||
|
suspend fun addCalendarEntry(
|
||||||
|
@Body entry: CalendarEntry
|
||||||
|
): Response<CalendarEntry>
|
||||||
|
|
||||||
|
@PUT("api/v1/calendar/entries/{entry_id}")
|
||||||
|
suspend fun updateCalendarEntry(
|
||||||
|
@Path("entry_id") entryId: Long,
|
||||||
|
@Body entry: CalendarEntry
|
||||||
|
): Response<CalendarEntry>
|
||||||
|
|
||||||
|
@DELETE("api/v1/calendar/entries/{entry_id}")
|
||||||
|
suspend fun deleteCalendarEntry(
|
||||||
|
@Path("entry_id") entryId: Long
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/insights")
|
||||||
|
suspend fun getHealthInsights(): Response<List<HealthInsight>>
|
||||||
|
|
||||||
|
@POST("api/v1/calendar/insights/{insight_id}/dismiss")
|
||||||
|
suspend fun dismissInsight(
|
||||||
|
@Path("insight_id") insightId: Long
|
||||||
|
): Response<Unit>
|
||||||
|
}
|
||||||
196
app/src/main/java/com/example/womansafe/data/api/WomanSafeApi.kt
Normal file
196
app/src/main/java/com/example/womansafe/data/api/WomanSafeApi.kt
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package com.example.womansafe.data.api
|
||||||
|
|
||||||
|
import com.example.womansafe.data.model.*
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
interface WomanSafeApi {
|
||||||
|
// Authentication endpoints
|
||||||
|
@POST("api/v1/auth/login")
|
||||||
|
suspend fun login(@Body request: UserLogin): Response<Token>
|
||||||
|
|
||||||
|
@POST("api/v1/auth/register")
|
||||||
|
suspend fun register(@Body request: UserCreate): Response<UserResponse>
|
||||||
|
|
||||||
|
// User endpoints
|
||||||
|
@GET("api/v1/users/me")
|
||||||
|
suspend fun getCurrentUser(): Response<UserResponse>
|
||||||
|
|
||||||
|
@PUT("api/v1/users/me")
|
||||||
|
suspend fun updateCurrentUser(@Body request: ApiRequestBody): Response<UserResponse>
|
||||||
|
|
||||||
|
@PATCH("api/v1/users/me")
|
||||||
|
suspend fun patchCurrentUser(@Body request: ApiRequestBody): Response<UserResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/users/me/change-password")
|
||||||
|
suspend fun changePassword(@Body request: ApiRequestBody): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/v1/users/dashboard")
|
||||||
|
suspend fun getDashboard(): Response<Any>
|
||||||
|
|
||||||
|
// Profile endpoints
|
||||||
|
@GET("api/v1/profile")
|
||||||
|
suspend fun getProfile(): Response<UserResponse>
|
||||||
|
|
||||||
|
@PUT("api/v1/profile")
|
||||||
|
suspend fun updateProfile(@Body request: ApiRequestBody): Response<UserResponse>
|
||||||
|
|
||||||
|
// Emergency Contacts endpoints
|
||||||
|
@GET("api/v1/users/me/emergency-contacts")
|
||||||
|
suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>>
|
||||||
|
|
||||||
|
@POST("api/v1/users/me/emergency-contacts")
|
||||||
|
suspend fun createEmergencyContact(@Body request: EmergencyContactCreate): Response<EmergencyContactResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/users/me/emergency-contacts/{contact_id}")
|
||||||
|
suspend fun getEmergencyContact(@Path("contact_id") contactId: String): Response<EmergencyContactResponse>
|
||||||
|
|
||||||
|
@PATCH("api/v1/users/me/emergency-contacts/{contact_id}")
|
||||||
|
suspend fun updateEmergencyContact(@Path("contact_id") contactId: String, @Body request: ApiRequestBody): Response<EmergencyContactResponse>
|
||||||
|
|
||||||
|
@DELETE("api/v1/users/me/emergency-contacts/{contact_id}")
|
||||||
|
suspend fun deleteEmergencyContact(@Path("contact_id") contactId: String): Response<Unit>
|
||||||
|
|
||||||
|
// Emergency Reports endpoints
|
||||||
|
@GET("api/v1/emergency/reports")
|
||||||
|
suspend fun getEmergencyReports(): Response<Any>
|
||||||
|
|
||||||
|
@POST("api/v1/emergency/reports")
|
||||||
|
suspend fun createEmergencyReport(): Response<Any>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/reports/nearby")
|
||||||
|
suspend fun getNearbyEmergencyReports(): Response<Any>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/reports/{report_id}")
|
||||||
|
suspend fun getEmergencyReport(@Path("report_id") reportId: String): Response<Any>
|
||||||
|
|
||||||
|
@PATCH("api/v1/emergency/reports/{report_id}")
|
||||||
|
suspend fun updateEmergencyReport(@Path("report_id") reportId: String): Response<Any>
|
||||||
|
|
||||||
|
@DELETE("api/v1/emergency/reports/{report_id}")
|
||||||
|
suspend fun deleteEmergencyReport(@Path("report_id") reportId: String): Response<Any>
|
||||||
|
|
||||||
|
// Emergency Alerts endpoints
|
||||||
|
@GET("api/v1/emergency/alerts")
|
||||||
|
suspend fun getEmergencyAlerts(): Response<List<EmergencyAlertResponse>>
|
||||||
|
|
||||||
|
@POST("api/v1/emergency/alerts")
|
||||||
|
suspend fun createEmergencyAlert(@Body request: EmergencyAlertCreate): Response<EmergencyAlertResponse>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/alerts/my")
|
||||||
|
suspend fun getMyEmergencyAlerts(): Response<List<EmergencyAlertResponse>>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/alerts/nearby")
|
||||||
|
suspend fun getNearbyEmergencyAlerts(): Response<Any>
|
||||||
|
|
||||||
|
@GET("api/v1/emergency/alerts/{alert_id}")
|
||||||
|
suspend fun getEmergencyAlert(@Path("alert_id") alertId: String): Response<Any>
|
||||||
|
|
||||||
|
@PATCH("api/v1/emergency/alerts/{alert_id}")
|
||||||
|
suspend fun updateEmergencyAlert(@Path("alert_id") alertId: String): Response<Any>
|
||||||
|
|
||||||
|
@DELETE("api/v1/emergency/alerts/{alert_id}")
|
||||||
|
suspend fun deleteEmergencyAlert(@Path("alert_id") alertId: String): Response<Any>
|
||||||
|
|
||||||
|
@PATCH("api/v1/emergency/alerts/{alert_id}/cancel")
|
||||||
|
suspend fun cancelEmergencyAlert(@Path("alert_id") alertId: String): Response<Any>
|
||||||
|
|
||||||
|
// Location endpoints
|
||||||
|
@POST("api/v1/locations/update")
|
||||||
|
suspend fun updateLocation(): Response<Any>
|
||||||
|
|
||||||
|
@GET("api/v1/locations/last")
|
||||||
|
suspend fun getLastLocation(): Response<Any>
|
||||||
|
|
||||||
|
@GET("api/v1/locations/history")
|
||||||
|
suspend fun getLocationHistory(): Response<Any>
|
||||||
|
|
||||||
|
@GET("api/v1/locations/users/nearby")
|
||||||
|
suspend fun getNearbyUsers(): Response<Any>
|
||||||
|
|
||||||
|
@GET("api/v1/locations/safe-places")
|
||||||
|
suspend fun getSafePlaces(): Response<Any>
|
||||||
|
|
||||||
|
@POST("api/v1/locations/safe-places")
|
||||||
|
suspend fun createSafePlace(): Response<Any>
|
||||||
|
|
||||||
|
@GET("api/v1/locations/safe-places/{place_id}")
|
||||||
|
suspend fun getSafePlace(@Path("place_id") placeId: String): Response<Any>
|
||||||
|
|
||||||
|
@PATCH("api/v1/locations/safe-places/{place_id}")
|
||||||
|
suspend fun updateSafePlace(@Path("place_id") placeId: String): Response<Any>
|
||||||
|
|
||||||
|
@DELETE("api/v1/locations/safe-places/{place_id}")
|
||||||
|
suspend fun deleteSafePlace(@Path("place_id") placeId: String): Response<Any>
|
||||||
|
|
||||||
|
// Календарь и отслеживание цикла
|
||||||
|
@GET("api/v1/calendar/cycle-data")
|
||||||
|
suspend fun getCycleData(): Response<CycleData>
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/entries")
|
||||||
|
suspend fun getCalendarEntries(
|
||||||
|
@Query("start_date") startDate: String? = null,
|
||||||
|
@Query("end_date") endDate: String? = null,
|
||||||
|
@Query("entry_type") entryType: String? = null,
|
||||||
|
@Query("limit") limit: Int? = null
|
||||||
|
): Response<List<CalendarEvent>>
|
||||||
|
|
||||||
|
@POST("api/v1/calendar/entries")
|
||||||
|
suspend fun createCalendarEntry(@Body entry: CalendarEntryRequest): Response<CalendarEvent>
|
||||||
|
|
||||||
|
@PUT("api/v1/calendar/entries/{entry_id}")
|
||||||
|
suspend fun updateCalendarEntry(
|
||||||
|
@Path("entry_id") entryId: String,
|
||||||
|
@Body entry: CalendarEntryRequest
|
||||||
|
): Response<CalendarEvent>
|
||||||
|
|
||||||
|
@DELETE("api/v1/calendar/entries/{entry_id}")
|
||||||
|
suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/statistics")
|
||||||
|
suspend fun getCycleStatistics(): Response<CycleStatistics>
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/predictions")
|
||||||
|
suspend fun getCyclePredictions(): Response<CyclePrediction>
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/insights")
|
||||||
|
suspend fun getHealthInsights(): Response<List<HealthInsight>>
|
||||||
|
|
||||||
|
@PATCH("api/v1/calendar/insights/{insight_id}/dismiss")
|
||||||
|
suspend fun dismissInsight(@Path("insight_id") insightId: String): Response<HealthInsight>
|
||||||
|
|
||||||
|
// Notification endpoints
|
||||||
|
@GET("api/v1/notifications/devices")
|
||||||
|
suspend fun getNotificationDevices(): Response<Any>
|
||||||
|
|
||||||
|
@POST("api/v1/notifications/devices")
|
||||||
|
suspend fun createNotificationDevice(): Response<Any>
|
||||||
|
|
||||||
|
@GET("api/v1/notifications/devices/{device_id}")
|
||||||
|
suspend fun getNotificationDevice(@Path("device_id") deviceId: String): Response<Any>
|
||||||
|
|
||||||
|
@DELETE("api/v1/notifications/devices/{device_id}")
|
||||||
|
suspend fun deleteNotificationDevice(@Path("device_id") deviceId: String): Response<Any>
|
||||||
|
|
||||||
|
@GET("api/v1/notifications/preferences")
|
||||||
|
suspend fun getNotificationPreferences(): Response<Any>
|
||||||
|
|
||||||
|
@POST("api/v1/notifications/preferences")
|
||||||
|
suspend fun updateNotificationPreferences(): Response<Any>
|
||||||
|
|
||||||
|
@POST("api/v1/notifications/test")
|
||||||
|
suspend fun testNotification(): Response<Any>
|
||||||
|
|
||||||
|
@GET("api/v1/notifications/history")
|
||||||
|
suspend fun getNotificationHistory(): Response<Any>
|
||||||
|
|
||||||
|
// Health check endpoints
|
||||||
|
@GET("api/v1/health")
|
||||||
|
suspend fun getHealth(): Response<Any>
|
||||||
|
|
||||||
|
@GET("api/v1/services-status")
|
||||||
|
suspend fun getServicesStatus(): Response<Any>
|
||||||
|
|
||||||
|
@GET("/")
|
||||||
|
suspend fun getRoot(): Response<Any>
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.example.womansafe.data.local
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleData
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO для работы с данными менструального календаря
|
||||||
|
*/
|
||||||
|
@Dao
|
||||||
|
interface CalendarDao {
|
||||||
|
// CycleData operations
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertCycleData(cycleData: CycleData)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_data WHERE userId = :userId")
|
||||||
|
suspend fun getCycleDataByUserId(userId: String): CycleData?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_data WHERE userId = :userId")
|
||||||
|
fun getCycleDataFlowByUserId(userId: String): Flow<CycleData?>
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteCycleData(cycleData: CycleData)
|
||||||
|
|
||||||
|
// CalendarEntry operations
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertCalendarEntry(entry: CalendarEntry): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertCalendarEntries(entries: List<CalendarEntry>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateCalendarEntry(entry: CalendarEntry)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calendar_entries WHERE userId = :userId ORDER BY entryDate DESC")
|
||||||
|
fun getAllCalendarEntriesFlow(userId: String): Flow<List<CalendarEntry>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calendar_entries WHERE userId = :userId AND entryDate = :date")
|
||||||
|
suspend fun getCalendarEntryByDate(userId: String, date: LocalDate): CalendarEntry?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calendar_entries WHERE userId = :userId AND entryDate BETWEEN :startDate AND :endDate ORDER BY entryDate")
|
||||||
|
suspend fun getCalendarEntriesBetweenDates(userId: String, startDate: LocalDate, endDate: LocalDate): List<CalendarEntry>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calendar_entries WHERE userId = :userId AND entryDate BETWEEN :startDate AND :endDate ORDER BY entryDate")
|
||||||
|
fun getCalendarEntriesBetweenDatesFlow(userId: String, startDate: LocalDate, endDate: LocalDate): Flow<List<CalendarEntry>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calendar_entries WHERE id = :entryId")
|
||||||
|
suspend fun getCalendarEntryById(entryId: Long): CalendarEntry?
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteCalendarEntry(entry: CalendarEntry)
|
||||||
|
|
||||||
|
@Query("DELETE FROM calendar_entries WHERE userId = :userId AND entryDate = :date")
|
||||||
|
suspend fun deleteCalendarEntryByDate(userId: String, date: LocalDate)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calendar_entries ORDER BY entryDate DESC")
|
||||||
|
suspend fun getAllCalendarEntries(): List<CalendarEntry>
|
||||||
|
|
||||||
|
// HealthInsight operations
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertHealthInsight(insight: HealthInsight): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertHealthInsights(insights: List<HealthInsight>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateHealthInsight(insight: HealthInsight)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM health_insights WHERE userId = :userId AND isDismissed = 0 ORDER BY createdAt DESC")
|
||||||
|
fun getActiveHealthInsightsFlow(userId: String): Flow<List<HealthInsight>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM health_insights WHERE userId = :userId ORDER BY createdAt DESC")
|
||||||
|
fun getAllHealthInsightsFlow(userId: String): Flow<List<HealthInsight>>
|
||||||
|
|
||||||
|
@Query("UPDATE health_insights SET isDismissed = 1 WHERE id = :insightId")
|
||||||
|
suspend fun dismissHealthInsight(insightId: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM health_insights WHERE userId = :userId AND createdAt < :timestamp")
|
||||||
|
suspend fun deleteOldInsights(userId: String, timestamp: Long)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_data ORDER BY lastUpdated DESC LIMIT 1")
|
||||||
|
suspend fun getLatestCycleData(): CycleData?
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.example.womansafe.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleData
|
||||||
|
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||||
|
|
||||||
|
/**
|
||||||
|
* База данных Room для хранения всех данных менструального календаря
|
||||||
|
*/
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
CycleData::class,
|
||||||
|
CalendarEntry::class,
|
||||||
|
HealthInsight::class
|
||||||
|
],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
@TypeConverters(CalendarTypeConverters::class)
|
||||||
|
abstract class CalendarDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun calendarDao(): CalendarDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: CalendarDatabase? = null
|
||||||
|
|
||||||
|
fun getDatabase(context: Context): CalendarDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
CalendarDatabase::class.java,
|
||||||
|
"calendar_database"
|
||||||
|
)
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package com.example.womansafe.data.local
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import com.example.womansafe.data.model.calendar.*
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертеры типов для Room базы данных для работы с нестандартными типами
|
||||||
|
*/
|
||||||
|
class CalendarTypeConverters {
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromLocalDate(value: LocalDate?): String? {
|
||||||
|
return value?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toLocalDate(value: String?): LocalDate? {
|
||||||
|
return value?.let { LocalDate.parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromSymptomList(value: List<Symptom>?): String? {
|
||||||
|
return value?.let { gson.toJson(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toSymptomList(value: String?): List<Symptom>? {
|
||||||
|
if (value == null) return null
|
||||||
|
val listType = object : TypeToken<List<Symptom>>() {}.type
|
||||||
|
return gson.fromJson(value, listType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromStringList(value: List<String>?): String? {
|
||||||
|
return value?.let { gson.toJson(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toStringList(value: String?): List<String>? {
|
||||||
|
if (value == null) return null
|
||||||
|
val listType = object : TypeToken<List<String>>() {}.type
|
||||||
|
return gson.fromJson(value, listType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromEntryType(value: EntryType): String {
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toEntryType(value: String): EntryType {
|
||||||
|
return try {
|
||||||
|
EntryType.valueOf(value)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
EntryType.NOTE // Дефолтное значение
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromFlowIntensity(value: FlowIntensity?): String? {
|
||||||
|
return value?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toFlowIntensity(value: String?): FlowIntensity? {
|
||||||
|
if (value == null) return null
|
||||||
|
return try {
|
||||||
|
FlowIntensity.valueOf(value)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromMood(value: Mood?): String? {
|
||||||
|
return value?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toMood(value: String?): Mood? {
|
||||||
|
if (value == null) return null
|
||||||
|
return try {
|
||||||
|
Mood.valueOf(value)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromInsightType(value: InsightType): String {
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toInsightType(value: String): InsightType {
|
||||||
|
return InsightType.valueOf(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromConfidenceLevel(value: ConfidenceLevel): String {
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toConfidenceLevel(value: String): ConfidenceLevel {
|
||||||
|
return ConfidenceLevel.valueOf(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
353
app/src/main/java/com/example/womansafe/data/model/ApiModels.kt
Normal file
353
app/src/main/java/com/example/womansafe/data/model/ApiModels.kt
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
package com.example.womansafe.data.model
|
||||||
|
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
// Request body wrapper for API Gateway proxy endpoints
|
||||||
|
data class ApiRequestBody(
|
||||||
|
val user_create: UserCreate? = null,
|
||||||
|
val user_login: UserLogin? = null,
|
||||||
|
val user_update: UserUpdate? = null,
|
||||||
|
val emergency_contact_create: EmergencyContactCreate? = null,
|
||||||
|
val emergency_contact_update: EmergencyContactUpdate? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auth models
|
||||||
|
data class UserLogin(
|
||||||
|
val email: String? = null,
|
||||||
|
val username: String? = null,
|
||||||
|
val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Token(
|
||||||
|
@SerializedName("access_token")
|
||||||
|
val accessToken: String,
|
||||||
|
@SerializedName("token_type")
|
||||||
|
val tokenType: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// User models
|
||||||
|
data class UserCreate(
|
||||||
|
val email: String,
|
||||||
|
val username: String? = null,
|
||||||
|
val phone: String? = null,
|
||||||
|
val phone_number: String? = null,
|
||||||
|
val first_name: String? = "",
|
||||||
|
val last_name: String? = "",
|
||||||
|
val full_name: String? = null,
|
||||||
|
val date_of_birth: String? = null,
|
||||||
|
val bio: String? = null,
|
||||||
|
val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserUpdate(
|
||||||
|
val first_name: String? = null,
|
||||||
|
val last_name: String? = null,
|
||||||
|
val phone: String? = null,
|
||||||
|
val date_of_birth: String? = null,
|
||||||
|
val bio: String? = null,
|
||||||
|
val avatar_url: String? = null,
|
||||||
|
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 location_sharing_enabled: Boolean? = null,
|
||||||
|
val emergency_notifications_enabled: Boolean? = null,
|
||||||
|
val push_notifications_enabled: Boolean? = null,
|
||||||
|
val email_notifications_enabled: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val email: String,
|
||||||
|
val username: String? = null,
|
||||||
|
val phone: String? = null,
|
||||||
|
val phone_number: String? = null,
|
||||||
|
val first_name: String? = "",
|
||||||
|
val last_name: String? = "",
|
||||||
|
val full_name: String? = null,
|
||||||
|
val date_of_birth: String? = null,
|
||||||
|
val bio: String? = null,
|
||||||
|
val avatar_url: String? = null,
|
||||||
|
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 location_sharing_enabled: Boolean,
|
||||||
|
val emergency_notifications_enabled: Boolean,
|
||||||
|
val push_notifications_enabled: Boolean,
|
||||||
|
val email_notifications_enabled: Boolean? = false,
|
||||||
|
val email_verified: Boolean,
|
||||||
|
val phone_verified: Boolean,
|
||||||
|
val is_active: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emergency Contact models
|
||||||
|
data class EmergencyContactCreate(
|
||||||
|
val name: String,
|
||||||
|
val phone_number: String,
|
||||||
|
val relationship: String? = null,
|
||||||
|
val notes: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyContactUpdate(
|
||||||
|
val name: String? = null,
|
||||||
|
val phone_number: String? = null,
|
||||||
|
val relationship: String? = null,
|
||||||
|
val notes: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyContactResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val name: String,
|
||||||
|
val phone_number: String,
|
||||||
|
val relationship: String? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val user_id: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request body for different endpoints
|
||||||
|
data class RequestBody(
|
||||||
|
val user_create: UserCreate? = null,
|
||||||
|
val user_login: UserLogin? = null,
|
||||||
|
val user_update: UserUpdate? = null,
|
||||||
|
val emergency_contact_create: EmergencyContactCreate? = null,
|
||||||
|
val emergency_contact_update: EmergencyContactUpdate? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Password change model
|
||||||
|
data class ChangePasswordRequest(
|
||||||
|
val current_password: String,
|
||||||
|
val new_password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dashboard and other response models
|
||||||
|
data class DashboardResponse(
|
||||||
|
val user: UserResponse? = null,
|
||||||
|
val emergency_contacts: List<EmergencyContactResponse>? = null,
|
||||||
|
val recent_activities: List<ActivityResponse>? = null,
|
||||||
|
val safety_status: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ActivityResponse(
|
||||||
|
val id: Int,
|
||||||
|
val type: String,
|
||||||
|
val description: String,
|
||||||
|
val timestamp: String,
|
||||||
|
val location: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Health check models
|
||||||
|
data class HealthResponse(
|
||||||
|
val status: String,
|
||||||
|
val timestamp: String,
|
||||||
|
val version: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ServicesStatusResponse(
|
||||||
|
val database: String,
|
||||||
|
val redis: String? = null,
|
||||||
|
val api: String,
|
||||||
|
val timestamp: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emergency models
|
||||||
|
data class EmergencyReportCreate(
|
||||||
|
val type: String,
|
||||||
|
val description: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyReportUpdate(
|
||||||
|
val description: String? = null,
|
||||||
|
val status: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyReportResponse(
|
||||||
|
val id: Int,
|
||||||
|
val type: String,
|
||||||
|
val description: String,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String? = null,
|
||||||
|
val timestamp: String,
|
||||||
|
val status: String,
|
||||||
|
val user_id: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyAlertCreate(
|
||||||
|
val type: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String? = null,
|
||||||
|
val is_anonymous: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmergencyAlertResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val user_id: Int,
|
||||||
|
val type: String,
|
||||||
|
val description: String?,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val is_anonymous: Boolean,
|
||||||
|
val status: String,
|
||||||
|
val created_at: String,
|
||||||
|
val updated_at: String?,
|
||||||
|
val is_active: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
// Location models
|
||||||
|
data class LocationUpdate(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val accuracy: Double? = null,
|
||||||
|
val address: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LocationResponse(
|
||||||
|
val id: Int,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val timestamp: String,
|
||||||
|
val accuracy: Double? = null,
|
||||||
|
val address: String? = null,
|
||||||
|
val user_id: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SafePlace(
|
||||||
|
val name: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val category: String,
|
||||||
|
val phone_number: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SafePlaceResponse(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val category: String,
|
||||||
|
val phone_number: String? = null,
|
||||||
|
val user_id: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NearbyUser(
|
||||||
|
val id: Int,
|
||||||
|
val username: String? = null,
|
||||||
|
val distance: Double,
|
||||||
|
val last_seen: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calendar models
|
||||||
|
data class LegacyCalendarEntryResponse( // Переименовано, чтобы избежать конфликта с CalendarModels.kt
|
||||||
|
val id: Int,
|
||||||
|
val title: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val start_date: String,
|
||||||
|
val end_date: String? = null,
|
||||||
|
val entry_type: String,
|
||||||
|
val mood: String? = null,
|
||||||
|
val symptoms: List<String>? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val user_id: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CalendarSettings(
|
||||||
|
val cycle_length: Int,
|
||||||
|
val period_length: Int,
|
||||||
|
val notifications_enabled: Boolean,
|
||||||
|
val reminder_days_before: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CalendarReminder(
|
||||||
|
val title: String,
|
||||||
|
val message: String,
|
||||||
|
val reminder_date: String,
|
||||||
|
val reminder_time: String,
|
||||||
|
val is_recurring: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CalendarInsights(
|
||||||
|
val average_cycle_length: Double,
|
||||||
|
val cycle_regularity: String,
|
||||||
|
val mood_patterns: Map<String, Int>,
|
||||||
|
val symptom_frequency: Map<String, Int>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CycleOverview(
|
||||||
|
val current_phase: String,
|
||||||
|
val next_period_date: String,
|
||||||
|
val cycle_day: Int,
|
||||||
|
val fertile_window: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notification models
|
||||||
|
data class NotificationDevice(
|
||||||
|
val device_token: String,
|
||||||
|
val device_type: String,
|
||||||
|
val is_active: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NotificationPreferences(
|
||||||
|
val push_notifications_enabled: Boolean,
|
||||||
|
val email_notifications_enabled: Boolean,
|
||||||
|
val sms_notifications_enabled: Boolean,
|
||||||
|
val emergency_notifications_enabled: Boolean,
|
||||||
|
val calendar_reminders_enabled: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NotificationHistory(
|
||||||
|
val id: Int,
|
||||||
|
val title: String,
|
||||||
|
val message: String,
|
||||||
|
val type: String,
|
||||||
|
val sent_at: String,
|
||||||
|
val is_read: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TestNotification(
|
||||||
|
val title: String,
|
||||||
|
val message: String,
|
||||||
|
val type: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generic response models
|
||||||
|
data class MessageResponse(
|
||||||
|
val message: String,
|
||||||
|
val status: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmailAvailabilityResponse(
|
||||||
|
val available: Boolean,
|
||||||
|
val message: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TestUserData(
|
||||||
|
val username: String,
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
val full_name: String,
|
||||||
|
val phone_number: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validation error models
|
||||||
|
data class ValidationError(
|
||||||
|
val loc: List<Any>,
|
||||||
|
val msg: String,
|
||||||
|
val type: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HTTPValidationError(
|
||||||
|
val detail: List<ValidationError>
|
||||||
|
)
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package com.example.womansafe.data.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
// Типы событий в календаре
|
||||||
|
enum class CalendarEventType {
|
||||||
|
MENSTRUATION, // Месячные
|
||||||
|
OVULATION, // Овуляция
|
||||||
|
FERTILE_WINDOW, // Окно фертильности
|
||||||
|
PREDICTED_MENSTRUATION, // Прогноз месячных
|
||||||
|
PREDICTED_OVULATION // Прогноз овуляции
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настроения
|
||||||
|
enum class MoodType {
|
||||||
|
EXCELLENT, // Отлично
|
||||||
|
GOOD, // Хорошо
|
||||||
|
NORMAL, // Нормально
|
||||||
|
BAD, // Плохо
|
||||||
|
TERRIBLE // Ужасно
|
||||||
|
}
|
||||||
|
|
||||||
|
// Симптомы
|
||||||
|
enum class SymptomType {
|
||||||
|
CRAMPS, // Спазмы
|
||||||
|
HEADACHE, // Головная боль
|
||||||
|
BLOATING, // Вздутие
|
||||||
|
BREAST_TENDERNESS, // Болезненность груди
|
||||||
|
MOOD_SWINGS, // Перепады настроения
|
||||||
|
FATIGUE, // Усталость
|
||||||
|
ACNE, // Акне
|
||||||
|
CRAVINGS, // Тяга к еде
|
||||||
|
BACK_PAIN, // Боль в спине
|
||||||
|
NAUSEA // Тошнота
|
||||||
|
}
|
||||||
|
|
||||||
|
// Событие календаря (доменная модель)
|
||||||
|
data class CalendarEvent(
|
||||||
|
val id: String? = null,
|
||||||
|
val date: LocalDate,
|
||||||
|
val type: CalendarEventType,
|
||||||
|
val isActual: Boolean = true, // true - фактическое, false - прогноз
|
||||||
|
val mood: MoodType? = null,
|
||||||
|
val symptoms: List<SymptomType> = emptyList(),
|
||||||
|
val notes: String = "",
|
||||||
|
val flowIntensity: Int? = null, // Интенсивность выделений 1-5
|
||||||
|
val createdAt: LocalDate = LocalDate.now(),
|
||||||
|
val updatedAt: LocalDate = LocalDate.now(),
|
||||||
|
val isPredicted: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
// API модели для обмена с сервером
|
||||||
|
data class CalendarEventApiResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val entry_date: String,
|
||||||
|
val entry_type: String,
|
||||||
|
val flow_intensity: String? = null,
|
||||||
|
val mood: String? = null,
|
||||||
|
val symptoms: String = "",
|
||||||
|
val notes: String? = null,
|
||||||
|
val is_predicted: Boolean = false,
|
||||||
|
val created_at: String? = null,
|
||||||
|
val updated_at: String? = null,
|
||||||
|
val is_active: Boolean = true,
|
||||||
|
val user_id: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// Настройки цикла
|
||||||
|
data class CycleSettings(
|
||||||
|
val averageCycleLength: Int = 28,
|
||||||
|
val averagePeriodLength: Int = 5,
|
||||||
|
val lastPeriodStart: LocalDate? = null,
|
||||||
|
val irregularCycles: Boolean = false,
|
||||||
|
val trackSymptoms: Boolean = true,
|
||||||
|
val trackMood: Boolean = true,
|
||||||
|
val showPredictions: Boolean = true,
|
||||||
|
val reminderDaysBefore: Int = 3 // За сколько дней напоминать о приближающемся цикле
|
||||||
|
)
|
||||||
|
|
||||||
|
// Модели для API
|
||||||
|
|
||||||
|
// Запрос на создание события в календаре
|
||||||
|
data class CalendarEntryCreate(
|
||||||
|
val date: String,
|
||||||
|
val type: String,
|
||||||
|
val mood: String? = null,
|
||||||
|
val symptoms: List<String> = emptyList(),
|
||||||
|
val notes: String? = null,
|
||||||
|
val flow_intensity: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Запрос на обновление события в календаре
|
||||||
|
data class CalendarEntryUpdate(
|
||||||
|
val date: String? = null,
|
||||||
|
val type: String? = null,
|
||||||
|
val mood: String? = null,
|
||||||
|
val symptoms: List<String>? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val flow_intensity: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// API ответ для отдельной записи календаря
|
||||||
|
data class CalendarEntryResponse(
|
||||||
|
val id: String,
|
||||||
|
val date: String,
|
||||||
|
val type: String,
|
||||||
|
val mood: String? = null,
|
||||||
|
val symptoms: List<String> = emptyList(),
|
||||||
|
val notes: String? = null,
|
||||||
|
val flow_intensity: Int? = null,
|
||||||
|
val is_predicted: Boolean = false,
|
||||||
|
val created_at: String? = null,
|
||||||
|
val updated_at: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// API ответ для информации о цикле
|
||||||
|
data class CycleInfoResponse(
|
||||||
|
val average_cycle_length: Int,
|
||||||
|
val average_period_length: Int,
|
||||||
|
val last_period_start: String?,
|
||||||
|
val next_period_predicted: String?,
|
||||||
|
val next_ovulation_predicted: String?,
|
||||||
|
val fertile_window_start: String?,
|
||||||
|
val fertile_window_end: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
// Полный API ответ для записей календаря
|
||||||
|
data class CalendarEntriesResponse(
|
||||||
|
val entries: List<CalendarEntryResponse>,
|
||||||
|
val cycle_info: CycleInfoResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extension функции для преобразования API моделей в доменные
|
||||||
|
fun CalendarEventApiResponse.toDomainModel(): CalendarEvent {
|
||||||
|
println("=== Преобразование API модели в доменную ===")
|
||||||
|
println("API данные: entry_date=${this.entry_date}, entry_type=${this.entry_type}, symptoms=${this.symptoms}")
|
||||||
|
|
||||||
|
val calendarEvent = CalendarEvent(
|
||||||
|
id = this.uuid,
|
||||||
|
date = LocalDate.parse(this.entry_date),
|
||||||
|
type = mapApiTypeToCalendarEventType(this.entry_type),
|
||||||
|
isActual = !this.is_predicted,
|
||||||
|
mood = this.mood?.let { mapApiMoodToMoodType(it) },
|
||||||
|
symptoms = parseApiSymptoms(this.symptoms),
|
||||||
|
notes = this.notes ?: "",
|
||||||
|
flowIntensity = mapApiFlowIntensityToInt(this.flow_intensity),
|
||||||
|
isPredicted = this.is_predicted,
|
||||||
|
createdAt = this.created_at?.let {
|
||||||
|
try {
|
||||||
|
LocalDate.parse(it.substring(0, 10))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LocalDate.now()
|
||||||
|
}
|
||||||
|
} ?: LocalDate.now(),
|
||||||
|
updatedAt = this.updated_at?.let {
|
||||||
|
try {
|
||||||
|
LocalDate.parse(it.substring(0, 10))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LocalDate.now()
|
||||||
|
}
|
||||||
|
} ?: LocalDate.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
println("Доменная модель: id=${calendarEvent.id}, date=${calendarEvent.date}, type=${calendarEvent.type}")
|
||||||
|
println("=============================================")
|
||||||
|
|
||||||
|
return calendarEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательные функции для маппинга
|
||||||
|
private fun mapApiTypeToCalendarEventType(apiType: String): CalendarEventType {
|
||||||
|
return when (apiType.lowercase()) {
|
||||||
|
"period" -> CalendarEventType.MENSTRUATION
|
||||||
|
"ovulation" -> CalendarEventType.OVULATION
|
||||||
|
"fertile_window" -> CalendarEventType.FERTILE_WINDOW
|
||||||
|
"predicted_period" -> CalendarEventType.PREDICTED_MENSTRUATION
|
||||||
|
"predicted_ovulation" -> CalendarEventType.PREDICTED_OVULATION
|
||||||
|
else -> CalendarEventType.MENSTRUATION // по умолчанию
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapApiMoodToMoodType(apiMood: String): MoodType {
|
||||||
|
return when (apiMood.uppercase()) {
|
||||||
|
"EXCELLENT" -> MoodType.EXCELLENT
|
||||||
|
"GOOD" -> MoodType.GOOD
|
||||||
|
"NORMAL" -> MoodType.NORMAL
|
||||||
|
"BAD" -> MoodType.BAD
|
||||||
|
"TERRIBLE" -> MoodType.TERRIBLE
|
||||||
|
else -> MoodType.NORMAL // по умолчанию
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseApiSymptoms(symptomsString: String): List<SymptomType> {
|
||||||
|
if (symptomsString.isBlank()) return emptyList()
|
||||||
|
|
||||||
|
return symptomsString.split(",")
|
||||||
|
.map { it.trim() }
|
||||||
|
.mapNotNull { symptom ->
|
||||||
|
try {
|
||||||
|
SymptomType.valueOf(symptom.uppercase())
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
null // Игнорируем неизвестные симптомы
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapApiFlowIntensityToInt(apiIntensity: String?): Int? {
|
||||||
|
return when (apiIntensity?.lowercase()) {
|
||||||
|
"light" -> 2
|
||||||
|
"medium" -> 3
|
||||||
|
"heavy" -> 5
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.example.womansafe.data.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.example.womansafe.data.model.calendar.EntryType
|
||||||
|
import com.example.womansafe.data.model.calendar.FlowIntensity
|
||||||
|
import com.example.womansafe.data.model.calendar.Symptom
|
||||||
|
import com.example.womansafe.data.model.calendar.Mood
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель данных цикла
|
||||||
|
*/
|
||||||
|
data class CycleData(
|
||||||
|
val user_id: Int,
|
||||||
|
val cycle_start_date: LocalDate,
|
||||||
|
val cycle_length: Int,
|
||||||
|
val period_length: Int,
|
||||||
|
val ovulation_date: LocalDate,
|
||||||
|
val fertile_window_start: LocalDate,
|
||||||
|
val fertile_window_end: LocalDate,
|
||||||
|
val next_period_predicted: LocalDate,
|
||||||
|
val cycle_regularity_score: Int, // 1-100
|
||||||
|
val avg_cycle_length: Float,
|
||||||
|
val avg_period_length: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для аналитики здоровья и инсайтов
|
||||||
|
*/
|
||||||
|
data class HealthInsight(
|
||||||
|
val id: Int,
|
||||||
|
val user_id: Int,
|
||||||
|
val insight_type: InsightType,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val recommendation: String,
|
||||||
|
val confidence_level: ConfidenceLevel,
|
||||||
|
val data_points_used: Int,
|
||||||
|
val is_dismissed: Boolean = false,
|
||||||
|
val created_at: LocalDateTime? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типы записей в календаре
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интенсивность менструации
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Настроение
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Симптомы
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типы инсайтов
|
||||||
|
*/
|
||||||
|
enum class InsightType {
|
||||||
|
CYCLE_IRREGULARITY,
|
||||||
|
PERIOD_LENGTH_CHANGE,
|
||||||
|
SYMPTOM_PATTERN,
|
||||||
|
HEALTH_RECOMMENDATION,
|
||||||
|
OVULATION_PREDICTION,
|
||||||
|
LIFESTYLE_IMPACT
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уровни достоверности инсайтов
|
||||||
|
*/
|
||||||
|
enum class ConfidenceLevel {
|
||||||
|
LOW,
|
||||||
|
MEDIUM,
|
||||||
|
HIGH
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для запроса на создание записи в календаре
|
||||||
|
*/
|
||||||
|
data class CalendarEntryRequest(
|
||||||
|
val entry_date: String,
|
||||||
|
val entry_type: String,
|
||||||
|
val flow_intensity: String? = null,
|
||||||
|
val period_symptoms: List<String>? = null,
|
||||||
|
val mood: String? = null,
|
||||||
|
val energy_level: Int? = null,
|
||||||
|
val sleep_hours: Float? = null,
|
||||||
|
val symptoms: List<String>? = null,
|
||||||
|
val medications: List<String>? = null,
|
||||||
|
val notes: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для статистики цикла
|
||||||
|
*/
|
||||||
|
data class CycleStatistics(
|
||||||
|
val average_cycle_length: Float,
|
||||||
|
val cycle_length_variation: Float,
|
||||||
|
val average_period_length: Float,
|
||||||
|
val cycle_regularity: Int,
|
||||||
|
val cycle_history: List<CycleHistoryItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Элемент истории циклов для отображения графика
|
||||||
|
*/
|
||||||
|
data class CycleHistoryItem(
|
||||||
|
val start_date: LocalDate,
|
||||||
|
val end_date: LocalDate,
|
||||||
|
val cycle_length: Int,
|
||||||
|
val period_length: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для прогноза цикла
|
||||||
|
*/
|
||||||
|
data class CyclePrediction(
|
||||||
|
val next_period_start: LocalDate,
|
||||||
|
val next_period_end: LocalDate,
|
||||||
|
val next_ovulation: LocalDate,
|
||||||
|
val fertile_window_start: LocalDate,
|
||||||
|
val fertile_window_end: LocalDate,
|
||||||
|
val confidence: Float = 0.85f
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.example.womansafe.data.model
|
||||||
|
|
||||||
|
// Этот файл оставлен пустым, так как все модели экстренных контактов
|
||||||
|
// теперь находятся в ApiModels.kt для избежания дублирования
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.example.womansafe.data.model
|
||||||
|
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
// Типы экстренных событий
|
||||||
|
enum class EmergencyType {
|
||||||
|
HARASSMENT, // Домогательства
|
||||||
|
ASSAULT, // Нападение
|
||||||
|
STALKING, // Преследование
|
||||||
|
DOMESTIC_VIOLENCE, // Домашнее насилие
|
||||||
|
UNSAFE_AREA, // Небезопасная зона
|
||||||
|
MEDICAL, // Медицинская помощь
|
||||||
|
OTHER // Другое
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статус экстренного события
|
||||||
|
enum class EmergencyStatus {
|
||||||
|
ACTIVE, // Активное
|
||||||
|
RESOLVED, // Решено
|
||||||
|
FALSE_ALARM // Ложная тревога
|
||||||
|
}
|
||||||
|
|
||||||
|
// Локальная модель экстренного события
|
||||||
|
data class EmergencyAlert(
|
||||||
|
val id: Int? = null,
|
||||||
|
val uuid: String? = null,
|
||||||
|
val type: EmergencyType,
|
||||||
|
val description: String? = null,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String? = null,
|
||||||
|
val isAnonymous: Boolean = false,
|
||||||
|
val status: EmergencyStatus = EmergencyStatus.ACTIVE,
|
||||||
|
val createdAt: Date? = null,
|
||||||
|
val updatedAt: Date? = null,
|
||||||
|
val isActive: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Местоположение пользователя
|
||||||
|
data class UserLocation(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val accuracy: Float? = null,
|
||||||
|
val address: String? = null,
|
||||||
|
val timestamp: Date = Date()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emergency models for UI layer
|
||||||
|
|
||||||
|
// Модель экстренного события для отображения в списке
|
||||||
|
data class EmergencyAlertItem(
|
||||||
|
val id: Int,
|
||||||
|
val type: EmergencyType,
|
||||||
|
val description: String?,
|
||||||
|
val address: String?,
|
||||||
|
val status: EmergencyStatus,
|
||||||
|
val createdAt: Date
|
||||||
|
)
|
||||||
|
|
||||||
|
// Модель экстренного события для детального просмотра
|
||||||
|
data class EmergencyAlertDetail(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String,
|
||||||
|
val type: EmergencyType,
|
||||||
|
val description: String?,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val address: String?,
|
||||||
|
val isAnonymous: Boolean,
|
||||||
|
val status: EmergencyStatus,
|
||||||
|
val createdAt: Date,
|
||||||
|
val updatedAt: Date,
|
||||||
|
val isActive: Boolean
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
data class UserResponse(
|
||||||
|
val id: Int,
|
||||||
|
val uuid: String?,
|
||||||
|
val username: String,
|
||||||
|
val email: String,
|
||||||
|
val phone: String?,
|
||||||
|
val firstName: String?,
|
||||||
|
val lastName: String?,
|
||||||
|
val dateOfBirth: String?,
|
||||||
|
val avatarUrl: String?,
|
||||||
|
val bio: String?,
|
||||||
|
val emergencyContact1Name: String?,
|
||||||
|
val emergencyContact1Phone: String?,
|
||||||
|
val emergencyContact2Name: String?,
|
||||||
|
val emergencyContact2Phone: String?,
|
||||||
|
val locationSharingEnabled: Boolean?,
|
||||||
|
val emergencyNotificationsEnabled: Boolean?,
|
||||||
|
val pushNotificationsEnabled: Boolean?,
|
||||||
|
val emailNotificationsEnabled: Boolean?,
|
||||||
|
val emailVerified: Boolean?,
|
||||||
|
val phoneVerified: Boolean?,
|
||||||
|
val isBlocked: Boolean?,
|
||||||
|
val isActive: Boolean?,
|
||||||
|
val createdAt: String?,
|
||||||
|
val updatedAt: String?
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Entity(tableName = "calendar_entries")
|
||||||
|
data class CalendarEntry(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
val userId: String,
|
||||||
|
val entryDate: LocalDate,
|
||||||
|
val entryType: EntryType,
|
||||||
|
val flowIntensity: FlowIntensity? = null,
|
||||||
|
val periodSymptoms: List<Symptom>? = null,
|
||||||
|
val mood: Mood? = null,
|
||||||
|
val energyLevel: Int? = null, // 1-5
|
||||||
|
val sleepHours: Float? = null,
|
||||||
|
val symptoms: List<Symptom>? = null,
|
||||||
|
val medications: List<String>? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val isPredicted: Boolean = false,
|
||||||
|
val confidenceScore: Int? = null, // 1-100
|
||||||
|
val syncTimestamp: Long = System.currentTimeMillis(),
|
||||||
|
val entryId: Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запрос на создание/обновление записи календаря
|
||||||
|
*/
|
||||||
|
data class CalendarEntryRequest(
|
||||||
|
val entry_date: String,
|
||||||
|
val entry_type: String,
|
||||||
|
val flow_intensity: String? = null,
|
||||||
|
val mood: String? = null,
|
||||||
|
val symptoms: List<String>? = null,
|
||||||
|
val period_symptoms: List<String>? = null,
|
||||||
|
val medications: List<String>? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val energy_level: Int? = null,
|
||||||
|
val sleep_hours: Float? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель события календаря, используемая в улучшенном календарном интерфейсе
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "calendar_events")
|
||||||
|
data class CalendarEvent(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String,
|
||||||
|
val userId: String,
|
||||||
|
val date: LocalDate,
|
||||||
|
val type: String, // period, ovulation, symptoms, medication, note, appointment
|
||||||
|
val flowIntensity: String? = null,
|
||||||
|
val mood: String? = null,
|
||||||
|
val energyLevel: Int? = null,
|
||||||
|
val sleepHours: Float? = null,
|
||||||
|
val symptoms: List<String>? = null,
|
||||||
|
val medications: List<String>? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val isDismissed: Boolean = false,
|
||||||
|
val syncTimestamp: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уровень уверенности в прогнозе или инсайте
|
||||||
|
*/
|
||||||
|
enum class ConfidenceLevel {
|
||||||
|
LOW, // Низкая уверенность
|
||||||
|
MEDIUM, // Средняя уверенность
|
||||||
|
HIGH // Высокая уверенность
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные о текущем менструальном цикле
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "cycle_data")
|
||||||
|
data class CycleData(
|
||||||
|
@PrimaryKey
|
||||||
|
val userId: String,
|
||||||
|
val periodStart: LocalDate?,
|
||||||
|
val periodEnd: LocalDate?,
|
||||||
|
val ovulationDate: LocalDate?,
|
||||||
|
val fertileWindowStart: LocalDate?,
|
||||||
|
val fertileWindowEnd: LocalDate?,
|
||||||
|
val cycleLength: Int = 28,
|
||||||
|
val periodLength: Int = 5,
|
||||||
|
val nextPeriodPredicted: LocalDate? = null,
|
||||||
|
val lastUpdated: Long = System.currentTimeMillis(),
|
||||||
|
// Добавленные поля для совместимости с кодом
|
||||||
|
val lastPeriodStartDate: LocalDate? = periodStart,
|
||||||
|
val averageCycleLength: Int = cycleLength,
|
||||||
|
val averagePeriodLength: Int = periodLength,
|
||||||
|
val regularityScore: Int = 0, // 0-100
|
||||||
|
val cycleStartDate: LocalDate? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель прогноза менструального цикла
|
||||||
|
*/
|
||||||
|
data class CyclePrediction(
|
||||||
|
val userId: String,
|
||||||
|
val nextPeriodStart: LocalDate,
|
||||||
|
val nextPeriodEnd: LocalDate,
|
||||||
|
val nextOvulation: LocalDate,
|
||||||
|
val fertileWindowStart: LocalDate,
|
||||||
|
val fertileWindowEnd: LocalDate,
|
||||||
|
val confidenceScore: Int, // 1-100
|
||||||
|
val createdAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель статистики менструального цикла
|
||||||
|
*/
|
||||||
|
data class CycleStatistics(
|
||||||
|
val userId: String,
|
||||||
|
val averageCycleLength: Int,
|
||||||
|
val averagePeriodLength: Int,
|
||||||
|
val shortestCycle: Int,
|
||||||
|
val longestCycle: Int,
|
||||||
|
val cycleLengthVariation: Int,
|
||||||
|
val regularityScore: Int, // 1-100
|
||||||
|
val dataPointsCount: Int,
|
||||||
|
val lastUpdated: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типы записей в календаре женского здоровья
|
||||||
|
*/
|
||||||
|
enum class EntryType {
|
||||||
|
PERIOD, // Менструация
|
||||||
|
OVULATION, // Овуляция
|
||||||
|
SYMPTOMS, // Симптомы
|
||||||
|
MEDICATION, // Лекарства
|
||||||
|
NOTE, // Заметка
|
||||||
|
APPOINTMENT // Приём у врача
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интенсивность менструального кровотечения
|
||||||
|
*/
|
||||||
|
enum class FlowIntensity {
|
||||||
|
SPOTTING, // Мажущие выделения
|
||||||
|
LIGHT, // Легкие
|
||||||
|
MEDIUM, // Средние
|
||||||
|
HEAVY, // Сильные
|
||||||
|
VERY_HEAVY // Очень сильные
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель медицинских инсайтов на основе данных календаря
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "health_insights")
|
||||||
|
data class HealthInsight(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: Long,
|
||||||
|
val userId: String,
|
||||||
|
val insightType: InsightType,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val confidenceLevel: ConfidenceLevel,
|
||||||
|
val recommendation: String,
|
||||||
|
val dataPointsUsed: Int,
|
||||||
|
val isDismissed: Boolean = false,
|
||||||
|
val createdAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типы инсайтов о женском здоровье
|
||||||
|
*/
|
||||||
|
enum class InsightType {
|
||||||
|
CYCLE_REGULARITY, // Регулярность цикла
|
||||||
|
PERIOD_LENGTH, // Продолжительность менструации
|
||||||
|
SYMPTOM_PATTERN, // Закономерности в симптомах
|
||||||
|
LIFESTYLE_IMPACT, // Влияние образа жизни
|
||||||
|
MEDICATION_EFFECTIVENESS, // Эффективность лекарств
|
||||||
|
MOOD_CORRELATION, // Корреляции настроения
|
||||||
|
PERIOD_PREDICTION, // Прогноз менструации
|
||||||
|
HEALTH_TIP, // Совет по здоровью
|
||||||
|
MEDICATION_REMINDER, // Напоминание о приеме лекарств
|
||||||
|
EXERCISE_SUGGESTION // Рекомендации по физическим упражнениям
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расширенный набор настроений для дополненного календаря
|
||||||
|
*/
|
||||||
|
enum class Mood {
|
||||||
|
VERY_HAPPY, // Очень счастливое
|
||||||
|
HAPPY, // Счастливое
|
||||||
|
NEUTRAL, // Нейтральное
|
||||||
|
NORMAL, // Обычное (для совместимости)
|
||||||
|
SAD, // Грустное
|
||||||
|
VERY_SAD, // Очень грустное
|
||||||
|
ANXIOUS, // Тревожное
|
||||||
|
IRRITATED, // Раздраженное (для совместимости)
|
||||||
|
IRRITABLE, // Раздражительное
|
||||||
|
SENSITIVE, // Чувствительное
|
||||||
|
CALM, // Спокойное
|
||||||
|
ENERGETIC, // Энергичное
|
||||||
|
TIRED // Усталое
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние кожи в менструальном календаре
|
||||||
|
*/
|
||||||
|
enum class SkinCondition {
|
||||||
|
NORMAL, // Нормальное состояние кожи
|
||||||
|
IRRITATED, // Раздраженная кожа
|
||||||
|
SENSITIVE, // Чувствительная кожа
|
||||||
|
DRY, // Сухая кожа
|
||||||
|
OILY, // Жирная кожа
|
||||||
|
ACNE, // Высыпания/акне
|
||||||
|
REDNESS // Покраснения
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расширенный набор симптомов для женского здоровья
|
||||||
|
*/
|
||||||
|
enum class Symptom {
|
||||||
|
CRAMPS, // Спазмы/боли
|
||||||
|
HEADACHE, // Головная боль
|
||||||
|
BACKACHE, // Боли в спине
|
||||||
|
NAUSEA, // Тошнота
|
||||||
|
FATIGUE, // Усталость
|
||||||
|
BLOATING, // Вздутие
|
||||||
|
BREAST_TENDERNESS, // Чувствительность груди
|
||||||
|
ACNE, // Высыпания
|
||||||
|
MOOD_SWINGS, // Перепады настроения
|
||||||
|
CRAVINGS, // Тяга к еде
|
||||||
|
INSOMNIA, // Бессонница
|
||||||
|
DIZZINESS, // Головокружение
|
||||||
|
CONSTIPATION, // Запоры
|
||||||
|
DIARRHEA, // Диарея
|
||||||
|
HOT_FLASHES, // Приливы
|
||||||
|
SPOTTING // Мажущие выделения
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package com.example.womansafe.data.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.example.womansafe.data.api.WomanSafeApi
|
||||||
|
import com.example.womansafe.util.PreferenceManager
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object NetworkClient {
|
||||||
|
private var BASE_URL = "http://192.168.0.112:8000/"
|
||||||
|
private var authToken: String? = null
|
||||||
|
private lateinit var preferenceManager: PreferenceManager
|
||||||
|
|
||||||
|
// Метод для получения экземпляра клиента Retrofit
|
||||||
|
fun getClient(): Retrofit {
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl(BASE_URL)
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метод для получения ID пользователя из токена
|
||||||
|
fun getUserId(): String? {
|
||||||
|
// Заглушка для метода - в реальном приложении здесь должна быть логика получения ID из токена
|
||||||
|
return "user123"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация клиента с контекстом приложения
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
preferenceManager = PreferenceManager.getInstance(context)
|
||||||
|
// Загружаем сохраненный токен при инициализации
|
||||||
|
authToken = preferenceManager.getAuthToken()
|
||||||
|
println("NetworkClient initialized with token: ${authToken?.take(10)}...")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val authInterceptor = Interceptor { chain ->
|
||||||
|
val requestBuilder = chain.request().newBuilder()
|
||||||
|
authToken?.let { token ->
|
||||||
|
requestBuilder.addHeader("Authorization", "Bearer $token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
val request = requestBuilder.build()
|
||||||
|
println("=== API Request Debug ===")
|
||||||
|
println("URL: ${request.url}")
|
||||||
|
println("Method: ${request.method}")
|
||||||
|
print("Headers: ")
|
||||||
|
request.headers.forEach { (name, value) ->
|
||||||
|
if (name.equals("Authorization", ignoreCase = true)) {
|
||||||
|
println("$name: ██")
|
||||||
|
} else {
|
||||||
|
println("$name: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
println("Response Code: ${response.code}")
|
||||||
|
println("Response Message: ${response.message}")
|
||||||
|
println("========================")
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
}
|
||||||
|
|
||||||
|
private val okHttpClient = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor(loggingInterceptor)
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val apiService: WomanSafeApi by lazy {
|
||||||
|
Retrofit.Builder()
|
||||||
|
.baseUrl(BASE_URL)
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
.create(WomanSafeApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAuthToken(token: String?) {
|
||||||
|
authToken = token
|
||||||
|
if (::preferenceManager.isInitialized) {
|
||||||
|
preferenceManager.saveAuthToken(token)
|
||||||
|
println("Token saved to preferences: ${token?.take(10)}...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAuthToken() {
|
||||||
|
authToken = null
|
||||||
|
if (::preferenceManager.isInitialized) {
|
||||||
|
preferenceManager.clearAuthData()
|
||||||
|
println("Token cleared from preferences")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAuthToken(): String? = authToken
|
||||||
|
|
||||||
|
fun updateBaseUrl(newUrl: String) {
|
||||||
|
BASE_URL = if (!newUrl.endsWith("/")) "$newUrl/" else newUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
private val retrofit: Retrofit = Retrofit.Builder()
|
||||||
|
.baseUrl("https://api.example.com/") // Замените на актуальный URL
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun <T> createService(serviceClass: Class<T>): T {
|
||||||
|
return retrofit.create(serviceClass)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.example.womansafe.data.network
|
||||||
|
|
||||||
|
import com.example.womansafe.data.api.WomanSafeApi
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object RetrofitClient {
|
||||||
|
private const val BASE_URL = "http://192.168.0.112:8000/"
|
||||||
|
|
||||||
|
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
}
|
||||||
|
|
||||||
|
private val okHttpClient = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(loggingInterceptor)
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val retrofit = Retrofit.Builder()
|
||||||
|
.baseUrl(BASE_URL)
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val api: WomanSafeApi = retrofit.create(WomanSafeApi::class.java)
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package com.example.womansafe.data.repository
|
||||||
|
|
||||||
|
import com.example.womansafe.data.model.*
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.example.womansafe.data.network.NetworkClient
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
class ApiRepository {
|
||||||
|
private val apiService = NetworkClient.apiService
|
||||||
|
|
||||||
|
// Auth methods
|
||||||
|
suspend fun login(email: String?, username: String?, password: String): Response<Token> {
|
||||||
|
val request = UserLogin(email, username, password)
|
||||||
|
return apiService.login(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun register(
|
||||||
|
email: String,
|
||||||
|
username: String? = null,
|
||||||
|
password: String,
|
||||||
|
fullName: String? = null,
|
||||||
|
phoneNumber: String? = null,
|
||||||
|
firstName: String? = null,
|
||||||
|
lastName: String? = null,
|
||||||
|
dateOfBirth: String? = null,
|
||||||
|
bio: String? = null
|
||||||
|
): Response<UserResponse> {
|
||||||
|
val request = UserCreate(
|
||||||
|
email = email,
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
full_name = fullName,
|
||||||
|
phone_number = phoneNumber,
|
||||||
|
first_name = firstName,
|
||||||
|
last_name = lastName,
|
||||||
|
date_of_birth = dateOfBirth,
|
||||||
|
bio = bio
|
||||||
|
)
|
||||||
|
return apiService.register(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User methods
|
||||||
|
suspend fun getCurrentUser(): Response<UserResponse> {
|
||||||
|
return apiService.getCurrentUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateCurrentUser(userUpdate: UserUpdate): Response<UserResponse> {
|
||||||
|
val body = ApiRequestBody(user_update = userUpdate)
|
||||||
|
return apiService.updateCurrentUser(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun patchCurrentUser(userUpdate: UserUpdate): Response<UserResponse> {
|
||||||
|
val body = ApiRequestBody(user_update = userUpdate)
|
||||||
|
return apiService.patchCurrentUser(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun changePassword(currentPassword: String, newPassword: String): Response<Unit> {
|
||||||
|
val passwordRequest = ChangePasswordRequest(currentPassword, newPassword)
|
||||||
|
// Поскольку WomanSafeApi ожидает ApiRequestBody, нам нужно обернуть запрос
|
||||||
|
val body = ApiRequestBody() // Здесь может потребоваться дополнительное поле для смены пароля
|
||||||
|
return apiService.changePassword(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getDashboard(): Response<Any> {
|
||||||
|
return apiService.getDashboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getUserProfile(): Response<UserResponse> {
|
||||||
|
return apiService.getProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateUserProfile(userUpdate: UserUpdate): Response<UserResponse> {
|
||||||
|
val body = ApiRequestBody(user_update = userUpdate)
|
||||||
|
return apiService.updateProfile(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emergency Contact methods
|
||||||
|
suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>> {
|
||||||
|
return apiService.getEmergencyContacts()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createEmergencyContact(contact: EmergencyContactCreate): Response<EmergencyContactResponse> {
|
||||||
|
return apiService.createEmergencyContact(contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getEmergencyContact(contactId: Int): Response<EmergencyContactResponse> {
|
||||||
|
return apiService.getEmergencyContact(contactId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateEmergencyContact(contactId: Int, contact: EmergencyContactUpdate): Response<EmergencyContactResponse> {
|
||||||
|
val body = ApiRequestBody(emergency_contact_update = contact)
|
||||||
|
return apiService.updateEmergencyContact(contactId.toString(), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteEmergencyContact(contactId: Int): Response<Unit> {
|
||||||
|
return apiService.deleteEmergencyContact(contactId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emergency methods - возвращают Any согласно WomanSafeApi
|
||||||
|
suspend fun getEmergencyReports(): Response<Any> {
|
||||||
|
return apiService.getEmergencyReports()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createEmergencyReport(): Response<Any> {
|
||||||
|
return apiService.createEmergencyReport()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getNearbyEmergencyReports(): Response<Any> {
|
||||||
|
return apiService.getNearbyEmergencyReports()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getEmergencyReport(reportId: Int): Response<Any> {
|
||||||
|
return apiService.getEmergencyReport(reportId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateEmergencyReport(reportId: Int): Response<Any> {
|
||||||
|
return apiService.updateEmergencyReport(reportId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteEmergencyReport(reportId: Int): Response<Any> {
|
||||||
|
return apiService.deleteEmergencyReport(reportId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun getEmergencyAlerts(): Response<List<EmergencyAlertResponse>> {
|
||||||
|
return apiService.getEmergencyAlerts()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createEmergencyAlert(request: EmergencyAlertCreate): Response<EmergencyAlertResponse> {
|
||||||
|
return apiService.createEmergencyAlert(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getMyEmergencyAlerts(): Response<List<EmergencyAlertResponse>> {
|
||||||
|
return apiService.getMyEmergencyAlerts()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getNearbyEmergencyAlerts(): Response<Any> {
|
||||||
|
return apiService.getNearbyEmergencyAlerts()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getEmergencyAlert(alertId: Int): Response<Any> {
|
||||||
|
return apiService.getEmergencyAlert(alertId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateEmergencyAlert(alertId: Int): Response<Any> {
|
||||||
|
return apiService.updateEmergencyAlert(alertId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun cancelEmergencyAlert(alertId: Int): Response<Any> {
|
||||||
|
return apiService.cancelEmergencyAlert(alertId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteEmergencyAlert(alertId: Int): Response<Any> {
|
||||||
|
return apiService.deleteEmergencyAlert(alertId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location methods
|
||||||
|
suspend fun updateLocation(): Response<Any> {
|
||||||
|
return apiService.updateLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLastLocation(): Response<Any> {
|
||||||
|
return apiService.getLastLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLocationHistory(): Response<Any> {
|
||||||
|
return apiService.getLocationHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getNearbyUsers(): Response<Any> {
|
||||||
|
return apiService.getNearbyUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getSafePlaces(): Response<Any> {
|
||||||
|
return apiService.getSafePlaces()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createSafePlace(): Response<Any> {
|
||||||
|
return apiService.createSafePlace()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getSafePlace(placeId: Int): Response<Any> {
|
||||||
|
return apiService.getSafePlace(placeId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateSafePlace(placeId: Int): Response<Any> {
|
||||||
|
return apiService.updateSafePlace(placeId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteSafePlace(placeId: Int): Response<Any> {
|
||||||
|
return apiService.deleteSafePlace(placeId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar methods
|
||||||
|
suspend fun getCalendarEntries(
|
||||||
|
startDate: String? = null,
|
||||||
|
endDate: String? = null,
|
||||||
|
entryType: String? = null,
|
||||||
|
limit: Int? = null
|
||||||
|
): Response<List<CalendarEntry>> {
|
||||||
|
// В WomanSafeApi нет метода getCalendarEntries, нужно использовать другой API
|
||||||
|
// Здесь должна быть интеграция с CalendarApi
|
||||||
|
throw NotImplementedError("Method getCalendarEntries not implemented in WomanSafeApi")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createCalendarEntry(entry: CalendarEntry): Response<CalendarEntry> {
|
||||||
|
// В WomanSafeApi нет метода createCalendarEntry, нужно использовать другой API
|
||||||
|
// Здесь должна быть интеграция с CalendarApi
|
||||||
|
throw NotImplementedError("Method createCalendarEntry not implemented in WomanSafeApi")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateCalendarEntry(id: String, entry: CalendarEntry): Response<CalendarEntry> {
|
||||||
|
// В WomanSafeApi нет метода updateCalendarEntry, нужно использовать другой API
|
||||||
|
// Здесь должна быть интеграция с CalendarApi
|
||||||
|
throw NotImplementedError("Method updateCalendarEntry not implemented in WomanSafeApi")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteCalendarEntry(id: String): Response<Unit> {
|
||||||
|
// В WomanSafeApi нет метода deleteCalendarEntry, нужно использовать другой API
|
||||||
|
// Здесь должна быть интеграция с CalendarApi
|
||||||
|
throw NotImplementedError("Method deleteCalendarEntry not implemented in WomanSafeApi")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCycleOverview(): Response<Any> {
|
||||||
|
return apiService.getHealth() // Временная заглушка
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCalendarInsights(): Response<Any> {
|
||||||
|
return apiService.getHealth() // Временная заглушка
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCalendarReminders(): Response<Any> {
|
||||||
|
return apiService.getHealth() // Временная заглушка
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createCalendarReminder(): Response<Any> {
|
||||||
|
return apiService.getHealth() // Временная заглушка
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCalendarSettings(): Response<Any> {
|
||||||
|
return apiService.getHealth() // Временная заглушка
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateCalendarSettings(): Response<Any> {
|
||||||
|
return apiService.getHealth() // Временная заглушка
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification methods
|
||||||
|
suspend fun getNotificationDevices(): Response<Any> {
|
||||||
|
return apiService.getNotificationDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun registerNotificationDevice(): Response<Any> {
|
||||||
|
return apiService.createNotificationDevice()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getNotificationDevice(deviceId: String): Response<Any> {
|
||||||
|
return apiService.getNotificationDevice(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun unregisterNotificationDevice(deviceId: String): Response<Any> {
|
||||||
|
return apiService.deleteNotificationDevice(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getNotificationPreferences(): Response<Any> {
|
||||||
|
return apiService.getNotificationPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateNotificationPreferences(): Response<Any> {
|
||||||
|
return apiService.updateNotificationPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendTestNotification(): Response<Any> {
|
||||||
|
return apiService.testNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getNotificationHistory(): Response<Any> {
|
||||||
|
return apiService.getNotificationHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health and status methods
|
||||||
|
suspend fun getHealth(): Response<Any> {
|
||||||
|
return apiService.getHealth()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getServicesStatus(): Response<Any> {
|
||||||
|
return apiService.getServicesStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getRoot(): Response<Any> {
|
||||||
|
return apiService.getRoot()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package com.example.womansafe.data.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.womansafe.data.api.CalendarApi
|
||||||
|
import com.example.womansafe.data.local.CalendarDao
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleData
|
||||||
|
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||||
|
import retrofit2.Response
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleStatistics
|
||||||
|
import com.example.womansafe.data.model.calendar.CyclePrediction
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий для работы с данными менструального календаря
|
||||||
|
*/
|
||||||
|
class CalendarRepository(
|
||||||
|
private val calendarDao: CalendarDao,
|
||||||
|
private val calendarApi: CalendarApi
|
||||||
|
) {
|
||||||
|
private val TAG = "CalendarRepository"
|
||||||
|
private val dateFormatter = DateTimeFormatter.ISO_LOCAL_DATE
|
||||||
|
|
||||||
|
// CycleData operations
|
||||||
|
fun getCycleDataFlow(userId: String): Flow<CycleData?> {
|
||||||
|
return calendarDao.getCycleDataFlowByUserId(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshCycleData(userId: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.getCycleData()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { cycleData ->
|
||||||
|
calendarDao.insertCycleData(cycleData)
|
||||||
|
Log.d(TAG, "Cycle data refreshed from server")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Failed to refresh cycle data: ${response.code()}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error refreshing cycle data", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateCycleData(cycleData: CycleData): Result<CycleData> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.updateCycleData(cycleData)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { updatedData ->
|
||||||
|
calendarDao.insertCycleData(updatedData)
|
||||||
|
Result.success(updatedData)
|
||||||
|
} ?: Result.failure(Exception("Empty response body"))
|
||||||
|
} else {
|
||||||
|
// Сохраняем данные локально даже при ошибке сети
|
||||||
|
calendarDao.insertCycleData(cycleData)
|
||||||
|
Result.failure(Exception("API error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// При ошибке сети сохраняем данные локально
|
||||||
|
calendarDao.insertCycleData(cycleData)
|
||||||
|
Log.e(TAG, "Error updating cycle data", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarEntry operations
|
||||||
|
fun getCalendarEntriesFlow(userId: String): Flow<List<CalendarEntry>> {
|
||||||
|
return calendarDao.getAllCalendarEntriesFlow(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalendarEntriesBetweenDatesFlow(
|
||||||
|
userId: String,
|
||||||
|
startDate: LocalDate,
|
||||||
|
endDate: LocalDate
|
||||||
|
): Flow<List<CalendarEntry>> {
|
||||||
|
return calendarDao.getCalendarEntriesBetweenDatesFlow(userId, startDate, endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshCalendarEntries(
|
||||||
|
userId: String,
|
||||||
|
startDate: LocalDate? = null,
|
||||||
|
endDate: LocalDate? = null
|
||||||
|
) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.getCalendarEntries(
|
||||||
|
startDate?.format(dateFormatter),
|
||||||
|
endDate?.format(dateFormatter)
|
||||||
|
)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { entries ->
|
||||||
|
calendarDao.insertCalendarEntries(entries)
|
||||||
|
Log.d(TAG, "Calendar entries refreshed from server: ${entries.size}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Failed to refresh calendar entries: ${response.code()}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error refreshing calendar entries", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCalendarEntryByDate(userId: String, date: LocalDate): CalendarEntry? {
|
||||||
|
return calendarDao.getCalendarEntryByDate(userId, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addCalendarEntry(entry: CalendarEntry): Result<CalendarEntry> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.addCalendarEntry(entry)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { serverEntry ->
|
||||||
|
calendarDao.insertCalendarEntry(serverEntry)
|
||||||
|
Result.success(serverEntry)
|
||||||
|
} ?: Result.failure(Exception("Empty response body"))
|
||||||
|
} else {
|
||||||
|
// Сохраняем данные локально даже при ошибке сети
|
||||||
|
val localId = calendarDao.insertCalendarEntry(entry)
|
||||||
|
Result.failure(Exception("API error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// При ошибке сети сохраняем данные локально
|
||||||
|
val localId = calendarDao.insertCalendarEntry(entry)
|
||||||
|
Log.e(TAG, "Error adding calendar entry", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateCalendarEntry(entry: CalendarEntry): Result<CalendarEntry> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.updateCalendarEntry(entry.id, entry)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { updatedEntry ->
|
||||||
|
calendarDao.updateCalendarEntry(updatedEntry)
|
||||||
|
Result.success(updatedEntry)
|
||||||
|
} ?: Result.failure(Exception("Empty response body"))
|
||||||
|
} else {
|
||||||
|
// Обновляем данные локально даже при ошибке сети
|
||||||
|
calendarDao.updateCalendarEntry(entry)
|
||||||
|
Result.failure(Exception("API error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// При ошибке сети обновляем данные локально
|
||||||
|
calendarDao.updateCalendarEntry(entry)
|
||||||
|
Log.e(TAG, "Error updating calendar entry", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteCalendarEntry(entryId: Long): Result<Unit> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val entry = calendarDao.getCalendarEntryById(entryId)
|
||||||
|
if (entry == null) {
|
||||||
|
return@withContext Result.failure(Exception("Entry not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = calendarApi.deleteCalendarEntry(entryId)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
calendarDao.deleteCalendarEntry(entry)
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("API error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error deleting calendar entry", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthInsight operations
|
||||||
|
fun getActiveHealthInsightsFlow(userId: String): Flow<List<HealthInsight>> {
|
||||||
|
return calendarDao.getActiveHealthInsightsFlow(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllHealthInsightsFlow(userId: String): Flow<List<HealthInsight>> {
|
||||||
|
return calendarDao.getAllHealthInsightsFlow(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshHealthInsights(userId: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.getHealthInsights()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { insights ->
|
||||||
|
calendarDao.insertHealthInsights(insights)
|
||||||
|
Log.d(TAG, "Health insights refreshed from server: ${insights.size}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Failed to refresh health insights: ${response.code()}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error refreshing health insights", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun dismissInsight(insightId: Long): Result<Unit> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.dismissInsight(insightId)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
calendarDao.dismissHealthInsight(insightId)
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("API error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error dismissing insight", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заглушка: Получение статистики цикла
|
||||||
|
suspend fun getCycleStatistics(userId: String): Response<CycleStatistics> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return Response.success(
|
||||||
|
CycleStatistics(
|
||||||
|
userId = userId,
|
||||||
|
averageCycleLength = 28,
|
||||||
|
averagePeriodLength = 5,
|
||||||
|
shortestCycle = 27,
|
||||||
|
longestCycle = 30,
|
||||||
|
cycleLengthVariation = 3,
|
||||||
|
regularityScore = 90,
|
||||||
|
dataPointsCount = 12,
|
||||||
|
lastUpdated = now
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заглушка: Получение прогнозов цикла
|
||||||
|
suspend fun getCyclePredictions(userId: String): Response<CyclePrediction> {
|
||||||
|
val today = java.time.LocalDate.now()
|
||||||
|
return Response.success(
|
||||||
|
CyclePrediction(
|
||||||
|
userId = userId,
|
||||||
|
nextPeriodStart = today.plusDays(10),
|
||||||
|
nextPeriodEnd = today.plusDays(15),
|
||||||
|
nextOvulation = today.plusDays(20),
|
||||||
|
fertileWindowStart = today.plusDays(18),
|
||||||
|
fertileWindowEnd = today.plusDays(22),
|
||||||
|
confidenceScore = 85,
|
||||||
|
createdAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заглушка: Получение кэшированных прогнозов
|
||||||
|
fun getCachedPredictions(userId: String): CyclePrediction {
|
||||||
|
val today = java.time.LocalDate.now()
|
||||||
|
return CyclePrediction(
|
||||||
|
userId = userId,
|
||||||
|
nextPeriodStart = today.plusDays(10),
|
||||||
|
nextPeriodEnd = today.plusDays(15),
|
||||||
|
nextOvulation = today.plusDays(20),
|
||||||
|
fertileWindowStart = today.plusDays(18),
|
||||||
|
fertileWindowEnd = today.plusDays(22),
|
||||||
|
confidenceScore = 80,
|
||||||
|
createdAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заглушка: Получение инсайтов о здоровье
|
||||||
|
suspend fun getHealthInsights(): Response<List<HealthInsight>> {
|
||||||
|
// TODO: Реализовать реальный запрос
|
||||||
|
return Response.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up operations
|
||||||
|
suspend fun cleanupOldInsights(userId: String, olderThan: Long) {
|
||||||
|
calendarDao.deleteOldInsights(userId, olderThan)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCalendarEntries(): List<CalendarEntry> {
|
||||||
|
// Пример: получение из DAO или API
|
||||||
|
return calendarDao.getAllCalendarEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCycleData(): CycleData? {
|
||||||
|
// Пример: получение из DAO или API
|
||||||
|
return calendarDao.getLatestCycleData()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package com.example.womansafe.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEvent
|
||||||
|
import com.example.womansafe.utils.DateUtils
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarEventDetailDialog(
|
||||||
|
event: CalendarEvent,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onEdit: (CalendarEvent) -> Unit,
|
||||||
|
onDelete: (CalendarEvent) -> Unit
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
// Заголовок с датой
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Иконка типа события
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(getEventIconBackgroundColor(event.type)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = getEventIcon(event.type),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = getEventTitle(event.type),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = event.date.format(DateTimeFormatter.ofPattern("d MMMM yyyy", Locale("ru"))),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 16.dp))
|
||||||
|
|
||||||
|
// Детали события
|
||||||
|
event.flowIntensity?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Интенсивность",
|
||||||
|
value = getFlowIntensityLabel(it),
|
||||||
|
icon = Icons.Default.Opacity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.mood?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Настроение",
|
||||||
|
value = getMoodLabel(it),
|
||||||
|
icon = Icons.Default.Face
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.symptoms?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Симптомы",
|
||||||
|
value = it.joinToString(", ") { symptom -> getSymptomLabel(symptom) },
|
||||||
|
icon = Icons.Default.HealthAndSafety
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.medications?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Лекарства",
|
||||||
|
value = it.joinToString(", "),
|
||||||
|
icon = Icons.Default.Medication
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.energyLevel?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Уровень энергии",
|
||||||
|
value = "$it/5",
|
||||||
|
icon = Icons.Default.BatteryChargingFull
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.sleepHours?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Часы сна",
|
||||||
|
value = "$it ч",
|
||||||
|
icon = Icons.Default.Bedtime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.notes?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Заметки",
|
||||||
|
value = it,
|
||||||
|
icon = Icons.Default.Notes,
|
||||||
|
maxLines = 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Кнопки действий
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Закрыть")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = { onEdit(event) }) {
|
||||||
|
Text("Редактировать")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = { onDelete(event) },
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Удалить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DetailItem(
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
maxLines: Int = 2
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 16.dp, top = 2.dp)
|
||||||
|
.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = maxLines,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательные функции для получения информации о событии
|
||||||
|
private fun getEventIcon(type: String): androidx.compose.ui.graphics.vector.ImageVector {
|
||||||
|
return when (type.lowercase()) {
|
||||||
|
"period" -> Icons.Default.Opacity
|
||||||
|
"ovulation" -> Icons.Default.Star
|
||||||
|
"symptoms" -> Icons.Default.Healing
|
||||||
|
"medication" -> Icons.Default.LocalPharmacy
|
||||||
|
"note" -> Icons.Default.Notes
|
||||||
|
"appointment" -> Icons.Default.Event
|
||||||
|
else -> Icons.Default.Check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEventIconBackgroundColor(type: String): Color {
|
||||||
|
return when (type.lowercase()) {
|
||||||
|
"period" -> Color(0xFFE91E63) // Розовый
|
||||||
|
"ovulation" -> Color(0xFF2196F3) // Голубой
|
||||||
|
"symptoms" -> Color(0xFFFFC107) // Желтый
|
||||||
|
"medication" -> Color(0xFF9C27B0) // Фиолетовый
|
||||||
|
"note" -> Color(0xFF607D8B) // Серо-синий
|
||||||
|
"appointment" -> Color(0xFF4CAF50) // Зеленый
|
||||||
|
else -> Color.Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEventTitle(type: String): String {
|
||||||
|
return when (type.lowercase()) {
|
||||||
|
"period" -> "Менструация"
|
||||||
|
"ovulation" -> "Овуляция"
|
||||||
|
"symptoms" -> "Симптомы"
|
||||||
|
"medication" -> "Лекарства"
|
||||||
|
"note" -> "Заметка"
|
||||||
|
"appointment" -> "Приём врача"
|
||||||
|
else -> type.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFlowIntensityLabel(intensity: String): String {
|
||||||
|
return when (intensity.uppercase()) {
|
||||||
|
"SPOTTING" -> "Мажущие"
|
||||||
|
"LIGHT" -> "Легкие"
|
||||||
|
"MEDIUM" -> "Средние"
|
||||||
|
"HEAVY" -> "Сильные"
|
||||||
|
"VERY_HEAVY" -> "Очень сильные"
|
||||||
|
else -> intensity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMoodLabel(mood: String): String {
|
||||||
|
return when (mood.uppercase()) {
|
||||||
|
"VERY_HAPPY" -> "Отлично"
|
||||||
|
"HAPPY" -> "Хорошо"
|
||||||
|
"NEUTRAL" -> "Нормально"
|
||||||
|
"NORMAL" -> "Нормально"
|
||||||
|
"SAD" -> "Грустно"
|
||||||
|
"VERY_SAD" -> "Очень грустно"
|
||||||
|
"ANXIOUS" -> "Тревожно"
|
||||||
|
"IRRITATED" -> "Раздражение"
|
||||||
|
"IRRITABLE" -> "Раздражительность"
|
||||||
|
"SENSITIVE" -> "Чувствительность"
|
||||||
|
"CALM" -> "Спокойно"
|
||||||
|
"ENERGETIC" -> "Энергично"
|
||||||
|
"TIRED" -> "Устало"
|
||||||
|
else -> mood
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSymptomLabel(symptom: String): String {
|
||||||
|
return when (symptom.uppercase()) {
|
||||||
|
"CRAMPS" -> "Спазмы"
|
||||||
|
"HEADACHE" -> "Головная боль"
|
||||||
|
"BACKACHE" -> "Боль в спине"
|
||||||
|
"NAUSEA" -> "Тошнота"
|
||||||
|
"FATIGUE" -> "Усталость"
|
||||||
|
"BLOATING" -> "Вздутие"
|
||||||
|
"BREAST_TENDERNESS" -> "Болезненность груди"
|
||||||
|
"ACNE" -> "Высыпания"
|
||||||
|
"MOOD_SWINGS" -> "Перепады настроения"
|
||||||
|
"CRAVINGS" -> "Тяга к еде"
|
||||||
|
"INSOMNIA" -> "Бессонница"
|
||||||
|
"DIZZINESS" -> "Головокружение"
|
||||||
|
"CONSTIPATION" -> "Запор"
|
||||||
|
"DIARRHEA" -> "Диарея"
|
||||||
|
"HOT_FLASHES" -> "Приливы"
|
||||||
|
"SPOTTING" -> "Мажущие выделения"
|
||||||
|
else -> symptom
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
package com.example.womansafe.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.womansafe.data.model.EmergencyContactResponse
|
||||||
|
import com.example.womansafe.data.model.UserResponse
|
||||||
|
import com.example.womansafe.ui.viewmodel.ApiTestViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthTab(
|
||||||
|
email: String,
|
||||||
|
onEmailChange: (String) -> Unit,
|
||||||
|
username: String,
|
||||||
|
onUsernameChange: (String) -> Unit,
|
||||||
|
password: String,
|
||||||
|
onPasswordChange: (String) -> Unit,
|
||||||
|
fullName: String,
|
||||||
|
onFullNameChange: (String) -> Unit,
|
||||||
|
phoneNumber: String,
|
||||||
|
onPhoneNumberChange: (String) -> Unit,
|
||||||
|
viewModel: ApiTestViewModel,
|
||||||
|
isLoading: Boolean
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "🔐 Аутентификация",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = onEmailChange,
|
||||||
|
label = { Text("Email") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = onUsernameChange,
|
||||||
|
label = { Text("Username (опционально)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = onPasswordChange,
|
||||||
|
label = { Text("Пароль") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.login(
|
||||||
|
email = if (email.isNotBlank()) email else null,
|
||||||
|
username = if (username.isNotBlank()) username else null,
|
||||||
|
password = password
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = !isLoading && password.isNotBlank()
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||||
|
} else {
|
||||||
|
Text("Войти")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.clearAuth() },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Выйти")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Регистрация",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = fullName,
|
||||||
|
onValueChange = onFullNameChange,
|
||||||
|
label = { Text("Полное имя") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = phoneNumber,
|
||||||
|
onValueChange = onPhoneNumberChange,
|
||||||
|
label = { Text("Номер телефона") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.register(
|
||||||
|
email = email,
|
||||||
|
username = if (username.isNotBlank()) username else null,
|
||||||
|
password = password,
|
||||||
|
fullName = if (fullName.isNotBlank()) fullName else null,
|
||||||
|
phoneNumber = if (phoneNumber.isNotBlank()) phoneNumber else null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading && email.isNotBlank() && password.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text("Зарегистрироваться")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserTab(
|
||||||
|
viewModel: ApiTestViewModel,
|
||||||
|
currentUser: UserResponse?,
|
||||||
|
isLoading: Boolean
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "👤 Профиль пользователя",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.getCurrentUser() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading
|
||||||
|
) {
|
||||||
|
Text("Получить профиль")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.getDashboard() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading
|
||||||
|
) {
|
||||||
|
Text("Получить дашборд")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Информация о пользователе:",
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text("ID: ${currentUser.id}")
|
||||||
|
Text("UUID: ${currentUser.uuid}")
|
||||||
|
Text("Email: ${currentUser.email}")
|
||||||
|
currentUser.full_name?.let { Text("Имя: $it") }
|
||||||
|
currentUser.phone_number?.let { Text("Телефон: $it") }
|
||||||
|
currentUser.username?.let { Text("Имя пользователя: $it") }
|
||||||
|
currentUser.first_name?.let { Text("Имя: $it") }
|
||||||
|
currentUser.last_name?.let { Text("Фамилия: $it") }
|
||||||
|
currentUser.bio?.let { Text("О себе: $it") }
|
||||||
|
currentUser.date_of_birth?.let { Text("Дата рождения: $it") }
|
||||||
|
Text("Email подтвержден: ${if (currentUser.email_verified) "Да" else "Нет"}")
|
||||||
|
Text("Телефон подтвержден: ${if (currentUser.phone_verified) "Да" else "Нет"}")
|
||||||
|
Text("Активен: ${if (currentUser.is_active) "Да" else "Нет"}")
|
||||||
|
Text("Геолокация включена: ${if (currentUser.location_sharing_enabled) "Да" else "Нет"}")
|
||||||
|
Text("Экстренные уведомления: ${if (currentUser.emergency_notifications_enabled) "Да" else "Нет"}")
|
||||||
|
Text("Push-уведомления: ${if (currentUser.push_notifications_enabled) "Да" else "Нет"}")
|
||||||
|
currentUser.emergency_contact_1_name?.let {
|
||||||
|
Text("Экстренный контакт 1: $it (${currentUser.emergency_contact_1_phone ?: "Не указан"})")
|
||||||
|
}
|
||||||
|
currentUser.emergency_contact_2_name?.let {
|
||||||
|
Text("Экстренный контакт 2: $it (${currentUser.emergency_contact_2_phone ?: "Не указан"})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactsTab(
|
||||||
|
contactName: String,
|
||||||
|
onContactNameChange: (String) -> Unit,
|
||||||
|
contactPhone: String,
|
||||||
|
onContactPhoneChange: (String) -> Unit,
|
||||||
|
contactRelationship: String,
|
||||||
|
onContactRelationshipChange: (String) -> Unit,
|
||||||
|
contactNotes: String,
|
||||||
|
onContactNotesChange: (String) -> Unit,
|
||||||
|
viewModel: ApiTestViewModel,
|
||||||
|
contacts: List<EmergencyContactResponse>,
|
||||||
|
isLoading: Boolean
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "🚨 Экстренные контакты",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.getEmergencyContacts() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading
|
||||||
|
) {
|
||||||
|
Text("Получить список контактов")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Добавить новый контакт:",
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = contactName,
|
||||||
|
onValueChange = onContactNameChange,
|
||||||
|
label = { Text("Имя контакта") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = contactPhone,
|
||||||
|
onValueChange = onContactPhoneChange,
|
||||||
|
label = { Text("Номер телефона") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = contactRelationship,
|
||||||
|
onValueChange = onContactRelationshipChange,
|
||||||
|
label = { Text("Отношение (опционально)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = contactNotes,
|
||||||
|
onValueChange = onContactNotesChange,
|
||||||
|
label = { Text("Заметки (опционально)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.createEmergencyContact(
|
||||||
|
name = contactName,
|
||||||
|
phoneNumber = contactPhone,
|
||||||
|
relationship = if (contactRelationship.isNotBlank()) contactRelationship else null,
|
||||||
|
notes = if (contactNotes.isNotBlank()) contactNotes else null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading && contactName.isNotBlank() && contactPhone.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text("Создать контакт")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contacts.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Существующие контакты:",
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
contacts.forEach { contact ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Text("${contact.name} - ${contact.phone_number}")
|
||||||
|
contact.relationship?.let { Text("Отношение: $it", fontSize = 12.sp) }
|
||||||
|
contact.notes?.let { Text("Заметки: $it", fontSize = 12.sp) }
|
||||||
|
Text("ID: ${contact.id}", fontSize = 10.sp, color = Color.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ApiTestsTab(
|
||||||
|
viewModel: ApiTestViewModel,
|
||||||
|
isLoading: Boolean
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "🧪 API Тесты",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
val endpoints = listOf(
|
||||||
|
"Health Check" to "/api/v1/health",
|
||||||
|
"Services Status" to "/api/v1/services-status",
|
||||||
|
"Root" to "/",
|
||||||
|
"Emergency Reports" to "/api/v1/emergency/reports",
|
||||||
|
"Emergency Alerts" to "/api/v1/emergency/alerts",
|
||||||
|
"Last Location" to "/api/v1/locations/last",
|
||||||
|
"Location History" to "/api/v1/locations/history",
|
||||||
|
"Calendar Entries" to "/api/v1/calendar/entries",
|
||||||
|
"Notification Preferences" to "/api/v1/notifications/preferences"
|
||||||
|
)
|
||||||
|
|
||||||
|
endpoints.forEach { (name, endpoint) ->
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.testGenericEndpoint(endpoint, "GET") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
enabled = !isLoading
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(name)
|
||||||
|
Text(
|
||||||
|
text = endpoint,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsTab(
|
||||||
|
baseUrl: String,
|
||||||
|
onBaseUrlChange: (String) -> Unit,
|
||||||
|
viewModel: ApiTestViewModel
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "⚙️ Настройки",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = baseUrl,
|
||||||
|
onValueChange = onBaseUrlChange,
|
||||||
|
label = { Text("Base URL API") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
supportingText = {
|
||||||
|
Text("Для эмулятора: http://10.0.2.2:8000/\nДля реального устройства: http://YOUR_IP:8000/")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Примеры URL:",
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
val exampleUrls = listOf(
|
||||||
|
"Эмулятор" to "http://10.0.2.2:8000/",
|
||||||
|
"Localhost" to "http://127.0.0.1:8000/",
|
||||||
|
"Удаленный сервер" to "https://api.womansafe.com/"
|
||||||
|
)
|
||||||
|
|
||||||
|
exampleUrls.forEach { (name, url) ->
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onBaseUrlChange(url) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(name)
|
||||||
|
Text(url, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ResultsSection(
|
||||||
|
endpoint: String,
|
||||||
|
response: String,
|
||||||
|
error: String,
|
||||||
|
onClear: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (error.isNotEmpty())
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "📡 Результат: $endpoint",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
TextButton(onClick = onClear) {
|
||||||
|
Text("Очистить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (error.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "❌ Ошибка:",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "✅ Ответ:",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (error.isEmpty()) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = response,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.example.womansafe.ui.icons
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Create
|
||||||
|
import androidx.compose.material.icons.filled.Spa
|
||||||
|
import androidx.compose.material.icons.filled.WaterDrop
|
||||||
|
import androidx.compose.material.icons.outlined.Description
|
||||||
|
import androidx.compose.material.icons.outlined.LocalHospital
|
||||||
|
import androidx.compose.material.icons.outlined.Opacity
|
||||||
|
import androidx.compose.material.icons.rounded.BarChart
|
||||||
|
import androidx.compose.material.icons.rounded.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.rounded.Event
|
||||||
|
import androidx.compose.material.icons.rounded.Lightbulb
|
||||||
|
import androidx.compose.material.icons.rounded.NavigateNext
|
||||||
|
import androidx.compose.material.icons.rounded.SentimentDissatisfied
|
||||||
|
import androidx.compose.material.icons.rounded.Warning
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расширенные иконки для использования в приложении
|
||||||
|
*/
|
||||||
|
object CustomIcons {
|
||||||
|
// Иконки для календаря
|
||||||
|
val Healing: ImageVector = Icons.Outlined.LocalHospital
|
||||||
|
val Notes: ImageVector = Icons.Outlined.Description
|
||||||
|
val WaterDrop: ImageVector = Icons.Default.WaterDrop
|
||||||
|
val Contacts: ImageVector = Icons.Default.Create
|
||||||
|
val BarChart: ImageVector = Icons.Rounded.BarChart
|
||||||
|
val Lightbulb: ImageVector = Icons.Rounded.Lightbulb
|
||||||
|
val CalendarMonth: ImageVector = Icons.Rounded.CalendarMonth
|
||||||
|
val LocalHospital: ImageVector = Icons.Outlined.LocalHospital
|
||||||
|
val Dangerous: ImageVector = Icons.Rounded.Warning
|
||||||
|
val Spa: ImageVector = Icons.Default.Spa
|
||||||
|
val SentimentDissatisfied: ImageVector = Icons.Rounded.SentimentDissatisfied
|
||||||
|
val Opacity: ImageVector = Icons.Outlined.Opacity
|
||||||
|
val Event: ImageVector = Icons.Rounded.Event
|
||||||
|
val ChevronRight: ImageVector = Icons.Rounded.NavigateNext
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.example.womansafe.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
|
||||||
|
sealed class BottomNavItem(val route: String, val icon: ImageVector, val title: String) {
|
||||||
|
object Home : BottomNavItem("home", Icons.Filled.Home, "Главная")
|
||||||
|
object Emergency : BottomNavItem("emergency", Icons.Filled.Warning, "Тревога")
|
||||||
|
object Calendar : BottomNavItem("calendar", Icons.Filled.DateRange, "Календарь")
|
||||||
|
object Profile : BottomNavItem("profile", Icons.Filled.Person, "Профиль")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BottomNavigationBar(navController: NavController) {
|
||||||
|
val items = listOf(
|
||||||
|
BottomNavItem.Home,
|
||||||
|
BottomNavItem.Emergency,
|
||||||
|
BottomNavItem.Calendar,
|
||||||
|
BottomNavItem.Profile
|
||||||
|
)
|
||||||
|
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
|
NavigationBar {
|
||||||
|
items.forEach { item ->
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = item.icon,
|
||||||
|
contentDescription = item.title
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(item.title) },
|
||||||
|
selected = currentRoute == item.route,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(item.route) {
|
||||||
|
// Pop up to the start destination of the graph to
|
||||||
|
// avoid building up a large stack of destinations
|
||||||
|
popUpTo(navController.graph.startDestinationId) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
// Avoid multiple copies of the same destination when
|
||||||
|
// reselecting the same item
|
||||||
|
launchSingleTop = true
|
||||||
|
// Restore state when reselecting a previously selected item
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.example.womansafe.ui.viewmodel.ApiTestViewModel
|
||||||
|
import com.example.womansafe.ui.components.*
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ApiTestScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ApiTestViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
var email by remember { mutableStateOf("user@example.com") }
|
||||||
|
var username by remember { mutableStateOf("user123") }
|
||||||
|
var password by remember { mutableStateOf("Password123!") }
|
||||||
|
var fullName by remember { mutableStateOf("John Doe") }
|
||||||
|
var phoneNumber by remember { mutableStateOf("+7123456789") }
|
||||||
|
var baseUrl by remember { mutableStateOf(state.baseUrl) }
|
||||||
|
|
||||||
|
var contactName by remember { mutableStateOf("Emergency Contact") }
|
||||||
|
var contactPhone by remember { mutableStateOf("+7987654321") }
|
||||||
|
var contactRelationship by remember { mutableStateOf("Friend") }
|
||||||
|
var contactNotes by remember { mutableStateOf("Test contact") }
|
||||||
|
|
||||||
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
|
val tabs = listOf("Аутентификация", "Пользователь", "Контакты", "API Тесты", "Настройки")
|
||||||
|
|
||||||
|
LaunchedEffect(baseUrl) {
|
||||||
|
viewModel.updateBaseUrl(baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Women's Safety API Tester",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.isAuthenticated) {
|
||||||
|
Text(
|
||||||
|
text = "✅ Авторизован",
|
||||||
|
color = Color(0xFF4CAF50),
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "❌ Не авторизован",
|
||||||
|
color = Color(0xFFF44336),
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "API: $baseUrl",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
ScrollableTabRow(selectedTabIndex = selectedTab) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = { Text(title) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Content based on selected tab
|
||||||
|
when (selectedTab) {
|
||||||
|
0 -> AuthTab(
|
||||||
|
email = email,
|
||||||
|
onEmailChange = { email = it },
|
||||||
|
username = username,
|
||||||
|
onUsernameChange = { username = it },
|
||||||
|
password = password,
|
||||||
|
onPasswordChange = { password = it },
|
||||||
|
fullName = fullName,
|
||||||
|
onFullNameChange = { fullName = it },
|
||||||
|
phoneNumber = phoneNumber,
|
||||||
|
onPhoneNumberChange = { phoneNumber = it },
|
||||||
|
viewModel = viewModel,
|
||||||
|
isLoading = state.isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
1 -> UserTab(
|
||||||
|
viewModel = viewModel,
|
||||||
|
currentUser = state.currentUser,
|
||||||
|
isLoading = state.isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
2 -> ContactsTab(
|
||||||
|
contactName = contactName,
|
||||||
|
onContactNameChange = { contactName = it },
|
||||||
|
contactPhone = contactPhone,
|
||||||
|
onContactPhoneChange = { contactPhone = it },
|
||||||
|
contactRelationship = contactRelationship,
|
||||||
|
onContactRelationshipChange = { contactRelationship = it },
|
||||||
|
contactNotes = contactNotes,
|
||||||
|
onContactNotesChange = { contactNotes = it },
|
||||||
|
viewModel = viewModel,
|
||||||
|
contacts = state.emergencyContacts,
|
||||||
|
isLoading = state.isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
3 -> ApiTestsTab(
|
||||||
|
viewModel = viewModel,
|
||||||
|
isLoading = state.isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
4 -> SettingsTab(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
onBaseUrlChange = { baseUrl = it },
|
||||||
|
viewModel = viewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Results section
|
||||||
|
if (state.selectedEndpoint.isNotEmpty()) {
|
||||||
|
ResultsSection(
|
||||||
|
endpoint = state.selectedEndpoint,
|
||||||
|
response = state.lastApiResponse,
|
||||||
|
error = state.lastApiError,
|
||||||
|
onClear = { viewModel.clearResults() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
642
app/src/main/java/com/example/womansafe/ui/screens/AuthScreen.kt
Normal file
642
app/src/main/java/com/example/womansafe/ui/screens/AuthScreen.kt
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.womansafe.data.model.UserResponse
|
||||||
|
import com.example.womansafe.ui.viewmodel.AuthViewModel
|
||||||
|
import com.example.womansafe.util.fixTouchEvents
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AuthScreen(viewModel: AuthViewModel) {
|
||||||
|
val uiState = viewModel.uiState
|
||||||
|
var isLogin by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
if (uiState.isLoggedIn) {
|
||||||
|
// Показываем индикатор загрузки профиля или сам профиль
|
||||||
|
if (!uiState.profileLoaded && uiState.user == null) {
|
||||||
|
ProfileLoadingScreen()
|
||||||
|
} else {
|
||||||
|
UserProfileScreen(
|
||||||
|
user = uiState.user,
|
||||||
|
onLogout = { viewModel.logout() },
|
||||||
|
error = uiState.error,
|
||||||
|
onClearError = { viewModel.clearError() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
if (isLogin) {
|
||||||
|
LoginForm(
|
||||||
|
onLogin = { usernameOrEmail, password ->
|
||||||
|
viewModel.login(usernameOrEmail, password)
|
||||||
|
},
|
||||||
|
onSwitchToRegister = { isLogin = false },
|
||||||
|
isLoading = uiState.isLoading,
|
||||||
|
error = uiState.error,
|
||||||
|
onClearError = { viewModel.clearError() }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
RegisterForm(
|
||||||
|
onRegister = { username, email, password, fullName, phoneNumber ->
|
||||||
|
viewModel.register(username, email, password, fullName, phoneNumber)
|
||||||
|
},
|
||||||
|
onSwitchToLogin = { isLogin = true },
|
||||||
|
isLoading = uiState.isLoading,
|
||||||
|
error = uiState.error,
|
||||||
|
onClearError = { viewModel.clearError() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginForm(
|
||||||
|
onLogin: (String, String) -> Unit,
|
||||||
|
onSwitchToRegister: () -> Unit,
|
||||||
|
isLoading: Boolean,
|
||||||
|
error: String?,
|
||||||
|
onClearError: () -> Unit
|
||||||
|
) {
|
||||||
|
var usernameOrEmail by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Вход в приложение",
|
||||||
|
style = MaterialTheme.typography.headlineMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = usernameOrEmail,
|
||||||
|
onValueChange = {
|
||||||
|
usernameOrEmail = it
|
||||||
|
if (error != null) onClearError()
|
||||||
|
},
|
||||||
|
label = { Text("Email или имя пользователя") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = {
|
||||||
|
password = it
|
||||||
|
if (error != null) onClearError()
|
||||||
|
},
|
||||||
|
label = { Text("Пароль") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onLogin(usernameOrEmail, password) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading && usernameOrEmail.isNotBlank() && password.isNotBlank()
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Вход...")
|
||||||
|
} else {
|
||||||
|
Text("Войти")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onSwitchToRegister,
|
||||||
|
enabled = !isLoading,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Нет аккаунта? Зарегистрироваться")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RegisterForm(
|
||||||
|
onRegister: (String, String, String, String?, String?) -> Unit,
|
||||||
|
onSwitchToLogin: () -> Unit,
|
||||||
|
isLoading: Boolean,
|
||||||
|
error: String?,
|
||||||
|
onClearError: () -> Unit
|
||||||
|
) {
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var fullName by remember { mutableStateOf("") }
|
||||||
|
var phoneNumber by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Регистрация",
|
||||||
|
style = MaterialTheme.typography.headlineMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = {
|
||||||
|
email = it
|
||||||
|
if (error != null) onClearError()
|
||||||
|
},
|
||||||
|
label = { Text("Email *") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = {
|
||||||
|
username = it
|
||||||
|
if (error != null) onClearError()
|
||||||
|
},
|
||||||
|
label = { Text("Имя пользователя") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = fullName,
|
||||||
|
onValueChange = {
|
||||||
|
fullName = it
|
||||||
|
if (error != null) onClearError()
|
||||||
|
},
|
||||||
|
label = { Text("Полное имя") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = phoneNumber,
|
||||||
|
onValueChange = {
|
||||||
|
phoneNumber = it
|
||||||
|
if (error != null) onClearError()
|
||||||
|
},
|
||||||
|
label = { Text("Номер телефона") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = {
|
||||||
|
password = it
|
||||||
|
if (error != null) onClearError()
|
||||||
|
},
|
||||||
|
label = { Text("Пароль *") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onRegister(
|
||||||
|
username.ifBlank { null } ?: "",
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
fullName.ifBlank { null },
|
||||||
|
phoneNumber.ifBlank { null }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isLoading && email.isNotBlank() && password.isNotBlank()
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Регистрация...")
|
||||||
|
} else {
|
||||||
|
Text("Зарегистрироваться")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onSwitchToLogin,
|
||||||
|
enabled = !isLoading,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Уже есть аккаунт? Войти")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileLoadingScreen() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "Загрузка профиля...",
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserProfileScreen(
|
||||||
|
user: UserResponse?,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
error: String? = null,
|
||||||
|
onClearError: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
.fixTouchEvents(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
// Заголовок профиля с реальными данными
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = user?.full_name ?: user?.username ?: "Профиль",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
|
||||||
|
user?.email?.let { email ->
|
||||||
|
Text(
|
||||||
|
text = email,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onLogout,
|
||||||
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
|
containerColor = Color.Transparent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ExitToApp,
|
||||||
|
contentDescription = "Выйти",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение ошибки, если есть
|
||||||
|
if (error != null && onClearError != null) {
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
IconButton(onClick = onClearError) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Закрыть",
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Основная информация пользователя из API
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Личная информация",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
user?.let {
|
||||||
|
ProfileInfoRow("ID пользователя", it.id.toString())
|
||||||
|
ProfileInfoRow("UUID", it.uuid)
|
||||||
|
ProfileInfoRow("Email", it.email)
|
||||||
|
it.username?.let { username ->
|
||||||
|
ProfileInfoRow("Имя пользователя", username)
|
||||||
|
}
|
||||||
|
it.full_name?.let { name ->
|
||||||
|
ProfileInfoRow("Полное имя", name)
|
||||||
|
}
|
||||||
|
it.first_name?.let { firstName ->
|
||||||
|
ProfileInfoRow("Имя", firstName)
|
||||||
|
}
|
||||||
|
it.last_name?.let { lastName ->
|
||||||
|
ProfileInfoRow("Фамилия", lastName)
|
||||||
|
}
|
||||||
|
it.phone_number?.let { phone ->
|
||||||
|
ProfileInfoRow("Номер телефона", phone)
|
||||||
|
}
|
||||||
|
it.date_of_birth?.let { date ->
|
||||||
|
ProfileInfoRow("Дата рождения", date)
|
||||||
|
}
|
||||||
|
it.bio?.let { bio ->
|
||||||
|
ProfileInfoRow("О себе", bio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Статус аккаунта из API
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Статус аккаунта",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
user?.let {
|
||||||
|
ProfileStatusRow(
|
||||||
|
"Аккаунт активен",
|
||||||
|
it.is_active,
|
||||||
|
if (it.is_active) "Активный" else "Неактивный"
|
||||||
|
)
|
||||||
|
ProfileStatusRow(
|
||||||
|
"Email подтверждён",
|
||||||
|
it.email_verified,
|
||||||
|
if (it.email_verified) "Подтверждён" else "Требует подтверждения"
|
||||||
|
)
|
||||||
|
ProfileStatusRow(
|
||||||
|
"Телефон подтверждён",
|
||||||
|
it.phone_verified,
|
||||||
|
if (it.phone_verified) "Подтверждён" else "Требует подтверждения"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Настройки уведомлений из API
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Настройки уведомлений",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
user?.let {
|
||||||
|
ProfileStatusRow(
|
||||||
|
"Геолокация",
|
||||||
|
it.location_sharing_enabled,
|
||||||
|
if (it.location_sharing_enabled) "Включена" else "Отключена"
|
||||||
|
)
|
||||||
|
ProfileStatusRow(
|
||||||
|
"Экстренные уведомления",
|
||||||
|
it.emergency_notifications_enabled,
|
||||||
|
if (it.emergency_notifications_enabled) "Включены" else "Отключены"
|
||||||
|
)
|
||||||
|
ProfileStatusRow(
|
||||||
|
"Push-уведомления",
|
||||||
|
it.push_notifications_enabled,
|
||||||
|
if (it.push_notifications_enabled) "Включены" else "Отключены"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экстренные контакты из API (если есть в профиле пользователя)
|
||||||
|
user?.let { userData ->
|
||||||
|
if (userData.emergency_contact_1_name != null || userData.emergency_contact_2_name != null) {
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Экстренные контакты",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
userData.emergency_contact_1_name?.let { contact1 ->
|
||||||
|
ProfileInfoRow("Контакт 1", contact1)
|
||||||
|
userData.emergency_contact_1_phone?.let { phone1 ->
|
||||||
|
ProfileInfoRow("Телефон 1", phone1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userData.emergency_contact_2_name?.let { contact2 ->
|
||||||
|
ProfileInfoRow("Контакт 2", contact2)
|
||||||
|
userData.emergency_contact_2_phone?.let { phone2 ->
|
||||||
|
ProfileInfoRow("Телефон 2", phone2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Кнопка выхода
|
||||||
|
Button(
|
||||||
|
onClick = onLogout,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ExitToApp,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Выйти из аккаунта")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileInfoRow(label: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textAlign = TextAlign.End
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileStatusRow(label: String, isEnabled: Boolean, statusText: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isEnabled) Icons.Default.CheckCircle else Icons.Default.Close,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (isEnabled) Color(0xFF4CAF50) else Color(0xFFF44336),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = statusText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (isEnabled) Color(0xFF4CAF50) else Color(0xFFF44336)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.womansafe.data.model.calendar.*
|
||||||
|
import com.example.womansafe.ui.icons.CustomIcons
|
||||||
|
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран для добавления/редактирования записей в календаре
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarEntryScreen(
|
||||||
|
viewModel: CalendarViewModel,
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
existingEntry: CalendarEntry? = null,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
// Форматтер для отображения даты
|
||||||
|
val dateFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale("ru"))
|
||||||
|
|
||||||
|
// Состояние для полей формы
|
||||||
|
var entryType by remember { mutableStateOf(existingEntry?.entryType ?: EntryType.PERIOD) }
|
||||||
|
var flowIntensity by remember { mutableStateOf(existingEntry?.flowIntensity ?: FlowIntensity.MEDIUM) }
|
||||||
|
var mood by remember { mutableStateOf(existingEntry?.mood ?: Mood.NORMAL) }
|
||||||
|
var energyLevel by remember { mutableStateOf(existingEntry?.energyLevel ?: 3) }
|
||||||
|
var sleepHours by remember { mutableStateOf(existingEntry?.sleepHours?.toString() ?: "8.0") }
|
||||||
|
var selectedSymptoms by remember { mutableStateOf(existingEntry?.symptoms?.toMutableList() ?: mutableListOf()) }
|
||||||
|
var medications by remember { mutableStateOf(existingEntry?.medications?.joinToString(", ") ?: "") }
|
||||||
|
var notes by remember { mutableStateOf(existingEntry?.notes ?: "") }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = if (existingEntry != null) "Редактировать запись" else "Новая запись"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Закрыть")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
// Создаем или обновляем запись
|
||||||
|
val entry = existingEntry?.copy(
|
||||||
|
entryDate = selectedDate,
|
||||||
|
entryType = entryType,
|
||||||
|
flowIntensity = if (entryType == EntryType.PERIOD) flowIntensity else null,
|
||||||
|
mood = mood,
|
||||||
|
energyLevel = energyLevel,
|
||||||
|
sleepHours = sleepHours.toFloatOrNull(),
|
||||||
|
symptoms = selectedSymptoms,
|
||||||
|
medications = if (medications.isBlank()) null else medications.split(",").map { it.trim() },
|
||||||
|
notes = notes.ifBlank { null }
|
||||||
|
) ?: CalendarEntry(
|
||||||
|
userId = "", // будет заменено в репозитории
|
||||||
|
entryDate = selectedDate,
|
||||||
|
entryType = entryType,
|
||||||
|
flowIntensity = if (entryType == EntryType.PERIOD) flowIntensity else null,
|
||||||
|
mood = mood,
|
||||||
|
energyLevel = energyLevel,
|
||||||
|
sleepHours = sleepHours.toFloatOrNull(),
|
||||||
|
symptoms = if (selectedSymptoms.isEmpty()) null else selectedSymptoms,
|
||||||
|
medications = if (medications.isBlank()) null else medications.split(",").map { it.trim() },
|
||||||
|
notes = notes.ifBlank { null },
|
||||||
|
entryId = existingEntry?.entryId ?: 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingEntry != null) {
|
||||||
|
viewModel.updateCalendarEntry(entry)
|
||||||
|
} else {
|
||||||
|
viewModel.addCalendarEntry(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Check, contentDescription = "Сохранить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
// Заголовок с датой
|
||||||
|
Text(
|
||||||
|
text = selectedDate.format(dateFormatter),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Выбор типа записи
|
||||||
|
Text(
|
||||||
|
text = "Тип записи",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
EntryTypeSelector(
|
||||||
|
selectedType = entryType,
|
||||||
|
onTypeSelected = { entryType = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Показываем дополнительные поля в зависимости от типа записи
|
||||||
|
when (entryType) {
|
||||||
|
EntryType.PERIOD -> {
|
||||||
|
FlowIntensitySelector(
|
||||||
|
selectedIntensity = flowIntensity,
|
||||||
|
onIntensitySelected = { flowIntensity = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
else -> { /* Другие поля будут отображаться всегда */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настроение
|
||||||
|
Text(
|
||||||
|
text = "Настроение",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
MoodSelector(
|
||||||
|
selectedMood = mood,
|
||||||
|
onMoodSelected = { mood = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Уровень энергии
|
||||||
|
Text(
|
||||||
|
text = "Уровень энергии: $energyLevel",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = energyLevel.toFloat(),
|
||||||
|
onValueChange = { energyLevel = it.toInt() },
|
||||||
|
valueRange = 1f..5f,
|
||||||
|
steps = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Часы сна
|
||||||
|
OutlinedTextField(
|
||||||
|
value = sleepHours,
|
||||||
|
onValueChange = { sleepHours = it },
|
||||||
|
label = { Text("Часы сна") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Number
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Симптомы
|
||||||
|
Text(
|
||||||
|
text = "Симптомы",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
SymptomsSelector(
|
||||||
|
selectedSymptoms = selectedSymptoms,
|
||||||
|
onSymptomsChanged = { selectedSymptoms = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Лекарства
|
||||||
|
OutlinedTextField(
|
||||||
|
value = medications,
|
||||||
|
onValueChange = { medications = it },
|
||||||
|
label = { Text("Лекарства (через запятую)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Заметки
|
||||||
|
OutlinedTextField(
|
||||||
|
value = notes,
|
||||||
|
onValueChange = { notes = it },
|
||||||
|
label = { Text("Заметки") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 120.dp),
|
||||||
|
maxLines = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Кнопка удаления (только для существующих записей)
|
||||||
|
existingEntry?.let {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.deleteCalendarEntry(existingEntry.id)
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Удалить запись")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Селектор типа записи
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EntryTypeSelector(
|
||||||
|
selectedType: EntryType,
|
||||||
|
onTypeSelected: (EntryType) -> Unit
|
||||||
|
) {
|
||||||
|
val entryTypes = listOf(
|
||||||
|
EntryType.PERIOD to "Менструация",
|
||||||
|
EntryType.OVULATION to "Овуляция",
|
||||||
|
EntryType.SYMPTOMS to "Симптомы",
|
||||||
|
EntryType.MEDICATION to "Лекарства",
|
||||||
|
EntryType.NOTE to "Заметка"
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectableGroup()
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
entryTypes.forEach { (type, label) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.selectable(
|
||||||
|
selected = type == selectedType,
|
||||||
|
onClick = { onTypeSelected(type) },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = type == selectedType,
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Селектор интенсивности менструации
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FlowIntensitySelector(
|
||||||
|
selectedIntensity: FlowIntensity,
|
||||||
|
onIntensitySelected: (FlowIntensity) -> Unit
|
||||||
|
) {
|
||||||
|
val intensities = listOf(
|
||||||
|
FlowIntensity.LIGHT to "Легкая",
|
||||||
|
FlowIntensity.MEDIUM to "Средняя",
|
||||||
|
FlowIntensity.HEAVY to "Сильная"
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = "Интенсивность",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectableGroup()
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
intensities.forEach { (intensity, label) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.selectable(
|
||||||
|
selected = intensity == selectedIntensity,
|
||||||
|
onClick = { onIntensitySelected(intensity) },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = intensity == selectedIntensity,
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Селектор настроения
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MoodSelector(
|
||||||
|
selectedMood: Mood,
|
||||||
|
onMoodSelected: (Mood) -> Unit
|
||||||
|
) {
|
||||||
|
// Создаем список троек (настроение, метка, иконка) вместо использования вложенных Pair
|
||||||
|
val moods = listOf(
|
||||||
|
Triple(Mood.HAPPY, "Счастливое", Icons.Default.Mood),
|
||||||
|
Triple(Mood.NORMAL, "Обычное", Icons.Default.Face),
|
||||||
|
Triple(Mood.SAD, "Грустное", CustomIcons.SentimentDissatisfied),
|
||||||
|
Triple(Mood.IRRITATED, "Раздражение", CustomIcons.Dangerous),
|
||||||
|
Triple(Mood.ANXIOUS, "Тревожное", Icons.Default.Warning),
|
||||||
|
Triple(Mood.SENSITIVE, "Чувствительное", Icons.Default.Favorite),
|
||||||
|
Triple(Mood.CALM, "Спокойное", CustomIcons.Spa)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
LazyRow {
|
||||||
|
items(moods.size) { index ->
|
||||||
|
val (mood, label, icon) = moods[index]
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.selectable(
|
||||||
|
selected = mood == selectedMood,
|
||||||
|
onClick = { onMoodSelected(mood) },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = label,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.padding(8.dp),
|
||||||
|
tint = if (mood == selectedMood)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (mood == selectedMood)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Селектор симптомов
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SymptomsSelector(
|
||||||
|
selectedSymptoms: List<Symptom>,
|
||||||
|
onSymptomsChanged: (MutableList<Symptom>) -> Unit
|
||||||
|
) {
|
||||||
|
val symptoms = listOf(
|
||||||
|
Symptom.CRAMPS to "Спазмы",
|
||||||
|
Symptom.HEADACHE to "Головная боль",
|
||||||
|
Symptom.BLOATING to "Вздутие",
|
||||||
|
Symptom.BACKACHE to "Боль в спине",
|
||||||
|
Symptom.FATIGUE to "Усталость",
|
||||||
|
Symptom.NAUSEA to "Тошнота",
|
||||||
|
Symptom.BREAST_TENDERNESS to "Боль в груди",
|
||||||
|
Symptom.ACNE to "Акне",
|
||||||
|
Symptom.CRAVINGS to "Тяга к еде",
|
||||||
|
Symptom.INSOMNIA to "Бессонница",
|
||||||
|
Symptom.DIZZINESS to "Головокружение",
|
||||||
|
Symptom.DIARRHEA to "Диарея",
|
||||||
|
Symptom.CONSTIPATION to "Запор"
|
||||||
|
)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
symptoms.chunked(2).forEach { row ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
row.forEach { (symptom, label) ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = selectedSymptoms.contains(symptom),
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
val newList = selectedSymptoms.toMutableList()
|
||||||
|
if (checked) {
|
||||||
|
newList.add(symptom)
|
||||||
|
} else {
|
||||||
|
newList.remove(symptom)
|
||||||
|
}
|
||||||
|
onSymptomsChanged(newList)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
|
import androidx.compose.material.icons.filled.FitnessCenter
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Insights
|
||||||
|
import androidx.compose.material.icons.filled.Medication
|
||||||
|
import androidx.compose.material.icons.filled.Mood
|
||||||
|
import androidx.compose.material.icons.filled.TipsAndUpdates
|
||||||
|
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.outlined.Favorite
|
||||||
|
import androidx.compose.material.icons.outlined.LocalHospital
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleData
|
||||||
|
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||||
|
import com.example.womansafe.data.model.calendar.InsightType
|
||||||
|
import com.example.womansafe.data.model.calendar.ConfidenceLevel
|
||||||
|
import com.example.womansafe.data.model.calendar.Mood
|
||||||
|
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран аналитики менструального цикла и инсайтов
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarInsightsScreen(
|
||||||
|
viewModel: CalendarViewModel = viewModel(),
|
||||||
|
onBackClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val calendarUiState by viewModel.calendarUiState.observeAsState()
|
||||||
|
val isLoading by viewModel.isLoading.observeAsState(false)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Аналитика и рекомендации") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBackClick) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Назад")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
CycleStatistics(cycleData = calendarUiState?.cycleData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разделитель
|
||||||
|
item {
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Персонализированные рекомендации",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список инсайтов
|
||||||
|
calendarUiState?.insights?.let { insights ->
|
||||||
|
if (insights.isNotEmpty()) {
|
||||||
|
items(insights) { insight ->
|
||||||
|
InsightItem(
|
||||||
|
insight = insight,
|
||||||
|
onDismiss = { viewModel.dismissInsight(insight.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item {
|
||||||
|
EmptyInsightsMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: item {
|
||||||
|
EmptyInsightsMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем пространство внизу
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статистика цикла
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CycleStatistics(cycleData: CycleData?) {
|
||||||
|
val dateFormatter = DateTimeFormatter.ofPattern("d MMMM", Locale("ru"))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Статистика цикла",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (cycleData != null) {
|
||||||
|
// Основные показатели
|
||||||
|
StatisticRow(
|
||||||
|
label = "Средняя длина цикла:",
|
||||||
|
value = "${cycleData.averageCycleLength} дней"
|
||||||
|
)
|
||||||
|
|
||||||
|
StatisticRow(
|
||||||
|
label = "Средняя продолжительность менструации:",
|
||||||
|
value = "${cycleData.averagePeriodLength} дней"
|
||||||
|
)
|
||||||
|
|
||||||
|
StatisticRow(
|
||||||
|
label = "Регулярность цикла:",
|
||||||
|
value = "${cycleData.regularityScore}/100"
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Прогнозы
|
||||||
|
cycleData.nextPeriodPredicted?.let { nextPeriod ->
|
||||||
|
Text(
|
||||||
|
text = "Прогноз следующего цикла",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
StatisticRow(
|
||||||
|
label = "Начало следующего цикла:",
|
||||||
|
value = nextPeriod.format(dateFormatter)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Прогноз овуляции
|
||||||
|
cycleData.ovulationDate?.let { ovulationDate ->
|
||||||
|
StatisticRow(
|
||||||
|
label = "Ожидаемая овуляция:",
|
||||||
|
value = ovulationDate.format(dateFormatter)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фертильное окно
|
||||||
|
if (cycleData.fertileWindowStart != null && cycleData.fertileWindowEnd != null) {
|
||||||
|
StatisticRow(
|
||||||
|
label = "Фертильное окно:",
|
||||||
|
value = "${cycleData.fertileWindowStart.format(dateFormatter)} - ${cycleData.fertileWindowEnd.format(dateFormatter)}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Диаграмма цикла
|
||||||
|
CycleGraph(cycleData = cycleData)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Пустое состояние
|
||||||
|
Text(
|
||||||
|
text = "Нет данных о цикле. Добавьте информацию о своем цикле для отображения статистики.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строка со статистическим показателем
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun StatisticRow(label: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Упрощенная диаграмма цикла
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CycleGraph(cycleData: CycleData) {
|
||||||
|
val totalDays = cycleData.cycleLength
|
||||||
|
val periodDays = cycleData.periodLength
|
||||||
|
val ovulationDay = cycleData.ovulationDate?.let {
|
||||||
|
val startDate = cycleData.periodStart ?: cycleData.lastPeriodStartDate
|
||||||
|
if (startDate != null) {
|
||||||
|
it.toEpochDay() - startDate.toEpochDay()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}?.toInt() ?: (totalDays / 2)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = "Текущий цикл",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(24.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
// Отображение фазы менструации
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(((periodDays.toFloat() / totalDays) * 100).dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color(0xFFE57373)) // Красный для менструации
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Отображение овуляции
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.offset(x = ((ovulationDay.toFloat() / totalDays) * 100).dp - 12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF64B5F6)) // Синий для овуляции
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Легенда
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFFE57373))
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Менструация",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(start = 4.dp, end = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF64B5F6))
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Овуляция",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Элемент списка инсайтов
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InsightItem(
|
||||||
|
insight: HealthInsight,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val (icon, color) = when (insight.insightType) {
|
||||||
|
InsightType.CYCLE_REGULARITY -> Pair(
|
||||||
|
Icons.Outlined.CalendarMonth,
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
InsightType.PERIOD_LENGTH -> Pair(
|
||||||
|
Icons.Outlined.Favorite,
|
||||||
|
Color(0xFFE57373)
|
||||||
|
)
|
||||||
|
InsightType.SYMPTOM_PATTERN -> Pair(
|
||||||
|
Icons.Default.Insights,
|
||||||
|
MaterialTheme.colorScheme.tertiary
|
||||||
|
)
|
||||||
|
InsightType.MOOD_CORRELATION -> Pair(
|
||||||
|
Icons.Filled.Mood,
|
||||||
|
Color(0xFF81C784)
|
||||||
|
)
|
||||||
|
InsightType.PERIOD_PREDICTION -> Pair(
|
||||||
|
Icons.Default.DateRange,
|
||||||
|
Color(0xFF64B5F6)
|
||||||
|
)
|
||||||
|
InsightType.HEALTH_TIP -> Pair(
|
||||||
|
Icons.Default.TipsAndUpdates,
|
||||||
|
Color(0xFFFFC107)
|
||||||
|
)
|
||||||
|
InsightType.MEDICATION_REMINDER -> Pair(
|
||||||
|
Icons.Default.Medication,
|
||||||
|
Color(0xFF9C27B0)
|
||||||
|
)
|
||||||
|
InsightType.EXERCISE_SUGGESTION -> Pair(
|
||||||
|
Icons.Default.FitnessCenter,
|
||||||
|
Color(0xFF607D8B)
|
||||||
|
)
|
||||||
|
else -> Pair(Icons.Default.Info, MaterialTheme.colorScheme.outline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок и иконка
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = insight.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Индикатор достоверности
|
||||||
|
ConfidenceBadge(confidence = insight.confidenceLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Описание инсайта
|
||||||
|
Text(
|
||||||
|
text = insight.description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Рекомендация
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Рекомендация:",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = insight.recommendation,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Информация об источниках данных
|
||||||
|
Text(
|
||||||
|
text = "На основе ${insight.dataPointsUsed} записей",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp)
|
||||||
|
.align(Alignment.End)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Кнопка "скрыть"
|
||||||
|
TextButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Text("Скрыть рекомендацию")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индикатор достоверности инсайта
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConfidenceBadge(confidence: ConfidenceLevel) {
|
||||||
|
val (color, text) = when (confidence) {
|
||||||
|
ConfidenceLevel.HIGH -> Pair(
|
||||||
|
Color(0xFF4CAF50),
|
||||||
|
"Высокая точность"
|
||||||
|
)
|
||||||
|
ConfidenceLevel.MEDIUM -> Pair(
|
||||||
|
Color(0xFFFFC107),
|
||||||
|
"Средняя точность"
|
||||||
|
)
|
||||||
|
ConfidenceLevel.LOW -> Pair(
|
||||||
|
Color(0xFFFF5722),
|
||||||
|
"Низкая точность"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(color.copy(alpha = 0.2f))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сообщение при отсутствии инсайтов
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EmptyInsightsMessage() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Insights,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Пока нет персональных рекомендаций",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Продолжайте вести календарь, и мы сможем предоставить вам полезные советы на основе ваших данных",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.example.womansafe.data.model.*
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleData
|
||||||
|
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||||
|
import com.example.womansafe.ui.viewmodel.DayType
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Главный экран менструального календаря
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: CalendarViewModel,
|
||||||
|
onAddEntryClick: (LocalDate) -> Unit,
|
||||||
|
onViewInsightsClick: () -> Unit
|
||||||
|
) {
|
||||||
|
// Наблюдение за состоянием
|
||||||
|
val calendarUiState by viewModel.calendarUiState.observeAsState()
|
||||||
|
val selectedDate by viewModel.selectedDate.observeAsState(LocalDate.now())
|
||||||
|
val isLoading by viewModel.isLoading.observeAsState(false)
|
||||||
|
val errorMessage = calendarUiState?.errorMessage
|
||||||
|
|
||||||
|
// Выбранный месяц для отображения
|
||||||
|
var currentMonth by remember { mutableStateOf(YearMonth.from(LocalDate.now())) }
|
||||||
|
|
||||||
|
// Обработка ошибок
|
||||||
|
LaunchedEffect(errorMessage) {
|
||||||
|
errorMessage?.let {
|
||||||
|
// Здесь можно показать сообщение об ошибке (например, Snackbar)
|
||||||
|
// После отображения ошибки очищаем сообщение
|
||||||
|
viewModel.clearErrorMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок с кнопками навигации по месяцам
|
||||||
|
MonthNavigator(
|
||||||
|
currentMonth = currentMonth,
|
||||||
|
onPreviousMonth = { currentMonth = currentMonth.minusMonths(1) },
|
||||||
|
onNextMonth = { currentMonth = currentMonth.plusMonths(1) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Календарная сетка с днями недели и числами
|
||||||
|
MonthCalendarView(
|
||||||
|
currentMonth = currentMonth,
|
||||||
|
selectedDate = selectedDate,
|
||||||
|
specialDays = calendarUiState?.specialDays ?: emptyMap(),
|
||||||
|
onDateSelected = { date -> viewModel.selectDate(date) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Данные выбранного дня и записи
|
||||||
|
SelectedDayDetails(
|
||||||
|
selectedDate = selectedDate,
|
||||||
|
entries = calendarUiState?.selectedDateEntries ?: emptyList(),
|
||||||
|
cycleData = calendarUiState?.cycleData,
|
||||||
|
onAddEntryClick = { onAddEntryClick(selectedDate) }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Показ инсайтов о здоровье, если они есть
|
||||||
|
calendarUiState?.insights?.takeIf { it.isNotEmpty() }?.let { insights ->
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
InsightsPreview(
|
||||||
|
insights = insights,
|
||||||
|
onViewAllClick = onViewInsightsClick,
|
||||||
|
onDismiss = { viewModel.dismissInsight(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Индикатор загрузки
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Навигация по месяцам календаря
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MonthNavigator(
|
||||||
|
currentMonth: YearMonth,
|
||||||
|
onPreviousMonth: () -> Unit,
|
||||||
|
onNextMonth: () -> Unit
|
||||||
|
) {
|
||||||
|
val monthFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", Locale("ru"))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onPreviousMonth) {
|
||||||
|
Icon(Icons.Default.KeyboardArrowLeft, contentDescription = "Предыдущий месяц")
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = currentMonth.format(monthFormatter).replaceFirstChar {
|
||||||
|
if (it.isLowerCase()) it.titlecase(Locale("ru")) else it.toString()
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(onClick = onNextMonth) {
|
||||||
|
Icon(Icons.Default.KeyboardArrowRight, contentDescription = "Следующий месяц")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Календарная сетка на месяц
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MonthCalendarView(
|
||||||
|
currentMonth: YearMonth,
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
specialDays: Map<LocalDate, DayType>,
|
||||||
|
onDateSelected: (LocalDate) -> Unit
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
// Дни недели (заголовок)
|
||||||
|
DaysOfWeekHeader()
|
||||||
|
|
||||||
|
// Дни месяца
|
||||||
|
val startDate = currentMonth.atDay(1)
|
||||||
|
val endDate = currentMonth.atEndOfMonth()
|
||||||
|
|
||||||
|
// Получаем все даты для отображения (включая дни из предыдущего и следующего месяца для заполнения сетки)
|
||||||
|
val firstDayOfGrid = startDate.minusDays(startDate.dayOfWeek.value.toLong() % 7)
|
||||||
|
val lastDayOfGrid = endDate.plusDays(6 - endDate.dayOfWeek.value.toLong() % 7)
|
||||||
|
|
||||||
|
val daysToShow = mutableListOf<LocalDate>()
|
||||||
|
var currentDate = firstDayOfGrid
|
||||||
|
while (!currentDate.isAfter(lastDayOfGrid)) {
|
||||||
|
daysToShow.add(currentDate)
|
||||||
|
currentDate = currentDate.plusDays(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(7),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(0.dp)
|
||||||
|
) {
|
||||||
|
items(daysToShow) { date ->
|
||||||
|
DayCell(
|
||||||
|
date = date,
|
||||||
|
isSelected = date == selectedDate,
|
||||||
|
isCurrentMonth = date.month == currentMonth.month,
|
||||||
|
dayType = specialDays[date] ?: DayType.NORMAL,
|
||||||
|
onClick = { onDateSelected(date) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заголовок с днями недели
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DaysOfWeekHeader() {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
for (dayOfWeek in DayOfWeek.values()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(4.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = dayOfWeek.getDisplayName(TextStyle.SHORT, Locale("ru")).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ячейка календаря для отдельного дня
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DayCell(
|
||||||
|
date: LocalDate,
|
||||||
|
isSelected: Boolean,
|
||||||
|
isCurrentMonth: Boolean,
|
||||||
|
dayType: DayType,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
// Цвета для разных типов дней
|
||||||
|
val backgroundColor = when {
|
||||||
|
isSelected -> MaterialTheme.colorScheme.primary
|
||||||
|
!isCurrentMonth -> Color.Transparent
|
||||||
|
else -> Color.Transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
val textColor = when {
|
||||||
|
isSelected -> MaterialTheme.colorScheme.onPrimary
|
||||||
|
!isCurrentMonth -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
|
||||||
|
LocalDate.now() == date -> MaterialTheme.colorScheme.primary
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
|
||||||
|
// Индикатор специального дня
|
||||||
|
val indicatorColor = when (dayType) {
|
||||||
|
DayType.PERIOD -> Color(0xFFE57373) // Красный для менструации
|
||||||
|
DayType.OVULATION -> Color(0xFF64B5F6) // Синий для овуляции
|
||||||
|
DayType.FERTILE -> Color(0xFF81C784) // Зеленый для фертильного окна
|
||||||
|
DayType.PREDICTED_PERIOD -> Color(0xFFE57373).copy(alpha = 0.5f) // Полупрозрачный красный для прогноза
|
||||||
|
DayType.NORMAL -> Color.Transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.padding(2.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.border(
|
||||||
|
width = if (isSelected) 0.dp else if (LocalDate.now() == date) 1.dp else 0.dp,
|
||||||
|
color = if (LocalDate.now() == date) MaterialTheme.colorScheme.primary else Color.Transparent,
|
||||||
|
shape = CircleShape
|
||||||
|
)
|
||||||
|
.clickable { onClick() },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date.dayOfMonth.toString(),
|
||||||
|
color = textColor,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = if (LocalDate.now() == date) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
|
||||||
|
if (dayType != DayType.NORMAL) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(4.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(indicatorColor)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Детали выбранного дня и записи
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SelectedDayDetails(
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
entries: List<CalendarEntry>,
|
||||||
|
cycleData: CycleData?,
|
||||||
|
onAddEntryClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val dateFormatter = DateTimeFormatter.ofPattern("d MMMM", Locale("ru"))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок выбранного дня
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = selectedDate.format(dateFormatter),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(onClick = onAddEntryClick) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Добавить запись")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Информация о специальном дне
|
||||||
|
cycleData?.let {
|
||||||
|
when {
|
||||||
|
selectedDate == it.cycleStartDate -> {
|
||||||
|
SpecialDayInfo(
|
||||||
|
title = "Начало цикла",
|
||||||
|
description = "Сегодня первый день вашего менструального цикла.",
|
||||||
|
color = Color(0xFFE57373)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selectedDate == it.ovulationDate -> {
|
||||||
|
SpecialDayInfo(
|
||||||
|
title = "День овуляции",
|
||||||
|
description = "Сегодня ваш день овуляции - наивысшая вероятность зачатия.",
|
||||||
|
color = Color(0xFF64B5F6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
it.fertileWindowStart?.let { start ->
|
||||||
|
it.fertileWindowEnd?.let { end ->
|
||||||
|
selectedDate in start..end
|
||||||
|
}
|
||||||
|
} == true -> {
|
||||||
|
SpecialDayInfo(
|
||||||
|
title = "Фертильное окно",
|
||||||
|
description = "Вы находитесь в фертильном периоде с повышенной вероятностью зачатия.",
|
||||||
|
color = Color(0xFF81C784)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Список записей для выбранного дня
|
||||||
|
if (entries.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Записи",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 200.dp)
|
||||||
|
) {
|
||||||
|
items(entries) { entry ->
|
||||||
|
EntryItem(entry = entry)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Пустое состояние
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Нет записей на выбранную дату",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Информация о специальном дне (менструация, овуляция и т.д.)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SpecialDayInfo(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
color: Color
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = color.copy(alpha = 0.15f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Элемент записи для выбранного дня
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EntryItem(entry: CalendarEntry) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок с типом записи
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
val (icon, title) = when (entry.entryType) {
|
||||||
|
com.example.womansafe.data.model.calendar.EntryType.PERIOD -> Pair(
|
||||||
|
Icons.Default.Favorite,
|
||||||
|
"Менструация: ${entry.flowIntensity?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: ""}"
|
||||||
|
)
|
||||||
|
com.example.womansafe.data.model.calendar.EntryType.OVULATION -> Pair(Icons.Default.Star, "Овуляция")
|
||||||
|
com.example.womansafe.data.model.calendar.EntryType.SYMPTOMS -> Pair(Icons.Default.Warning, "Симптомы")
|
||||||
|
com.example.womansafe.data.model.calendar.EntryType.MEDICATION -> Pair(Icons.Default.Healing, "Медикаменты")
|
||||||
|
com.example.womansafe.data.model.calendar.EntryType.NOTE -> Pair(Icons.Default.Info, "Заметка")
|
||||||
|
com.example.womansafe.data.model.calendar.EntryType.APPOINTMENT -> Pair(Icons.Default.Event, "Приём врача")
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Детали записи
|
||||||
|
entry.symptoms?.takeIf { it.isNotEmpty() }?.let { symptoms ->
|
||||||
|
EntryDetailRow(title = "Симптомы:", content = symptoms.joinToString(", ") {
|
||||||
|
it.name.replace("_", " ").lowercase().replaceFirstChar { c -> c.uppercase() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.mood?.let { mood ->
|
||||||
|
EntryDetailRow(title = "Настроение:", content = mood.name.lowercase().replaceFirstChar { it.uppercase() })
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.energyLevel?.let { energy ->
|
||||||
|
EntryDetailRow(title = "Энергия:", content = "$energy из 5")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.sleepHours?.let { sleep ->
|
||||||
|
EntryDetailRow(title = "Сон:", content = "$sleep часов")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.medications?.takeIf { it.isNotEmpty() }?.let { meds ->
|
||||||
|
EntryDetailRow(title = "Лекарства:", content = meds.joinToString(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.notes?.takeIf { it.isNotEmpty() }?.let { notes ->
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = notes,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строка с деталями записи
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EntryDetailRow(title: String, content: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.width(90.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Предварительный просмотр инсайтов о здоровье
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InsightsPreview(
|
||||||
|
insights: List<HealthInsight>,
|
||||||
|
onViewAllClick: () -> Unit,
|
||||||
|
onDismiss: (Long) -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Ваши инсайты",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
TextButton(onClick = onViewAllClick) {
|
||||||
|
Text(text = "Все")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Инсайты в прокручиваемом ряду
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
items(insights.take(3)) { insight ->
|
||||||
|
InsightCard(
|
||||||
|
insight = insight,
|
||||||
|
onDismiss = { onDismiss(insight.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка с инсайтом о здоровье
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InsightCard(
|
||||||
|
insight: HealthInsight,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.width(280.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок и уровень достоверности
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = insight.title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
val confidenceColor = when (insight.confidenceLevel) {
|
||||||
|
com.example.womansafe.data.model.calendar.ConfidenceLevel.HIGH -> Color(0xFF4CAF50)
|
||||||
|
com.example.womansafe.data.model.calendar.ConfidenceLevel.MEDIUM -> Color(0xFFFFC107)
|
||||||
|
com.example.womansafe.data.model.calendar.ConfidenceLevel.LOW -> Color(0xFFFF5722)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(confidenceColor.copy(alpha = 0.2f))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when (insight.confidenceLevel) {
|
||||||
|
com.example.womansafe.data.model.calendar.ConfidenceLevel.HIGH -> "Высокая точность"
|
||||||
|
com.example.womansafe.data.model.calendar.ConfidenceLevel.MEDIUM -> "Средняя точность"
|
||||||
|
com.example.womansafe.data.model.calendar.ConfidenceLevel.LOW -> "Низкая точность"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = confidenceColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Описание инсайта
|
||||||
|
Text(
|
||||||
|
text = insight.description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.heightIn(min = 60.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Рекомендация
|
||||||
|
Text(
|
||||||
|
text = "Рекомендация:",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = insight.recommendation,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Кнопка отклонения инсайта
|
||||||
|
TextButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Text(text = "Скрыть")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.rememberAsyncImagePainter
|
||||||
|
import com.example.womansafe.util.ContactsHelper
|
||||||
|
import com.example.womansafe.util.PermissionManager
|
||||||
|
import com.example.womansafe.util.RequestPermissions
|
||||||
|
import com.example.womansafe.util.fixTouchEvents
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactPickerScreen(
|
||||||
|
onContactSelected: (ContactsHelper.Contact, String) -> Unit,
|
||||||
|
onBackPressed: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val contactsHelper = remember { ContactsHelper(context) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var contacts by remember { mutableStateOf<List<ContactsHelper.Contact>>(emptyList()) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
var showRelationshipDialog by remember { mutableStateOf<ContactsHelper.Contact?>(null) }
|
||||||
|
|
||||||
|
// Фильтрованные контакты на основе поискового запроса
|
||||||
|
val filteredContacts = remember(contacts, searchQuery) {
|
||||||
|
if (searchQuery.isBlank()) contacts
|
||||||
|
else contacts.filter {
|
||||||
|
it.name.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.phoneNumber.contains(searchQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрос разрешения на чтение контактов
|
||||||
|
RequestPermissions(
|
||||||
|
permissions = listOf(PermissionManager.PERMISSION_CONTACTS),
|
||||||
|
onAllPermissionsGranted = {
|
||||||
|
// Загружаем контакты после получения разрешения
|
||||||
|
coroutineScope.launch {
|
||||||
|
isLoading = true
|
||||||
|
contacts = contactsHelper.getContacts()
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPermissionDenied = { deniedPermissions ->
|
||||||
|
// Показываем сообщение, если пользователь отклонил разрешение
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Выбрать контакт") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBackPressed) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Назад")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
// Строка поиска
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = { searchQuery = it },
|
||||||
|
placeholder = { Text("Поиск") },
|
||||||
|
singleLine = true,
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.7f)
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
if (isLoading) {
|
||||||
|
// Показываем индикатор загрузки
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (contacts.isEmpty()) {
|
||||||
|
// Показываем сообщение, если контакты не найдены
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("Контакты не найдены или нет разрешения на доступ к контактам")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Показываем список контактов
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.fixTouchEvents()
|
||||||
|
) {
|
||||||
|
items(filteredContacts) { contact ->
|
||||||
|
ContactItem(
|
||||||
|
contact = contact,
|
||||||
|
onClick = { showRelationshipDialog = contact }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог выбора отношения к контакту
|
||||||
|
showRelationshipDialog?.let { contact ->
|
||||||
|
var relationship by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showRelationshipDialog = null },
|
||||||
|
title = { Text("Укажите отношение") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Контакт: ${contact.name}")
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = relationship,
|
||||||
|
onValueChange = { relationship = it },
|
||||||
|
label = { Text("Отношение (например: Мама, Папа, Друг)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onContactSelected(contact, relationship)
|
||||||
|
showRelationshipDialog = null
|
||||||
|
},
|
||||||
|
enabled = relationship.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text("Добавить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showRelationshipDialog = null }) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactItem(
|
||||||
|
contact: ContactsHelper.Contact,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Аватар контакта
|
||||||
|
val painter = if (contact.photoUri != null) {
|
||||||
|
rememberAsyncImagePainter(contact.photoUri)
|
||||||
|
} else {
|
||||||
|
rememberVectorPainter(Icons.Default.Person)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(
|
||||||
|
painter = painter,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = contact.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = contact.phoneNumber,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
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.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.womansafe.data.model.EmergencyContactCreate
|
||||||
|
import com.example.womansafe.data.model.EmergencyContactResponse
|
||||||
|
import com.example.womansafe.ui.viewmodel.EmergencyContactsViewModel
|
||||||
|
import com.example.womansafe.util.ContactsHelper
|
||||||
|
import com.example.womansafe.util.fixTouchEvents
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EmergencyContactsScreen(
|
||||||
|
emergencyContactsViewModel: EmergencyContactsViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onNavigateToContactPicker: () -> Unit
|
||||||
|
) {
|
||||||
|
val uiState = emergencyContactsViewModel.uiState
|
||||||
|
var showAddDialog by remember { mutableStateOf(false) }
|
||||||
|
var showContactPickerDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
emergencyContactsViewModel.loadContacts()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок и кнопка добавления
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Экстренные контакты",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { showContactPickerDialog = true },
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Add,
|
||||||
|
contentDescription = "Добавить контакт"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Информационная карточка
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Info,
|
||||||
|
contentDescription = "Информация",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "При активации экстренной кнопки уведомления будут отправлены всем контактам из этого списка",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (uiState.contacts.isEmpty()) {
|
||||||
|
EmptyContactsCard(onAddContact = { showContactPickerDialog = true })
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
items(uiState.contacts) { contact ->
|
||||||
|
ContactCard(
|
||||||
|
contact = contact,
|
||||||
|
onEdit = { emergencyContactsViewModel.editContact(contact) },
|
||||||
|
onDelete = { emergencyContactsViewModel.deleteContact(contact.id) },
|
||||||
|
onCall = { emergencyContactsViewModel.callContact(contact.phone_number) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог добавления контакта
|
||||||
|
if (showAddDialog) {
|
||||||
|
AddContactDialog(
|
||||||
|
onDismiss = { showAddDialog = false },
|
||||||
|
onConfirm = { contactData ->
|
||||||
|
emergencyContactsViewModel.addContact(contactData)
|
||||||
|
showAddDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог выбора контакта
|
||||||
|
if (showContactPickerDialog) {
|
||||||
|
ContactPickerDialog(
|
||||||
|
onDismiss = { showContactPickerDialog = false },
|
||||||
|
onContactSelected = { contact ->
|
||||||
|
emergencyContactsViewModel.addContact(contact)
|
||||||
|
showContactPickerDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка ошибок
|
||||||
|
uiState.error?.let { error ->
|
||||||
|
LaunchedEffect(error) {
|
||||||
|
// Показать snackbar с ошибкой
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyContactsCard(onAddContact: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Person,
|
||||||
|
contentDescription = "Нет контактов",
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Нет экстренных контактов",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Добавьте контакты людей, которых нужно уведомить в экстренной ситуации",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(onClick = onAddContact) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Add,
|
||||||
|
contentDescription = "Добавить"
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Добавить контакт")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactCard(
|
||||||
|
contact: EmergencyContactResponse,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onCall: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = contact.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = contact.phone_number,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
contact.relationship?.let { rel ->
|
||||||
|
if (rel.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = rel,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Меню действий
|
||||||
|
Row {
|
||||||
|
IconButton(onClick = onCall) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Phone,
|
||||||
|
contentDescription = "Позвонить",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onEdit) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Edit,
|
||||||
|
contentDescription = "Редактировать"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Delete,
|
||||||
|
contentDescription = "Удалить",
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Индикатор основного контакта
|
||||||
|
// TODO: Исправить после добавления аннотаций сериализации в модель
|
||||||
|
// if (contact.is_primary == true) {
|
||||||
|
// Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
// Card(
|
||||||
|
// colors = CardDefaults.cardColors(
|
||||||
|
// containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
// )
|
||||||
|
// ) {
|
||||||
|
// Row(
|
||||||
|
// modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
// verticalAlignment = Alignment.CenterVertically
|
||||||
|
// ) {
|
||||||
|
// Icon(
|
||||||
|
// imageVector = Icons.Filled.Star,
|
||||||
|
// contentDescription = "Основной",
|
||||||
|
// modifier = Modifier.size(16.dp),
|
||||||
|
// tint = MaterialTheme.colorScheme.secondary
|
||||||
|
// )
|
||||||
|
// Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
// Text(
|
||||||
|
// text = "Основной контакт",
|
||||||
|
// style = MaterialTheme.typography.bodySmall,
|
||||||
|
// color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddContactDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (EmergencyContactCreate) -> Unit
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var phone by remember { mutableStateOf("") }
|
||||||
|
var relationship by remember { mutableStateOf("") }
|
||||||
|
var isPrimary by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Добавить контакт") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Имя") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = phone,
|
||||||
|
onValueChange = { phone = it },
|
||||||
|
label = { Text("Телефон") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = relationship,
|
||||||
|
onValueChange = { relationship = it },
|
||||||
|
label = { Text("Отношение (необязательно)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = isPrimary,
|
||||||
|
onCheckedChange = { isPrimary = it }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Основной контакт",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (name.isNotBlank() && phone.isNotBlank()) {
|
||||||
|
val contactCreate = EmergencyContactCreate(
|
||||||
|
name = name.trim(),
|
||||||
|
phone_number = phone.trim(),
|
||||||
|
relationship = if (relationship.isNotBlank()) relationship else null
|
||||||
|
// TODO: Добавить is_primary после исправления модели данных
|
||||||
|
)
|
||||||
|
onConfirm(contactCreate)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = name.isNotBlank() && phone.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text("Добавить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactPickerDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onContactSelected: (EmergencyContactCreate) -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showContactPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showContactPicker) {
|
||||||
|
// Используем полноэкранный контактный пикер
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { onDismiss() },
|
||||||
|
title = { Text("Переход к выбору контактов") },
|
||||||
|
text = {
|
||||||
|
Text("Вы будете перенаправлены на экран выбора контактов из телефонной книги.")
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
showContactPicker = false
|
||||||
|
onDismiss()
|
||||||
|
// Здесь будет логика перехода на ContactPickerScreen
|
||||||
|
}) {
|
||||||
|
Text("Понятно")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Выберите способ добавления") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { showContactPicker = true },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Contacts,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Выбрать из контактов")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onDismiss()
|
||||||
|
// Показываем диалог ручного добавления
|
||||||
|
// (используем существующий диалог добавления контакта)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Добавить вручную")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { },
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
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.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.example.womansafe.data.model.EmergencyContactResponse
|
||||||
|
import com.example.womansafe.data.model.EmergencyType
|
||||||
|
import com.example.womansafe.ui.viewmodel.EmergencyViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EmergencyScreen(
|
||||||
|
emergencyViewModel: EmergencyViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val uiState by emergencyViewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
// Запуск анимации для экстренной кнопки
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "emergency_pulse")
|
||||||
|
val scale by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = if (uiState.isEmergencyActive) 1.1f else 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "emergency_scale"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Запрос разрешений на местоположение
|
||||||
|
val locationPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { permissions ->
|
||||||
|
val granted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
|
||||||
|
permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
|
||||||
|
emergencyViewModel.onLocationPermissionResult(granted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация клиента местоположения
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
emergencyViewModel.initLocationClient(context)
|
||||||
|
emergencyViewModel.loadEmergencyContacts()
|
||||||
|
|
||||||
|
// Запрашиваем разрешения если их нет
|
||||||
|
if (!uiState.hasLocationPermission) {
|
||||||
|
locationPermissionLauncher.launch(
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог выбора типа экстренной ситуации
|
||||||
|
var showEmergencyTypeDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
MaterialTheme.colorScheme.background,
|
||||||
|
MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Заголовок
|
||||||
|
Text(
|
||||||
|
text = "Экстренная ситуация",
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(bottom = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Статус местоположения
|
||||||
|
LocationStatusCard(
|
||||||
|
hasPermission = uiState.hasLocationPermission,
|
||||||
|
currentLocation = uiState.currentLocation?.address,
|
||||||
|
onRequestPermission = {
|
||||||
|
locationPermissionLauncher.launch(
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Главная кнопка экстренной ситуации
|
||||||
|
EmergencyButton(
|
||||||
|
isActive = uiState.isEmergencyActive,
|
||||||
|
isLoading = uiState.isLoading,
|
||||||
|
scale = scale,
|
||||||
|
onClick = {
|
||||||
|
if (uiState.isEmergencyActive) {
|
||||||
|
emergencyViewModel.cancelEmergencyAlert()
|
||||||
|
} else {
|
||||||
|
showEmergencyTypeDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Экстренные контакты
|
||||||
|
EmergencyContactsSection(
|
||||||
|
contacts = uiState.emergencyContacts,
|
||||||
|
context = context
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сообщение об ошибке
|
||||||
|
uiState.errorMessage?.let { error ->
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог выбора типа экстренной ситуации
|
||||||
|
if (showEmergencyTypeDialog) {
|
||||||
|
EmergencyTypeDialog(
|
||||||
|
onDismiss = { showEmergencyTypeDialog = false },
|
||||||
|
onTypeSelected = { type ->
|
||||||
|
emergencyViewModel.createEmergencyAlert(
|
||||||
|
context = context,
|
||||||
|
type = type
|
||||||
|
)
|
||||||
|
showEmergencyTypeDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LocationStatusCard(
|
||||||
|
hasPermission: Boolean,
|
||||||
|
currentLocation: String?,
|
||||||
|
onRequestPermission: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (hasPermission)
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (hasPermission) Icons.Filled.LocationOn else Icons.Filled.Clear,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (hasPermission)
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = if (hasPermission) "Местоположение определено" else "Нет доступа к местоположению",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = if (hasPermission)
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
if (hasPermission && currentLocation != null) {
|
||||||
|
Text(
|
||||||
|
text = currentLocation,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasPermission) {
|
||||||
|
TextButton(onClick = onRequestPermission) {
|
||||||
|
Text("Разрешить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmergencyButton(
|
||||||
|
isActive: Boolean,
|
||||||
|
isLoading: Boolean,
|
||||||
|
scale: Float,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(200.dp)
|
||||||
|
.scale(scale),
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (isActive)
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
else
|
||||||
|
Color.Red
|
||||||
|
),
|
||||||
|
enabled = !isLoading
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = MaterialTheme.colorScheme.onError,
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isActive) Icons.Filled.Close else Icons.Filled.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = if (isActive) "ОТМЕНИТЬ" else "SOS",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmergencyContactsSection(
|
||||||
|
contacts: List<EmergencyContactResponse>,
|
||||||
|
context: Context
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Экстренные контакты",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contacts.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Добавьте экстренные контакты в настройках",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn {
|
||||||
|
items(contacts) { contact ->
|
||||||
|
EmergencyContactItem(
|
||||||
|
contact = contact,
|
||||||
|
onCallClick = {
|
||||||
|
val intent = Intent(Intent.ACTION_CALL).apply {
|
||||||
|
data = Uri.parse("tel:${contact.phone_number}")
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (contact != contacts.last()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmergencyContactItem(
|
||||||
|
contact: EmergencyContactResponse,
|
||||||
|
onCallClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = contact.name,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = contact.phone_number,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
contact.relationship?.let { relationship ->
|
||||||
|
Text(
|
||||||
|
text = relationship,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = onCallClick,
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Phone,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Вызов")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun EmergencyTypeDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onTypeSelected: (EmergencyType) -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Выберите тип экстренной ситуации",
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
LazyColumn {
|
||||||
|
items(EmergencyType.values()) { type ->
|
||||||
|
val (title, icon) = when (type) {
|
||||||
|
EmergencyType.HARASSMENT -> "Домогательства" to Icons.Filled.Warning
|
||||||
|
EmergencyType.ASSAULT -> "Нападение" to Icons.Filled.Warning
|
||||||
|
EmergencyType.STALKING -> "Преследование" to Icons.Filled.Search
|
||||||
|
EmergencyType.DOMESTIC_VIOLENCE -> "Домашнее насилие" to Icons.Filled.Home
|
||||||
|
EmergencyType.UNSAFE_AREA -> "Небезопасная зона" to Icons.Filled.LocationOn
|
||||||
|
EmergencyType.MEDICAL -> "Медицинская помощь" to Icons.Filled.Favorite
|
||||||
|
EmergencyType.OTHER -> "Другое" to Icons.Filled.MoreVert
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = { onTypeSelected(type) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
561
app/src/main/java/com/example/womansafe/ui/screens/HomeScreen.kt
Normal file
561
app/src/main/java/com/example/womansafe/ui/screens/HomeScreen.kt
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.womansafe.ui.viewmodel.AuthViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
authViewModel: AuthViewModel,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val uiState = authViewModel.uiState
|
||||||
|
var showLocationDialog by remember { mutableStateOf(false) }
|
||||||
|
var showEmergencyDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Приветствие пользователя
|
||||||
|
WelcomeCard(userName = uiState.user?.username ?: "Пользователь")
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Быстрые действия
|
||||||
|
QuickActionsSection(
|
||||||
|
onShareLocation = { showLocationDialog = true },
|
||||||
|
onCallHelp = { /* TODO: Call emergency services */ },
|
||||||
|
onSendSignal = { showEmergencyDialog = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Статус безопасности
|
||||||
|
SafetyStatusCard(uiState)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Экстренные контакты
|
||||||
|
EmergencyContactsCard(
|
||||||
|
contacts = uiState.emergencyContacts ?: emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Последняя активность
|
||||||
|
RecentActivityCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Календарь - краткий обзор
|
||||||
|
CalendarOverviewCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог отправки местоположения
|
||||||
|
if (showLocationDialog) {
|
||||||
|
LocationSharingDialog(
|
||||||
|
onDismiss = { showLocationDialog = false },
|
||||||
|
onConfirm = { contacts ->
|
||||||
|
// TODO: Отправить местоположение выбранным контактам
|
||||||
|
showLocationDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог экстренного сигнала
|
||||||
|
if (showEmergencyDialog) {
|
||||||
|
EmergencySignalDialog(
|
||||||
|
onDismiss = { showEmergencyDialog = false },
|
||||||
|
onConfirm = { message ->
|
||||||
|
// TODO: Отправить экстренный сигнал
|
||||||
|
showEmergencyDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WelcomeCard(userName: String) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Привет, $userName!",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Ваша безопасность - наш приоритет",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickActionsSection(
|
||||||
|
onShareLocation: () -> Unit,
|
||||||
|
onCallHelp: () -> Unit,
|
||||||
|
onSendSignal: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Быстрые действия",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
QuickActionButton(
|
||||||
|
icon = Icons.Filled.LocationOn,
|
||||||
|
text = "Поделиться местоположением",
|
||||||
|
onClick = onShareLocation
|
||||||
|
)
|
||||||
|
QuickActionButton(
|
||||||
|
icon = Icons.Filled.Phone,
|
||||||
|
text = "Вызвать помощь",
|
||||||
|
onClick = onCallHelp
|
||||||
|
)
|
||||||
|
QuickActionButton(
|
||||||
|
icon = Icons.Filled.Notifications,
|
||||||
|
text = "Отправить сигнал",
|
||||||
|
onClick = onSendSignal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickActionButton(
|
||||||
|
icon: ImageVector,
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.width(80.dp)
|
||||||
|
) {
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.size(56.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = text,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SafetyStatusCard(uiState: Any) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(20.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = "Статус безопасности",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Статус: Безопасно",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Все системы работают нормально",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmergencyContactsCard(contacts: List<Any>) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Экстренные контакты",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
TextButton(onClick = { /* TODO: Navigate to contacts */ }) {
|
||||||
|
Text("Управление")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
if (contacts.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Добавьте экстренные контакты для быстрого доступа",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Настроено контактов: ${contacts.size}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentActivityCard() {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Последняя активность",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Placeholder для активности
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.DateRange,
|
||||||
|
contentDescription = "История",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Нет недавней активности",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CalendarOverviewCard() {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Календарь - краткий обзор",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Placeholder для календаря
|
||||||
|
Text(
|
||||||
|
text = "Нет предстоящих событий",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LocationSharingDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (List<String>) -> Unit
|
||||||
|
) {
|
||||||
|
var selectedContacts by remember { mutableStateOf(setOf<String>()) }
|
||||||
|
var customMessage by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Поделиться местоположением") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Ваше текущее местоположение будет отправлено выбранным контактам",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
// Список контактов (заглушка)
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Экстренные контакты:",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
listOf("Мама", "Служба безопасности", "Врач").forEach { contact ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = selectedContacts.contains(contact),
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
selectedContacts = if (checked) {
|
||||||
|
selectedContacts + contact
|
||||||
|
} else {
|
||||||
|
selectedContacts - contact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = contact,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = customMessage,
|
||||||
|
onValueChange = { customMessage = it },
|
||||||
|
label = { Text("Дополнительное сообщение (необязательно)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onConfirm(selectedContacts.toList()) },
|
||||||
|
enabled = selectedContacts.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Text("Отправить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun EmergencySignalDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var emergencyType by remember { mutableStateOf("Общая тревога") }
|
||||||
|
var customMessage by remember { mutableStateOf("") }
|
||||||
|
var includeLocation by remember { mutableStateOf(true) }
|
||||||
|
var includePhoto by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val emergencyTypes = listOf("Общая тревога", "Медицинская помощь", "Преследование", "ДТП", "Другое")
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Экстренный сигнал") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Сигнал будет отправлен всем вашим экстренным контактам",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тип экстренной ситуации
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = !expanded }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = emergencyType,
|
||||||
|
onValueChange = { },
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Тип ситуации") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor()
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
emergencyTypes.forEach { type ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(type) },
|
||||||
|
onClick = {
|
||||||
|
emergencyType = type
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = customMessage,
|
||||||
|
onValueChange = { customMessage = it },
|
||||||
|
label = { Text("Дополнительная информация") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3,
|
||||||
|
placeholder = { Text("Опишите ситуацию...") }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Дополнительные опции
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = includeLocation,
|
||||||
|
onCheckedChange = { includeLocation = it }
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Включить местоположение",
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = includePhoto,
|
||||||
|
onCheckedChange = { includePhoto = it }
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Сделать фото с камеры",
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val message = buildString {
|
||||||
|
append("ЭКСТРЕННЫЙ СИГНАЛ: $emergencyType")
|
||||||
|
if (customMessage.isNotBlank()) {
|
||||||
|
append("\nДетали: $customMessage")
|
||||||
|
}
|
||||||
|
if (includeLocation) {
|
||||||
|
append("\nМестоположение: будет приложено")
|
||||||
|
}
|
||||||
|
if (includePhoto) {
|
||||||
|
append("\nФото: будет приложено")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onConfirm(message)
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("ОТПРАВИТЬ SOS")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
144
app/src/main/java/com/example/womansafe/ui/screens/MainScreen.kt
Normal file
144
app/src/main/java/com/example/womansafe/ui/screens/MainScreen.kt
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.example.womansafe.ui.navigation.BottomNavItem
|
||||||
|
import com.example.womansafe.ui.navigation.BottomNavigationBar
|
||||||
|
import com.example.womansafe.ui.viewmodel.AuthViewModel
|
||||||
|
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||||
|
import com.example.womansafe.ui.viewmodel.EmergencyContactsViewModel
|
||||||
|
import com.example.womansafe.ui.viewmodel.EmergencyViewModel
|
||||||
|
import com.example.womansafe.ui.viewmodel.ProfileSettingsViewModel
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
authViewModel: AuthViewModel,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text("WomanSafe")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
BottomNavigationBar(navController = navController)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
MainNavHost(
|
||||||
|
navController = navController,
|
||||||
|
authViewModel = authViewModel,
|
||||||
|
modifier = Modifier.padding(paddingValues)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MainNavHost(
|
||||||
|
navController: NavHostController,
|
||||||
|
authViewModel: AuthViewModel,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
// Создаем ViewModel для календаря здесь для общего доступа
|
||||||
|
val calendarViewModel: CalendarViewModel = viewModel()
|
||||||
|
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = BottomNavItem.Home.route,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
composable(BottomNavItem.Home.route) {
|
||||||
|
HomeScreen(authViewModel = authViewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(BottomNavItem.Emergency.route) {
|
||||||
|
EmergencyScreen(emergencyViewModel = EmergencyViewModel())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заменяем прямой вызов CalendarScreen на вложенную навигацию
|
||||||
|
composable(BottomNavItem.Calendar.route) {
|
||||||
|
CalendarNavigation(
|
||||||
|
calendarViewModel = calendarViewModel,
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(BottomNavItem.Profile.route) {
|
||||||
|
ProfileScreen(
|
||||||
|
authViewModel = authViewModel,
|
||||||
|
onNavigateToContacts = { navController.navigate("emergency_contacts") },
|
||||||
|
onNavigateToSettings = { navController.navigate("profile_settings") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дополнительные экраны
|
||||||
|
composable("emergency_contacts") {
|
||||||
|
EmergencyContactsScreen(
|
||||||
|
emergencyContactsViewModel = EmergencyContactsViewModel(),
|
||||||
|
onNavigateToContactPicker = { navController.navigate("contact_picker") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable("profile_settings") {
|
||||||
|
ProfileSettingsScreen(
|
||||||
|
profileSettingsViewModel = ProfileSettingsViewModel(),
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экраны календаря
|
||||||
|
composable("calendar_entry/{date}") { backStackEntry ->
|
||||||
|
val dateString = backStackEntry.arguments?.getString("date") ?: LocalDate.now().toString()
|
||||||
|
val date = LocalDate.parse(dateString)
|
||||||
|
|
||||||
|
CalendarEntryScreen(
|
||||||
|
viewModel = calendarViewModel,
|
||||||
|
selectedDate = date,
|
||||||
|
onClose = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable("calendar_insights") {
|
||||||
|
CalendarInsightsScreen(
|
||||||
|
viewModel = calendarViewModel,
|
||||||
|
onBackClick = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вложенная навигация для календаря
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarNavigation(
|
||||||
|
calendarViewModel: CalendarViewModel,
|
||||||
|
navController: NavHostController
|
||||||
|
) {
|
||||||
|
val selectedDate by calendarViewModel.selectedDate.observeAsState(LocalDate.now())
|
||||||
|
|
||||||
|
CalendarScreen(
|
||||||
|
viewModel = calendarViewModel,
|
||||||
|
onAddEntryClick = { date ->
|
||||||
|
navController.navigate("calendar_entry/${date}")
|
||||||
|
},
|
||||||
|
onViewInsightsClick = {
|
||||||
|
navController.navigate("calendar_insights")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.womansafe.ui.viewmodel.AuthViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileScreen(
|
||||||
|
authViewModel: AuthViewModel,
|
||||||
|
onNavigateToContacts: (() -> Unit)? = null,
|
||||||
|
onNavigateToSettings: (() -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val uiState = authViewModel.uiState
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Профиль пользователя
|
||||||
|
UserProfileCard(
|
||||||
|
userName = uiState.user?.username ?: "Пользователь",
|
||||||
|
userEmail = uiState.user?.email ?: "",
|
||||||
|
userPhone = uiState.user?.phone,
|
||||||
|
onEditProfile = { onNavigateToSettings?.invoke() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Настройки безопасности
|
||||||
|
SecuritySettingsSection()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Экстренные контакты
|
||||||
|
EmergencyContactsSection(
|
||||||
|
contactsCount = uiState.emergencyContacts?.size ?: 0,
|
||||||
|
onManageContacts = { onNavigateToContacts?.invoke() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Приватность и уведомления
|
||||||
|
PrivacyAndNotificationsSection(uiState)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Дополнительные настройки
|
||||||
|
AdditionalSettingsSection()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
// Кнопка выхода
|
||||||
|
LogoutButton(onLogout = { authViewModel.logout() })
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UserProfileCard(
|
||||||
|
userName: String,
|
||||||
|
userEmail: String,
|
||||||
|
userPhone: String?,
|
||||||
|
onEditProfile: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Аватар пользователя
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(80.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Person,
|
||||||
|
contentDescription = "Аватар",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = userName,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
if (userEmail.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = userEmail,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
userPhone?.let { phone ->
|
||||||
|
Text(
|
||||||
|
text = phone,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onEditProfile
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Edit,
|
||||||
|
contentDescription = "Редактировать",
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Редактировать профиль")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SecuritySettingsSection() {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Настройки безопасности",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
SettingItem(
|
||||||
|
icon = Icons.Filled.LocationOn,
|
||||||
|
title = "Отслеживание местоположения",
|
||||||
|
subtitle = "Разрешить приложению отслеживать ваше местоположение",
|
||||||
|
hasSwitch = true,
|
||||||
|
switchState = true,
|
||||||
|
onSwitchChange = { /* TODO */ }
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingItem(
|
||||||
|
icon = Icons.Filled.Lock,
|
||||||
|
title = "Сменить пароль",
|
||||||
|
subtitle = "Обновить пароль для входа в приложение",
|
||||||
|
onClick = { /* TODO */ }
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingItem(
|
||||||
|
icon = Icons.Filled.Lock,
|
||||||
|
title = "Двухфакторная аутентификация",
|
||||||
|
subtitle = "Дополнительная защита вашего аккаунта",
|
||||||
|
onClick = { /* TODO */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmergencyContactsSection(contactsCount: Int, onManageContacts: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Экстренные контакты",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Настроено: $contactsCount",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onManageContacts) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowForward,
|
||||||
|
contentDescription = "Управление контактами"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PrivacyAndNotificationsSection(uiState: Any) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Приватность и уведомления",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
SettingItem(
|
||||||
|
icon = Icons.Filled.Notifications,
|
||||||
|
title = "Push-уведомления",
|
||||||
|
subtitle = "Получать уведомления о важных событиях",
|
||||||
|
hasSwitch = true,
|
||||||
|
switchState = true,
|
||||||
|
onSwitchChange = { /* TODO */ }
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingItem(
|
||||||
|
icon = Icons.Filled.Email,
|
||||||
|
title = "Email-уведомления",
|
||||||
|
subtitle = "Получать уведомления на электронную почту",
|
||||||
|
hasSwitch = true,
|
||||||
|
switchState = false,
|
||||||
|
onSwitchChange = { /* TODO */ }
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingItem(
|
||||||
|
icon = Icons.Filled.Lock,
|
||||||
|
title = "Приватность данных",
|
||||||
|
subtitle = "Управление видимостью личной информации",
|
||||||
|
onClick = { /* TODO */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AdditionalSettingsSection() {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Дополнительно",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
SettingItem(
|
||||||
|
icon = Icons.Filled.Info,
|
||||||
|
title = "Справка и поддержка",
|
||||||
|
subtitle = "Получить помощь по использованию приложения",
|
||||||
|
onClick = { /* TODO */ }
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingItem(
|
||||||
|
icon = Icons.Filled.Info,
|
||||||
|
title = "О приложении",
|
||||||
|
subtitle = "Информация о версии и разработчиках",
|
||||||
|
onClick = { /* TODO */ }
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingItem(
|
||||||
|
icon = Icons.Filled.Info,
|
||||||
|
title = "Политика конфиденциальности",
|
||||||
|
subtitle = "Ознакомиться с правилами обработки данных",
|
||||||
|
onClick = { /* TODO */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
hasSwitch: Boolean = false,
|
||||||
|
switchState: Boolean = false,
|
||||||
|
onSwitchChange: ((Boolean) -> Unit)? = null,
|
||||||
|
onClick: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val itemModifier = if (onClick != null && !hasSwitch) {
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
} else {
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = itemModifier.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = title,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSwitch && onSwitchChange != null) {
|
||||||
|
Switch(
|
||||||
|
checked = switchState,
|
||||||
|
onCheckedChange = onSwitchChange
|
||||||
|
)
|
||||||
|
} else if (onClick != null) {
|
||||||
|
IconButton(onClick = onClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowForward,
|
||||||
|
contentDescription = "Открыть"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LogoutButton(onLogout: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onLogout,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ExitToApp,
|
||||||
|
contentDescription = "Выйти",
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Выйти из аккаунта",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.womansafe.data.model.UserUpdate
|
||||||
|
import com.example.womansafe.ui.viewmodel.ProfileSettingsViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ProfileSettingsScreen(
|
||||||
|
profileSettingsViewModel: ProfileSettingsViewModel,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val uiState = profileSettingsViewModel.uiState
|
||||||
|
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
profileSettingsViewModel.loadProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowBack,
|
||||||
|
contentDescription = "Назад"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Настройки профиля",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Основная информация
|
||||||
|
PersonalInfoSection(
|
||||||
|
uiState = uiState,
|
||||||
|
onUpdate = { userUpdate -> profileSettingsViewModel.updateProfile(userUpdate) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Настройки безопасности
|
||||||
|
SecuritySection(
|
||||||
|
onChangePassword = { showPasswordDialog = true },
|
||||||
|
locationEnabled = uiState.locationSharingEnabled,
|
||||||
|
onLocationToggle = { enabled ->
|
||||||
|
profileSettingsViewModel.updateLocationSharing(enabled)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Настройки уведомлений
|
||||||
|
NotificationSection(
|
||||||
|
pushEnabled = uiState.pushNotificationsEnabled,
|
||||||
|
emailEnabled = uiState.emailNotificationsEnabled,
|
||||||
|
emergencyEnabled = uiState.emergencyNotificationsEnabled,
|
||||||
|
onPushToggle = { enabled ->
|
||||||
|
profileSettingsViewModel.updateNotificationSettings(push = enabled)
|
||||||
|
},
|
||||||
|
onEmailToggle = { enabled ->
|
||||||
|
profileSettingsViewModel.updateNotificationSettings(email = enabled)
|
||||||
|
},
|
||||||
|
onEmergencyToggle = { enabled ->
|
||||||
|
profileSettingsViewModel.updateNotificationSettings(emergency = enabled)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог смены пароля
|
||||||
|
if (showPasswordDialog) {
|
||||||
|
ChangePasswordDialog(
|
||||||
|
onDismiss = { showPasswordDialog = false },
|
||||||
|
onConfirm = { currentPassword, newPassword ->
|
||||||
|
profileSettingsViewModel.changePassword(currentPassword, newPassword)
|
||||||
|
showPasswordDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка ошибок
|
||||||
|
uiState.error?.let { error ->
|
||||||
|
LaunchedEffect(error) {
|
||||||
|
// Показать snackbar с ошибкой
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PersonalInfoSection(
|
||||||
|
uiState: com.example.womansafe.ui.viewmodel.ProfileSettingsUiState,
|
||||||
|
onUpdate: (UserUpdate) -> Unit
|
||||||
|
) {
|
||||||
|
var firstName by remember { mutableStateOf(uiState.firstName ?: "") }
|
||||||
|
var lastName by remember { mutableStateOf(uiState.lastName ?: "") }
|
||||||
|
var phone by remember { mutableStateOf(uiState.phone ?: "") }
|
||||||
|
var bio by remember { mutableStateOf(uiState.bio ?: "") }
|
||||||
|
var isEditing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Личная информация",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (isEditing) {
|
||||||
|
// Сохранить изменения
|
||||||
|
onUpdate(UserUpdate(
|
||||||
|
first_name = firstName.takeIf { it.isNotBlank() },
|
||||||
|
last_name = lastName.takeIf { it.isNotBlank() },
|
||||||
|
phone = phone.takeIf { it.isNotBlank() },
|
||||||
|
bio = bio.takeIf { it.isNotBlank() }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
isEditing = !isEditing
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(if (isEditing) "Сохранить" else "Редактировать")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = firstName,
|
||||||
|
onValueChange = { firstName = it },
|
||||||
|
label = { Text("Имя") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lastName,
|
||||||
|
onValueChange = { lastName = it },
|
||||||
|
label = { Text("Фамилия") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = phone,
|
||||||
|
onValueChange = { phone = it },
|
||||||
|
label = { Text("Телефон") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = bio,
|
||||||
|
onValueChange = { bio = it },
|
||||||
|
label = { Text("О себе") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Отображение информации
|
||||||
|
InfoRow("Имя", firstName.ifBlank { "Не указано" })
|
||||||
|
InfoRow("Фамилия", lastName.ifBlank { "Не указано" })
|
||||||
|
InfoRow("Телефон", phone.ifBlank { "Не указан" })
|
||||||
|
InfoRow("Email", uiState.email ?: "Не указан")
|
||||||
|
if (bio.isNotBlank()) {
|
||||||
|
InfoRow("О себе", bio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoRow(label: String, value: String) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SecuritySection(
|
||||||
|
onChangePassword: () -> Unit,
|
||||||
|
locationEnabled: Boolean,
|
||||||
|
onLocationToggle: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Безопасность",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Смена пароля
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Сменить пароль",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Обновить пароль для входа в приложение",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onChangePassword) {
|
||||||
|
Text("Изменить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
// Отслеживание местоположения
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Отслеживание местоположения",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Разрешить приложению отслеживать ваше местоположение",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Switch(
|
||||||
|
checked = locationEnabled,
|
||||||
|
onCheckedChange = onLocationToggle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotificationSection(
|
||||||
|
pushEnabled: Boolean,
|
||||||
|
emailEnabled: Boolean,
|
||||||
|
emergencyEnabled: Boolean,
|
||||||
|
onPushToggle: (Boolean) -> Unit,
|
||||||
|
onEmailToggle: (Boolean) -> Unit,
|
||||||
|
onEmergencyToggle: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Уведомления",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Push-уведомления
|
||||||
|
NotificationToggle(
|
||||||
|
title = "Push-уведомления",
|
||||||
|
subtitle = "Получать уведомления о важных событиях",
|
||||||
|
enabled = pushEnabled,
|
||||||
|
onToggle = onPushToggle
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
// Email-уведомления
|
||||||
|
NotificationToggle(
|
||||||
|
title = "Email-уведомления",
|
||||||
|
subtitle = "Получать уведомления на электронную почту",
|
||||||
|
enabled = emailEnabled,
|
||||||
|
onToggle = onEmailToggle
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
// Экстренные уведомления
|
||||||
|
NotificationToggle(
|
||||||
|
title = "Экстренные уведомления",
|
||||||
|
subtitle = "Уведомления о чрезвычайных ситуациях",
|
||||||
|
enabled = emergencyEnabled,
|
||||||
|
onToggle = onEmergencyToggle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotificationToggle(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
onToggle: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Switch(
|
||||||
|
checked = enabled,
|
||||||
|
onCheckedChange = onToggle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChangePasswordDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String, String) -> Unit
|
||||||
|
) {
|
||||||
|
var currentPassword by remember { mutableStateOf("") }
|
||||||
|
var newPassword by remember { mutableStateOf("") }
|
||||||
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
var showCurrentPassword by remember { mutableStateOf(false) }
|
||||||
|
var showNewPassword by remember { mutableStateOf(false) }
|
||||||
|
var showConfirmPassword by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val isValid = currentPassword.isNotBlank() &&
|
||||||
|
newPassword.isNotBlank() &&
|
||||||
|
confirmPassword == newPassword &&
|
||||||
|
newPassword.length >= 8
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Сменить пароль") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = currentPassword,
|
||||||
|
onValueChange = { currentPassword = it },
|
||||||
|
label = { Text("Текущий пароль") },
|
||||||
|
visualTransformation = if (showCurrentPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showCurrentPassword = !showCurrentPassword }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (showCurrentPassword) Icons.Filled.Lock else Icons.Filled.Info,
|
||||||
|
contentDescription = if (showCurrentPassword) "Скрыть пароль" else "Показать пароль"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newPassword,
|
||||||
|
onValueChange = { newPassword = it },
|
||||||
|
label = { Text("Новый пароль") },
|
||||||
|
visualTransformation = if (showNewPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showNewPassword = !showNewPassword }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (showNewPassword) Icons.Filled.Lock else Icons.Filled.Info,
|
||||||
|
contentDescription = if (showNewPassword) "Скрыть пароль" else "Показать пароль"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
isError = newPassword.isNotEmpty() && newPassword.length < 8,
|
||||||
|
supportingText = if (newPassword.isNotEmpty() && newPassword.length < 8) {
|
||||||
|
{ Text("Пароль должен содержать минимум 8 символов") }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = confirmPassword,
|
||||||
|
onValueChange = { confirmPassword = it },
|
||||||
|
label = { Text("Подтвердите пароль") },
|
||||||
|
visualTransformation = if (showConfirmPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showConfirmPassword = !showConfirmPassword }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (showConfirmPassword) Icons.Filled.Lock else Icons.Filled.Info,
|
||||||
|
contentDescription = if (showConfirmPassword) "Скрыть пароль" else "Показать пароль"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
isError = confirmPassword.isNotEmpty() && confirmPassword != newPassword,
|
||||||
|
supportingText = if (confirmPassword.isNotEmpty() && confirmPassword != newPassword) {
|
||||||
|
{ Text("Пароли не совпадают") }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onConfirm(currentPassword, newPassword) },
|
||||||
|
enabled = isValid
|
||||||
|
) {
|
||||||
|
Text("Изменить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.example.womansafe.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Цвета приложения для использования в EnhancedCalendarScreen и других компонентах
|
||||||
|
*/
|
||||||
|
object AppColors {
|
||||||
|
// Основные цвета приложения
|
||||||
|
val PrimaryPink = Color(0xFFE91E63)
|
||||||
|
val SecondaryBlue = Color(0xFF2196F3)
|
||||||
|
val TertiaryGreen = Color(0xFF4CAF50)
|
||||||
|
|
||||||
|
// Цвета для индикаторов менструального цикла
|
||||||
|
val PeriodColor = Color(0xFFE91E63)
|
||||||
|
val PeriodLightColor = Color(0xFFF8BBD0)
|
||||||
|
val OvulationColor = Color(0xFF2196F3)
|
||||||
|
val OvulationLightColor = Color(0xFFBBDEFB)
|
||||||
|
val FertileColor = Color(0xFF00BCD4)
|
||||||
|
val FertileLightColor = Color(0xFFB2EBF2)
|
||||||
|
|
||||||
|
// Цвета настроений
|
||||||
|
val HappyColor = Color(0xFF4CAF50)
|
||||||
|
val NeutralColor = Color(0xFF9E9E9E)
|
||||||
|
val SadColor = Color(0xFF9C27B0)
|
||||||
|
val IrritatedColor = Color(0xFFFF5722)
|
||||||
|
val AnxiousColor = Color(0xFFFFEB3B)
|
||||||
|
|
||||||
|
// Цвета для симптомов
|
||||||
|
val SymptomColor = Color(0xFFFFC107)
|
||||||
|
val MedicationColor = Color(0xFF673AB7)
|
||||||
|
val NoteColor = Color(0xFF607D8B)
|
||||||
|
}
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
package com.example.womansafe.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.womansafe.data.model.*
|
||||||
|
import com.example.womansafe.data.network.NetworkClient
|
||||||
|
import com.example.womansafe.data.repository.ApiRepository
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
data class ApiTestState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val currentUser: UserResponse? = null,
|
||||||
|
val authToken: String? = null,
|
||||||
|
val isAuthenticated: Boolean = false,
|
||||||
|
val emergencyContacts: List<EmergencyContactResponse> = emptyList(),
|
||||||
|
val emergencyReports: List<EmergencyReportResponse> = emptyList(),
|
||||||
|
val emergencyAlerts: List<EmergencyAlertResponse> = emptyList(),
|
||||||
|
val calendarEntries: List<CalendarEntryResponse> = emptyList(),
|
||||||
|
val locationHistory: List<LocationResponse> = emptyList(),
|
||||||
|
val safePlaces: List<SafePlaceResponse> = emptyList(),
|
||||||
|
val notificationHistory: List<NotificationHistory> = emptyList(),
|
||||||
|
val lastApiResponse: String = "",
|
||||||
|
val lastApiError: String = "",
|
||||||
|
val selectedEndpoint: String = "",
|
||||||
|
val baseUrl: String = "http://192.168.0.103:8000/"
|
||||||
|
)
|
||||||
|
|
||||||
|
class ApiTestViewModel : ViewModel() {
|
||||||
|
private val repository = ApiRepository()
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(ApiTestState())
|
||||||
|
val state: StateFlow<ApiTestState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun updateBaseUrl(url: String) {
|
||||||
|
_state.value = _state.value.copy(baseUrl = url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(email: String?, username: String?, password: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "POST /api/v1/auth/login",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = repository.login(email, username, password)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val token = response.body()
|
||||||
|
token?.let {
|
||||||
|
NetworkClient.setAuthToken(it.accessToken)
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
authToken = it.accessToken,
|
||||||
|
isAuthenticated = true,
|
||||||
|
lastApiResponse = "Login successful! Token: ${it.accessToken.take(20)}...",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
// Запрос профиля сразу после авторизации
|
||||||
|
getCurrentUser()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register(email: String, username: String?, password: String, fullName: String?, phoneNumber: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "POST /api/v1/auth/register",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = repository.register(email, username, password, fullName, phoneNumber)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val user = response.body()
|
||||||
|
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
currentUser = user,
|
||||||
|
lastApiResponse = userJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentUser() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "GET /api/v1/users/me",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = repository.getCurrentUser()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val user = response.body()
|
||||||
|
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
currentUser = user,
|
||||||
|
lastApiResponse = userJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDashboard() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "GET /api/v1/users/dashboard",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = repository.getDashboard()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val dashboard = response.body()
|
||||||
|
val dashboardJson = withContext(Dispatchers.Default) { gson.toJson(dashboard) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiResponse = dashboardJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getEmergencyContacts() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "GET /api/v1/users/me/emergency-contacts",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = repository.getEmergencyContacts()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val contacts = response.body() ?: emptyList()
|
||||||
|
val contactsJson = withContext(Dispatchers.Default) { gson.toJson(contacts) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
emergencyContacts = contacts,
|
||||||
|
lastApiResponse = contactsJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createEmergencyContact(name: String, phoneNumber: String, relationship: String?, notes: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "POST /api/v1/users/me/emergency-contacts",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val contact = EmergencyContactCreate(name, phoneNumber, relationship, notes)
|
||||||
|
val response = repository.createEmergencyContact(contact)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val createdContact = response.body()
|
||||||
|
val contactJson = withContext(Dispatchers.Default) { gson.toJson(createdContact) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiResponse = contactJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
// Refresh the contacts list
|
||||||
|
getEmergencyContacts()
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateUser(
|
||||||
|
firstName: String? = null,
|
||||||
|
lastName: String? = null,
|
||||||
|
phone: String? = null,
|
||||||
|
dateOfBirth: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
avatarUrl: String? = null,
|
||||||
|
emergencyContact1Name: String? = null,
|
||||||
|
emergencyContact1Phone: String? = null,
|
||||||
|
emergencyContact2Name: String? = null,
|
||||||
|
emergencyContact2Phone: String? = null,
|
||||||
|
locationSharingEnabled: Boolean? = null,
|
||||||
|
emergencyNotificationsEnabled: Boolean? = null,
|
||||||
|
pushNotificationsEnabled: Boolean? = null
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "PUT /api/v1/users/me",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val userUpdate = UserUpdate(
|
||||||
|
first_name = firstName,
|
||||||
|
last_name = lastName,
|
||||||
|
phone = phone,
|
||||||
|
date_of_birth = dateOfBirth,
|
||||||
|
bio = bio,
|
||||||
|
avatar_url = avatarUrl,
|
||||||
|
emergency_contact_1_name = emergencyContact1Name,
|
||||||
|
emergency_contact_1_phone = emergencyContact1Phone,
|
||||||
|
emergency_contact_2_name = emergencyContact2Name,
|
||||||
|
emergency_contact_2_phone = emergencyContact2Phone,
|
||||||
|
location_sharing_enabled = locationSharingEnabled,
|
||||||
|
emergency_notifications_enabled = emergencyNotificationsEnabled,
|
||||||
|
push_notifications_enabled = pushNotificationsEnabled
|
||||||
|
)
|
||||||
|
val response = repository.updateCurrentUser(userUpdate)
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val user = response.body()
|
||||||
|
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
currentUser = user,
|
||||||
|
lastApiResponse = userJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateEmergencyContact(contactId: Int, name: String?, phoneNumber: String?, relationship: String?, notes: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "PATCH /api/v1/users/me/emergency-contacts/$contactId",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val contactUpdate = EmergencyContactUpdate(name, phoneNumber, relationship, notes)
|
||||||
|
val response = repository.updateEmergencyContact(contactId, contactUpdate)
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val contact = response.body()
|
||||||
|
val contactJson = withContext(Dispatchers.Default) { gson.toJson(contact) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiResponse = contactJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
// Refresh contacts
|
||||||
|
getEmergencyContacts()
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteEmergencyContact(contactId: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "DELETE /api/v1/users/me/emergency-contacts/$contactId",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = repository.deleteEmergencyContact(contactId)
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val message = response.body()
|
||||||
|
val messageJson = withContext(Dispatchers.Default) { gson.toJson(message) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiResponse = messageJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
// Refresh contacts
|
||||||
|
getEmergencyContacts()
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getEmergencyReports() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "GET /api/v1/emergency/reports",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = repository.getEmergencyReports()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val reports = response.body()
|
||||||
|
val reportsJson = withContext(Dispatchers.Default) { gson.toJson(reports) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiResponse = reportsJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMyEmergencyAlerts() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "GET /api/v1/emergency/alerts/my",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = repository.getMyEmergencyAlerts()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val alerts = response.body()
|
||||||
|
val alertsJson = withContext(Dispatchers.Default) { gson.toJson(alerts) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiResponse = alertsJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLocationHistory() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "GET /api/v1/locations/history",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = repository.getLocationHistory()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val history = response.body()
|
||||||
|
val historyJson = withContext(Dispatchers.Default) { gson.toJson(history) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiResponse = historyJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSafePlaces() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "GET /api/v1/locations/safe-places",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = repository.getSafePlaces()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val places = response.body()
|
||||||
|
val placesJson = withContext(Dispatchers.Default) { gson.toJson(places) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiResponse = placesJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalendarEntries() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "GET /api/v1/calendar/entries",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = repository.getCalendarEntries()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val entries = response.body()
|
||||||
|
val entriesJson = withContext(Dispatchers.Default) { gson.toJson(entries) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiResponse = entriesJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNotificationHistory() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "GET /api/v1/notifications/history",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = repository.getNotificationHistory()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val history = response.body()
|
||||||
|
val historyJson = withContext(Dispatchers.Default) { gson.toJson(history) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiResponse = historyJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun testGenericEndpoint(endpoint: String, method: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
selectedEndpoint = "$method $endpoint",
|
||||||
|
lastApiError = "",
|
||||||
|
lastApiResponse = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = when (endpoint.lowercase()) {
|
||||||
|
"/api/v1/health" -> repository.getHealth()
|
||||||
|
"/api/v1/services-status" -> repository.getServicesStatus()
|
||||||
|
"/" -> repository.getRoot()
|
||||||
|
"/api/v1/users/dashboard" -> repository.getDashboard()
|
||||||
|
"/api/v1/emergency/reports" -> repository.getEmergencyReports()
|
||||||
|
"/api/v1/emergency/alerts" -> repository.getEmergencyAlerts()
|
||||||
|
"/api/v1/emergency/alerts/my" -> repository.getMyEmergencyAlerts()
|
||||||
|
"/api/v1/emergency/alerts/nearby" -> repository.getNearbyEmergencyAlerts()
|
||||||
|
"/api/v1/locations/last" -> repository.getLastLocation()
|
||||||
|
"/api/v1/locations/history" -> repository.getLocationHistory()
|
||||||
|
"/api/v1/locations/safe-places" -> repository.getSafePlaces()
|
||||||
|
"/api/v1/locations/users/nearby" -> repository.getNearbyUsers()
|
||||||
|
"/api/v1/calendar/entries" -> repository.getCalendarEntries()
|
||||||
|
"/api/v1/calendar/cycle-overview" -> repository.getCycleOverview()
|
||||||
|
"/api/v1/calendar/insights" -> repository.getCalendarInsights()
|
||||||
|
"/api/v1/calendar/reminders" -> repository.getCalendarReminders()
|
||||||
|
"/api/v1/calendar/settings" -> repository.getCalendarSettings()
|
||||||
|
"/api/v1/notifications/preferences" -> repository.getNotificationPreferences()
|
||||||
|
"/api/v1/notifications/devices" -> repository.getNotificationDevices()
|
||||||
|
"/api/v1/notifications/history" -> repository.getNotificationHistory()
|
||||||
|
else -> {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Endpoint not implemented in this test app",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()
|
||||||
|
val bodyJson = withContext(Dispatchers.Default) { gson.toJson(body) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiResponse = bodyJson,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Error ${response.code()}: $errorBody",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiError = "Network error: ${e.message}",
|
||||||
|
lastApiResponse = "",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAuth() {
|
||||||
|
NetworkClient.setAuthToken(null)
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
authToken = null,
|
||||||
|
isAuthenticated = false,
|
||||||
|
currentUser = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearResults() {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
lastApiResponse = "",
|
||||||
|
lastApiError = "",
|
||||||
|
selectedEndpoint = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package com.example.womansafe.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.womansafe.data.model.*
|
||||||
|
import com.example.womansafe.data.repository.ApiRepository
|
||||||
|
import com.example.womansafe.data.network.NetworkClient
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class AuthViewModel : ViewModel() {
|
||||||
|
private val repository = ApiRepository()
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(AuthUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun login(usernameOrEmail: String, password: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
try {
|
||||||
|
// Определяем, что введено - email или username
|
||||||
|
val isEmail = usernameOrEmail.contains("@")
|
||||||
|
println("=== LOGIN ATTEMPT ===")
|
||||||
|
println("Input: $usernameOrEmail, isEmail: $isEmail")
|
||||||
|
|
||||||
|
val response = repository.login(
|
||||||
|
email = if (isEmail) usernameOrEmail else null,
|
||||||
|
username = if (!isEmail) usernameOrEmail else null,
|
||||||
|
password = password
|
||||||
|
)
|
||||||
|
|
||||||
|
println("Login Response Code: ${response.code()}")
|
||||||
|
println("Login Response Message: ${response.message()}")
|
||||||
|
println("Login Response Body: ${response.body()}")
|
||||||
|
println("Login Response Error Body: ${response.errorBody()?.string()}")
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val token = response.body()
|
||||||
|
token?.let {
|
||||||
|
println("Login Success: Token received - ${it.accessToken.take(10)}...")
|
||||||
|
NetworkClient.setAuthToken(it.accessToken)
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isLoggedIn = true,
|
||||||
|
token = it.accessToken,
|
||||||
|
tokenType = it.tokenType
|
||||||
|
)
|
||||||
|
// Получаем профиль пользователя сразу после успешного входа
|
||||||
|
getCurrentUser()
|
||||||
|
} ?: run {
|
||||||
|
println("Login Error: Token is null in successful response")
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка авторизации: Получен пустой токен"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Неизвестная ошибка"
|
||||||
|
println("Login Error: ${response.code()} - $errorBody")
|
||||||
|
|
||||||
|
// Более специфичные сообщения для разных кодов ошибок
|
||||||
|
val errorMessage = when (response.code()) {
|
||||||
|
401 -> "Неверный логин или пароль"
|
||||||
|
403 -> "Доступ запрещен"
|
||||||
|
404 -> "Пользователь не найден"
|
||||||
|
else -> "Ошибка авторизации: ${response.code()} - $errorBody"
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = errorMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Login Exception: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка сети: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register(username: String, email: String, password: String, fullName: String?, phoneNumber: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
try {
|
||||||
|
println("=== REGISTER ATTEMPT ===")
|
||||||
|
println("Username: $username, Email: $email, Full Name: $fullName, Phone Number: $phoneNumber")
|
||||||
|
println("Password length: ${password.length}")
|
||||||
|
|
||||||
|
val response = repository.register(
|
||||||
|
email = email,
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
fullName = fullName,
|
||||||
|
phoneNumber = phoneNumber
|
||||||
|
)
|
||||||
|
|
||||||
|
println("Register Response Code: ${response.code()}")
|
||||||
|
println("Register Response Message: ${response.message()}")
|
||||||
|
println("Register Response Body: ${response.body()}")
|
||||||
|
println("Register Response Error Body: ${response.errorBody()?.string()}")
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val userResponse = response.body()
|
||||||
|
userResponse?.let {
|
||||||
|
println("Registration Success: User ID: ${it.id}, Username: ${it.username}")
|
||||||
|
|
||||||
|
// После успешной регистрации выполняем автоматический вход
|
||||||
|
println("Attempting auto-login after registration")
|
||||||
|
// Выбираем имя пользователя или email для входа
|
||||||
|
val loginIdentifier = username.ifBlank { email }
|
||||||
|
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
user = it
|
||||||
|
)
|
||||||
|
|
||||||
|
// Выполняем автоматический вход
|
||||||
|
login(loginIdentifier, password)
|
||||||
|
} ?: run {
|
||||||
|
println("Register Error: User object is null in successful response")
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка регистрации: Получен пустой ответ"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string() ?: "Неизвестная ошибка"
|
||||||
|
println("Register Error: ${response.code()} - $errorBody")
|
||||||
|
|
||||||
|
// Более специфичные сообщения для разных кодов ошибок
|
||||||
|
val errorMessage = when (response.code()) {
|
||||||
|
400 -> {
|
||||||
|
if (errorBody.contains("email", ignoreCase = true)) {
|
||||||
|
"Этот email уже используется или имеет неверный формат"
|
||||||
|
} else if (errorBody.contains("username", ignoreCase = true)) {
|
||||||
|
"Это имя пользователя уже используется"
|
||||||
|
} else if (errorBody.contains("password", ignoreCase = true)) {
|
||||||
|
"Пароль не соответствует требованиям безопасности"
|
||||||
|
} else {
|
||||||
|
"Ошибка в отправленных данных: $errorBody"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
409 -> "Пользователь с таким email или именем уже существует"
|
||||||
|
422 -> "Неверный формат данных: $errorBody"
|
||||||
|
else -> "Ошибка регистрации: ${response.code()} - $errorBody"
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = errorMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Register Exception: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка сети при регистрации: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCurrentUser() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
println("=== Начинаем загрузку данных пользователя ===")
|
||||||
|
|
||||||
|
// Загружаем основную информацию пользователя
|
||||||
|
println("Отправляем запрос getCurrentUser...")
|
||||||
|
val userResponse = repository.getCurrentUser()
|
||||||
|
println("getCurrentUser ответ: ${userResponse.code()}, успешно: ${userResponse.isSuccessful}")
|
||||||
|
|
||||||
|
if (userResponse.isSuccessful) {
|
||||||
|
val user = userResponse.body()
|
||||||
|
println("Пользователь получен: ${user?.username}")
|
||||||
|
|
||||||
|
// Параллельно загружаем экстренные контакты и дашборд
|
||||||
|
val emergencyContactsDeferred = async {
|
||||||
|
try {
|
||||||
|
println("Отправляем запрос getEmergencyContacts...")
|
||||||
|
val response = repository.getEmergencyContacts()
|
||||||
|
println("getEmergencyContacts ответ: ${response.code()}")
|
||||||
|
response
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Ошибка getEmergencyContacts: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val dashboardDeferred = async {
|
||||||
|
try {
|
||||||
|
println("Отправляем запрос getDashboard...")
|
||||||
|
val response = repository.getDashboard()
|
||||||
|
println("getDashboard ответ: ${response.code()}")
|
||||||
|
response
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Ошибка getDashboard: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val profileDeferred = async {
|
||||||
|
try {
|
||||||
|
println("Отправляем запрос getUserProfile...")
|
||||||
|
val response = repository.getUserProfile()
|
||||||
|
println("getUserProfile ответ: ${response.code()}")
|
||||||
|
response
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Ошибка getUserProfile: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ждём результаты дополнительных запросов
|
||||||
|
val emergencyContacts = emergencyContactsDeferred.await()
|
||||||
|
val dashboard = dashboardDeferred.await()
|
||||||
|
val profile = profileDeferred.await()
|
||||||
|
|
||||||
|
println("Завершены все запросы, обновляем UI state...")
|
||||||
|
|
||||||
|
uiState = uiState.copy(
|
||||||
|
user = user,
|
||||||
|
emergencyContacts = emergencyContacts?.takeIf { it.isSuccessful }?.body() ?: emptyList(),
|
||||||
|
dashboard = dashboard?.takeIf { it.isSuccessful }?.body(),
|
||||||
|
profileExtended = profile?.takeIf { it.isSuccessful }?.body(),
|
||||||
|
profileLoaded = true,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
println("Ошибка getCurrentUser: ${userResponse.code()} - ${userResponse.message()}")
|
||||||
|
uiState = uiState.copy(
|
||||||
|
profileLoaded = true,
|
||||||
|
isLoading = false,
|
||||||
|
error = "Не удалось загрузить профиль: ${userResponse.code()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Исключение в getCurrentUser: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
|
uiState = uiState.copy(
|
||||||
|
profileLoaded = true,
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка загрузки профиля: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метод для автоматического входа с использованием сохраненного токена
|
||||||
|
fun autoLogin(token: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
try {
|
||||||
|
println("=== AUTO LOGIN ATTEMPT ===")
|
||||||
|
println("Using saved token: ${token.take(10)}...")
|
||||||
|
|
||||||
|
// Устанавливаем токен в NetworkClient
|
||||||
|
NetworkClient.setAuthToken(token)
|
||||||
|
|
||||||
|
// Устанавливаем состояние авторизации
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isLoggedIn = true,
|
||||||
|
token = token
|
||||||
|
)
|
||||||
|
|
||||||
|
// Получаем профиль пользователя
|
||||||
|
getCurrentUser()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Auto Login Exception: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
|
|
||||||
|
// В случае ошибки сбрасываем токен и состояние
|
||||||
|
NetworkClient.clearAuthToken()
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка автоматического входа: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
// Очищаем токен в NetworkClient
|
||||||
|
NetworkClient.clearAuthToken()
|
||||||
|
uiState = AuthUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
uiState = uiState.copy(error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AuthUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isLoggedIn: Boolean = false,
|
||||||
|
val user: UserResponse? = null,
|
||||||
|
val emergencyContacts: List<EmergencyContactResponse>? = null,
|
||||||
|
val dashboard: Any? = null,
|
||||||
|
val profileExtended: UserResponse? = null,
|
||||||
|
val token: String? = null,
|
||||||
|
val tokenType: String? = null,
|
||||||
|
val registrationSuccess: Boolean = false,
|
||||||
|
val profileLoaded: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package com.example.womansafe.ui.viewmodel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.womansafe.data.api.CalendarApi
|
||||||
|
import com.example.womansafe.data.local.CalendarDatabase
|
||||||
|
import com.example.womansafe.data.model.calendar.*
|
||||||
|
import com.example.womansafe.data.network.NetworkClient
|
||||||
|
import com.example.womansafe.data.repository.CalendarRepository
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel для функциональности менструального календаря
|
||||||
|
*/
|
||||||
|
class CalendarViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
// Состояние UI
|
||||||
|
private val _calendarUiState = MutableLiveData(CalendarUiState())
|
||||||
|
val calendarUiState: LiveData<CalendarUiState> = _calendarUiState
|
||||||
|
|
||||||
|
// Состояние загрузки
|
||||||
|
private val _isLoading = MutableLiveData(false)
|
||||||
|
val isLoading: LiveData<Boolean> = _isLoading
|
||||||
|
|
||||||
|
// Сообщения об ошибках
|
||||||
|
private val _error = MutableLiveData<String?>(null)
|
||||||
|
val error: LiveData<String?> = _error
|
||||||
|
|
||||||
|
// Дата, выбранная пользователем
|
||||||
|
private val _selectedDate = MutableLiveData(LocalDate.now())
|
||||||
|
val selectedDate: LiveData<LocalDate> = _selectedDate
|
||||||
|
|
||||||
|
// Репозиторий для работы с данными календаря
|
||||||
|
private val repository: CalendarRepository
|
||||||
|
|
||||||
|
// Месяц, просматриваемый пользователем
|
||||||
|
private var viewingMonth: LocalDate = LocalDate.now()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Инициализация репозитория
|
||||||
|
val calendarApi = NetworkClient.createService(CalendarApi::class.java)
|
||||||
|
val calendarDao = CalendarDatabase.getDatabase(application).calendarDao()
|
||||||
|
repository = CalendarRepository(calendarDao, calendarApi)
|
||||||
|
|
||||||
|
// Загрузка данных
|
||||||
|
loadCalendarEntries()
|
||||||
|
loadCycleData()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка записей календаря
|
||||||
|
*/
|
||||||
|
private fun loadCalendarEntries() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
try {
|
||||||
|
// Получение записей из репозитория как Flow
|
||||||
|
repository.getCalendarEntriesFlow("userId").collect { entries: List<CalendarEntry> ->
|
||||||
|
updateCalendarStateWithEntries(entries)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Ошибка при загрузке записей: ${e.localizedMessage}"
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка данных о цикле
|
||||||
|
*/
|
||||||
|
private fun loadCycleData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
try {
|
||||||
|
// Получение данных о цикле из репозитория
|
||||||
|
val cycleData = repository.getCycleData()
|
||||||
|
updateCalendarStateWithCycleData(cycleData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Ошибка при загрузке данных цикла: ${e.localizedMessage}"
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор даты пользователем
|
||||||
|
*/
|
||||||
|
fun selectDate(date: LocalDate) {
|
||||||
|
_selectedDate.value = date
|
||||||
|
|
||||||
|
// Если выбрана дата другого месяца, обновляем просматриваемый месяц
|
||||||
|
if (date.month != viewingMonth.month || date.year != viewingMonth.year) {
|
||||||
|
viewingMonth = date.withDayOfMonth(1)
|
||||||
|
updateMonthData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление данных для текущего месяца
|
||||||
|
*/
|
||||||
|
private fun updateMonthData() {
|
||||||
|
val currentState = _calendarUiState.value ?: CalendarUiState()
|
||||||
|
_calendarUiState.value = currentState.copy(
|
||||||
|
viewingMonth = viewingMonth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление состояния UI с записями календаря
|
||||||
|
*/
|
||||||
|
private fun updateCalendarStateWithEntries(entries: List<CalendarEntry>) {
|
||||||
|
val updatedState = _calendarUiState.value?.copy(
|
||||||
|
entries = entries,
|
||||||
|
selectedDateEntries = entries.filter {
|
||||||
|
it.entryDate.isEqual(selectedDate.value)
|
||||||
|
}
|
||||||
|
) ?: CalendarUiState(entries = entries)
|
||||||
|
_calendarUiState.value = updatedState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление состояния UI с данными о цикле
|
||||||
|
*/
|
||||||
|
private fun updateCalendarStateWithCycleData(cycleData: CycleData?) {
|
||||||
|
if (cycleData == null) return
|
||||||
|
|
||||||
|
val updatedState = _calendarUiState.value?.copy(
|
||||||
|
cycleStartDate = cycleData.lastPeriodStartDate,
|
||||||
|
cycleData = cycleData,
|
||||||
|
avgCycleLength = cycleData.averageCycleLength,
|
||||||
|
avgPeriodLength = cycleData.averagePeriodLength,
|
||||||
|
cycleRegularityScore = cycleData.regularityScore
|
||||||
|
) ?: CalendarUiState()
|
||||||
|
_calendarUiState.value = updatedState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление инсайта
|
||||||
|
*/
|
||||||
|
fun dismissInsight(id: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// Здесь должен быть вызов метода репозитория для удаления инсайта
|
||||||
|
// repository.dismissInsight(id)
|
||||||
|
|
||||||
|
// Обновляем список инсайтов, удаляя указанный
|
||||||
|
_calendarUiState.value = _calendarUiState.value?.copy(
|
||||||
|
insights = _calendarUiState.value?.insights?.filter { it.id != id } ?: emptyList()
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Ошибка при удалении инсайта: ${e.localizedMessage}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очистка сообщения об ошибке
|
||||||
|
*/
|
||||||
|
fun clearErrorMessage() {
|
||||||
|
_error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавление новой записи календаря
|
||||||
|
*/
|
||||||
|
fun addCalendarEntry(entry: CalendarEntry) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
try {
|
||||||
|
repository.addCalendarEntry(entry)
|
||||||
|
// Обновляем данные после добавления
|
||||||
|
loadCalendarEntries()
|
||||||
|
_error.value = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Ошибка при добавлении записи: ${e.localizedMessage}"
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление существующей записи
|
||||||
|
*/
|
||||||
|
fun updateCalendarEntry(entry: CalendarEntry) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
try {
|
||||||
|
repository.updateCalendarEntry(entry)
|
||||||
|
// Обновляем данные после изменения
|
||||||
|
loadCalendarEntries()
|
||||||
|
_error.value = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Ошибка при обновлении записи: ${e.localizedMessage}"
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление записи календаря
|
||||||
|
*/
|
||||||
|
fun deleteCalendarEntry(entryId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
try {
|
||||||
|
repository.deleteCalendarEntry(entryId)
|
||||||
|
// Обновляем данные после удаления
|
||||||
|
loadCalendarEntries()
|
||||||
|
_error.value = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Ошибка при удалении записи: ${e.localizedMessage}"
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс, представляющий состояние UI календаря
|
||||||
|
*/
|
||||||
|
data class CalendarUiState(
|
||||||
|
val entries: List<CalendarEntry> = emptyList(),
|
||||||
|
val selectedDateEntries: List<CalendarEntry> = emptyList(),
|
||||||
|
val cycleStartDate: LocalDate? = null,
|
||||||
|
val cycleData: CycleData? = null,
|
||||||
|
val specialDays: Map<LocalDate, DayType> = emptyMap(),
|
||||||
|
val avgCycleLength: Int? = null,
|
||||||
|
val avgPeriodLength: Int? = null,
|
||||||
|
val cycleRegularityScore: Int? = null,
|
||||||
|
val insights: List<HealthInsight> = emptyList(),
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val viewingMonth: LocalDate = LocalDate.now().withDayOfMonth(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типы дней в календаре
|
||||||
|
*/
|
||||||
|
enum class DayType {
|
||||||
|
PERIOD, // День менструации
|
||||||
|
OVULATION, // День овуляции
|
||||||
|
FERTILE, // Фертильный день
|
||||||
|
PREDICTED_PERIOD, // Прогноз дня менструации
|
||||||
|
NORMAL // Обычный день
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.example.womansafe.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.womansafe.data.model.EmergencyContactCreate
|
||||||
|
import com.example.womansafe.data.model.EmergencyContactResponse
|
||||||
|
import com.example.womansafe.data.model.EmergencyContactUpdate
|
||||||
|
import com.example.womansafe.data.repository.ApiRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class EmergencyContactsViewModel : ViewModel() {
|
||||||
|
private val repository = ApiRepository()
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(EmergencyContactsUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun loadContacts() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
try {
|
||||||
|
val response = repository.getEmergencyContacts()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
contacts = response.body() ?: emptyList(),
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка загрузки контактов: ${response.code()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка сети: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addContact(contact: EmergencyContactCreate) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response = repository.createEmergencyContact(contact)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
loadContacts() // Перезагружаем список
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка добавления контакта: ${response.code()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка добавления контакта: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun editContact(contact: EmergencyContactResponse) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val update = EmergencyContactUpdate(
|
||||||
|
name = contact.name,
|
||||||
|
phone_number = contact.phone_number,
|
||||||
|
relationship = contact.relationship
|
||||||
|
)
|
||||||
|
val response = repository.updateEmergencyContact(contact.id, update)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
loadContacts() // Перезагружаем список
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка редактирования контакта: ${response.code()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка редактирования контакта: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteContact(contactId: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response = repository.deleteEmergencyContact(contactId)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
loadContacts() // Перезагружаем список
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка удаления контакта: ${response.code()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка удаления контакта: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun callContact(phoneNumber: String) {
|
||||||
|
// В реальном приложении здесь будет интент для звонка
|
||||||
|
// Пока просто логируем
|
||||||
|
println("Calling: $phoneNumber")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
uiState = uiState.copy(error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class EmergencyContactsUiState(
|
||||||
|
val contacts: List<EmergencyContactResponse> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package com.example.womansafe.ui.viewmodel
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.location.Address
|
||||||
|
import android.location.Geocoder
|
||||||
|
import android.location.Location
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.womansafe.data.api.WomanSafeApi
|
||||||
|
import com.example.womansafe.data.model.*
|
||||||
|
import com.example.womansafe.data.network.RetrofitClient
|
||||||
|
import com.google.android.gms.location.*
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
data class EmergencyUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val currentLocation: UserLocation? = null,
|
||||||
|
val emergencyContacts: List<EmergencyContactResponse> = emptyList(),
|
||||||
|
val isEmergencyActive: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val hasLocationPermission: Boolean = false,
|
||||||
|
val locationPermissionRequested: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
class EmergencyViewModel : ViewModel() {
|
||||||
|
private val api: WomanSafeApi = RetrofitClient.api
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(EmergencyUiState())
|
||||||
|
val uiState: StateFlow<EmergencyUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private lateinit var fusedLocationClient: FusedLocationProviderClient
|
||||||
|
|
||||||
|
fun initLocationClient(context: Context) {
|
||||||
|
fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
|
||||||
|
checkLocationPermission(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkLocationPermission(context: Context) {
|
||||||
|
val hasPermission = ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(hasLocationPermission = hasPermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLocationPermissionResult(granted: Boolean) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
hasLocationPermission = granted,
|
||||||
|
locationPermissionRequested = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCurrentLocation(context: Context): UserLocation? {
|
||||||
|
return suspendCancellableCoroutine { continuation ->
|
||||||
|
try {
|
||||||
|
if (ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
continuation.resume(null)
|
||||||
|
return@suspendCancellableCoroutine
|
||||||
|
}
|
||||||
|
|
||||||
|
fusedLocationClient.lastLocation
|
||||||
|
.addOnSuccessListener { location: Location? ->
|
||||||
|
if (location != null) {
|
||||||
|
val address = getAddressFromLocation(context, location.latitude, location.longitude)
|
||||||
|
val userLocation = UserLocation(
|
||||||
|
latitude = location.latitude,
|
||||||
|
longitude = location.longitude,
|
||||||
|
accuracy = location.accuracy,
|
||||||
|
address = address
|
||||||
|
)
|
||||||
|
_uiState.value = _uiState.value.copy(currentLocation = userLocation)
|
||||||
|
continuation.resume(userLocation)
|
||||||
|
} else {
|
||||||
|
continuation.resume(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addOnFailureListener { exception ->
|
||||||
|
continuation.resumeWithException(exception)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
continuation.resumeWithException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAddressFromLocation(context: Context, latitude: Double, longitude: Double): String? {
|
||||||
|
return try {
|
||||||
|
val geocoder = Geocoder(context, Locale.getDefault())
|
||||||
|
val addresses: List<Address> = geocoder.getFromLocation(latitude, longitude, 1) ?: emptyList()
|
||||||
|
if (addresses.isNotEmpty()) {
|
||||||
|
val address = addresses[0]
|
||||||
|
"${address.getAddressLine(0)}"
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadEmergencyContacts() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
val response = api.getEmergencyContacts()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
emergencyContacts = response.body() ?: emptyList(),
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val errorMsg = when (response.code()) {
|
||||||
|
401 -> "Необходима авторизация. Пожалуйста, войдите снова."
|
||||||
|
403 -> "У вас нет доступа к этому ресурсу."
|
||||||
|
404 -> "Список контактов не найден."
|
||||||
|
500, 502, 503 -> "Ошибка сервера. Пожалуйста, попробуйте позже."
|
||||||
|
else -> "Не удалось загрузить контакты: код ${response.code()}"
|
||||||
|
}
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
errorMessage = errorMsg,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val errorMsg = when {
|
||||||
|
e.message?.contains("Unable to resolve host") == true -> "Нет соединения с сервером. Проверьте подключение к интернету."
|
||||||
|
e.message?.contains("timeout") == true -> "Время ожидания истекло. Проверьте подключение к интернету."
|
||||||
|
else -> "Ошибка загрузки контактов: ${e.message ?: "неизвестная ошибка"}"
|
||||||
|
}
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
errorMessage = errorMsg,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createEmergencyAlert(
|
||||||
|
context: Context,
|
||||||
|
type: EmergencyType,
|
||||||
|
description: String? = null,
|
||||||
|
isAnonymous: Boolean = false
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
|
// Получаем текущее местоположение
|
||||||
|
val location = getCurrentLocation(context)
|
||||||
|
if (location == null) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
errorMessage = "Не удалось получить местоположение",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем запрос используя обновленную модель из ApiModels.kt
|
||||||
|
val request = EmergencyAlertCreate(
|
||||||
|
type = type.name.lowercase(),
|
||||||
|
description = description,
|
||||||
|
latitude = location.latitude,
|
||||||
|
longitude = location.longitude,
|
||||||
|
address = location.address,
|
||||||
|
is_anonymous = isAnonymous
|
||||||
|
)
|
||||||
|
|
||||||
|
// Отправляем на сервер
|
||||||
|
val response = api.createEmergencyAlert(request)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isEmergencyActive = true,
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
errorMessage = "Не удалось создать экстренное событие: ${response.code()}",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
errorMessage = "Ошибка создания экстренного события: ${e.message}",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelEmergencyAlert() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
// Здесь можно добавить API вызов для отмены экстренного события
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isEmergencyActive = false,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
errorMessage = "Ошибка отмены экстренного события: ${e.message}",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(errorMessage = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package com.example.womansafe.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.womansafe.data.model.calendar.*
|
||||||
|
import com.example.womansafe.data.repository.CalendarRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние UI для экрана календаря
|
||||||
|
*/
|
||||||
|
data class EnhancedCalendarUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val currentMonth: LocalDate = LocalDate.now(),
|
||||||
|
val selectedDate: LocalDate = LocalDate.now(),
|
||||||
|
val events: Map<LocalDate, List<CalendarEvent>> = emptyMap(),
|
||||||
|
val selectedEvent: CalendarEvent? = null,
|
||||||
|
val cycleData: CycleData? = null,
|
||||||
|
val predictions: CyclePrediction? = null,
|
||||||
|
val statistics: CycleStatistics? = null,
|
||||||
|
val insights: List<HealthInsight> = emptyList(),
|
||||||
|
val showEventDialog: Boolean = false,
|
||||||
|
val showInsightsDialog: Boolean = false,
|
||||||
|
val showSettingsDialog: Boolean = false,
|
||||||
|
val editingEvent: CalendarEvent? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class EnhancedCalendarViewModel(
|
||||||
|
private val repository: CalendarRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(EnhancedCalendarUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val userId: String = "userId" // TODO: заменить на реальный userId
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadInitialData()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка всех необходимых данных при запуске
|
||||||
|
*/
|
||||||
|
fun loadInitialData() {
|
||||||
|
loadCalendarData(LocalDate.now())
|
||||||
|
loadCycleData()
|
||||||
|
loadHealthInsights()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка данных календаря для выбранного месяца
|
||||||
|
*/
|
||||||
|
fun loadCalendarData(selectedDate: LocalDate) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
try {
|
||||||
|
val entries = repository.getCalendarEntries()
|
||||||
|
|
||||||
|
// Преобразуем CalendarEntry в CalendarEvent
|
||||||
|
val events = entries.map { convertToCalendarEvent(it) }
|
||||||
|
|
||||||
|
// Группируем события по дате
|
||||||
|
val groupedEvents = events.groupBy { it.date }
|
||||||
|
|
||||||
|
uiState = uiState.copy(
|
||||||
|
events = groupedEvents,
|
||||||
|
selectedDate = selectedDate,
|
||||||
|
currentMonth = selectedDate.withDayOfMonth(1),
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка: ${e.message}",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует CalendarEntry в CalendarEvent для совместимости в UI
|
||||||
|
*/
|
||||||
|
private fun convertToCalendarEvent(entry: CalendarEntry): CalendarEvent {
|
||||||
|
return CalendarEvent(
|
||||||
|
id = entry.id.toString(),
|
||||||
|
userId = entry.userId,
|
||||||
|
date = entry.entryDate,
|
||||||
|
type = entry.entryType.name.lowercase(),
|
||||||
|
flowIntensity = entry.flowIntensity?.name?.lowercase(),
|
||||||
|
mood = entry.mood?.name?.lowercase(),
|
||||||
|
energyLevel = entry.energyLevel,
|
||||||
|
sleepHours = entry.sleepHours,
|
||||||
|
symptoms = entry.symptoms?.map { it.name.lowercase() },
|
||||||
|
medications = entry.medications,
|
||||||
|
notes = entry.notes,
|
||||||
|
isDismissed = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка данных о цикле
|
||||||
|
*/
|
||||||
|
private fun loadCycleData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val cycleData = repository.getCycleData()
|
||||||
|
uiState = uiState.copy(cycleData = cycleData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ошибка загрузки данных цикла
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка статистики цикла
|
||||||
|
*/
|
||||||
|
private fun loadCycleStatistics() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response = repository.getCycleStatistics(userId)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val statistics = response.body()
|
||||||
|
uiState = uiState.copy(statistics = statistics)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ошибка загрузки статистики
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка прогнозов цикла
|
||||||
|
*/
|
||||||
|
private fun loadCyclePredictions() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response = repository.getCyclePredictions(userId)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val predictions = response.body()
|
||||||
|
uiState = uiState.copy(predictions = predictions)
|
||||||
|
} else {
|
||||||
|
val cachedPredictions = repository.getCachedPredictions(userId)
|
||||||
|
uiState = uiState.copy(predictions = cachedPredictions)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val cachedPredictions = repository.getCachedPredictions(userId)
|
||||||
|
uiState = uiState.copy(predictions = cachedPredictions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка инсайтов о здоровье
|
||||||
|
*/
|
||||||
|
private fun loadHealthInsights() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response = repository.getHealthInsights()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val insights = response.body() ?: emptyList()
|
||||||
|
uiState = uiState.copy(insights = insights)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ошибка загрузки инсайтов
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор даты
|
||||||
|
*/
|
||||||
|
fun selectDate(date: LocalDate) {
|
||||||
|
uiState = uiState.copy(selectedDate = date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Изменение отображаемого месяца
|
||||||
|
*/
|
||||||
|
fun changeMonth(offset: Int) {
|
||||||
|
val newCurrentMonth = uiState.currentMonth.plusMonths(offset.toLong())
|
||||||
|
loadCalendarData(newCurrentMonth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор события для просмотра деталей
|
||||||
|
*/
|
||||||
|
fun selectEvent(event: CalendarEvent) {
|
||||||
|
uiState = uiState.copy(selectedEvent = event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очистка выбранного события
|
||||||
|
*/
|
||||||
|
fun clearSelectedEvent() {
|
||||||
|
uiState = uiState.copy(selectedEvent = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать диалог добавления/редактирования события
|
||||||
|
*/
|
||||||
|
fun showEventDialog(event: CalendarEvent? = null) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
showEventDialog = true,
|
||||||
|
editingEvent = event
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скрыть диалог добавления/редактирования события
|
||||||
|
*/
|
||||||
|
fun hideEventDialog() {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
showEventDialog = false,
|
||||||
|
editingEvent = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать диалог инсайтов
|
||||||
|
*/
|
||||||
|
fun showInsightsDialog() {
|
||||||
|
uiState = uiState.copy(showInsightsDialog = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скрыть диалог инсайтов
|
||||||
|
*/
|
||||||
|
fun hideInsightsDialog() {
|
||||||
|
uiState = uiState.copy(showInsightsDialog = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать диалог настроек
|
||||||
|
*/
|
||||||
|
fun showSettingsDialog() {
|
||||||
|
uiState = uiState.copy(showSettingsDialog = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скрыть диалог настроек
|
||||||
|
*/
|
||||||
|
fun hideSettingsDialog() {
|
||||||
|
uiState = uiState.copy(showSettingsDialog = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавление нового события
|
||||||
|
*/
|
||||||
|
fun addCalendarEntry(entry: CalendarEntry) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true)
|
||||||
|
try {
|
||||||
|
val result = repository.addCalendarEntry(entry)
|
||||||
|
if (result.isSuccess) {
|
||||||
|
loadCalendarData(entry.entryDate)
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка при добавлении события",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка: ${e.message}",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновление существующего события
|
||||||
|
*/
|
||||||
|
fun updateCalendarEntry(entry: CalendarEntry) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true)
|
||||||
|
try {
|
||||||
|
val result = repository.updateCalendarEntry(entry)
|
||||||
|
if (result.isSuccess) {
|
||||||
|
loadCalendarData(entry.entryDate)
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка при обновлении события",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка: ${e.message}",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление события
|
||||||
|
*/
|
||||||
|
fun deleteCalendarEntry(entryId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true)
|
||||||
|
try {
|
||||||
|
val result = repository.deleteCalendarEntry(entryId)
|
||||||
|
if (result.isSuccess) {
|
||||||
|
loadCalendarData(uiState.selectedDate)
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка при удалении события",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка: ${e.message}",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отметка инсайта как прочитанного/отклоненного
|
||||||
|
*/
|
||||||
|
fun dismissInsight(insightId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.dismissInsight(insightId)
|
||||||
|
loadHealthInsights()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Обработка ошибки без блокировки интерфейса
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очистка сообщения об ошибке
|
||||||
|
*/
|
||||||
|
fun clearError() {
|
||||||
|
uiState = uiState.copy(error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.example.womansafe.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.womansafe.data.model.UserUpdate
|
||||||
|
import com.example.womansafe.data.repository.ApiRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class ProfileSettingsUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val username: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val firstName: String = "",
|
||||||
|
val lastName: String = "",
|
||||||
|
val phone: String = "",
|
||||||
|
val bio: String = "",
|
||||||
|
val locationSharingEnabled: Boolean = false,
|
||||||
|
val pushNotificationsEnabled: Boolean = true,
|
||||||
|
val emailNotificationsEnabled: Boolean = false,
|
||||||
|
val emergencyNotificationsEnabled: Boolean = true,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class ProfileSettingsViewModel : ViewModel() {
|
||||||
|
private val repository = ApiRepository()
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(ProfileSettingsUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun loadProfile() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
try {
|
||||||
|
val response = repository.getCurrentUser()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val user = response.body()
|
||||||
|
user?.let {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
username = it.username ?: "",
|
||||||
|
email = it.email,
|
||||||
|
firstName = it.first_name ?: "",
|
||||||
|
lastName = it.last_name ?: "",
|
||||||
|
phone = it.phone ?: "",
|
||||||
|
bio = it.bio ?: "",
|
||||||
|
locationSharingEnabled = it.location_sharing_enabled,
|
||||||
|
pushNotificationsEnabled = it.push_notifications_enabled,
|
||||||
|
emailNotificationsEnabled = it.email_notifications_enabled ?: false,
|
||||||
|
emergencyNotificationsEnabled = it.emergency_notifications_enabled,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка загрузки профиля: ${response.code()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка сети: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProfile(userUpdate: UserUpdate) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response = repository.updateCurrentUser(userUpdate)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
loadProfile() // Перезагружаем профиль
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка обновления профиля: ${response.code()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка обновления профиля: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changePassword(currentPassword: String, newPassword: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val response = repository.changePassword(currentPassword, newPassword)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
// Успешно изменен пароль
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка смены пароля: ${response.code()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка смены пароля: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateLocationSharing(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val userUpdate = UserUpdate(location_sharing_enabled = enabled)
|
||||||
|
val response = repository.updateCurrentUser(userUpdate)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
uiState = uiState.copy(locationSharingEnabled = enabled)
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка обновления настроек: ${response.code()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка обновления настроек: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNotificationSettings(
|
||||||
|
push: Boolean? = null,
|
||||||
|
email: Boolean? = null,
|
||||||
|
emergency: Boolean? = null
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val userUpdate = UserUpdate(
|
||||||
|
push_notifications_enabled = push,
|
||||||
|
email_notifications_enabled = email,
|
||||||
|
emergency_notifications_enabled = emergency
|
||||||
|
)
|
||||||
|
val response = repository.updateCurrentUser(userUpdate)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
pushNotificationsEnabled = push ?: uiState.pushNotificationsEnabled,
|
||||||
|
emailNotificationsEnabled = email ?: uiState.emailNotificationsEnabled,
|
||||||
|
emergencyNotificationsEnabled = emergency ?: uiState.emergencyNotificationsEnabled
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка обновления уведомлений: ${response.code()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Ошибка обновления уведомлений: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/src/main/java/com/example/womansafe/util/ContactsHelper.kt
Normal file
107
app/src/main/java/com/example/womansafe/util/ContactsHelper.kt
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package com.example.womansafe.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для работы с контактами устройства
|
||||||
|
*/
|
||||||
|
class ContactsHelper(private val context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс модели контакта
|
||||||
|
*/
|
||||||
|
data class Contact(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val phoneNumber: String,
|
||||||
|
val photoUri: Uri? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение списка контактов устройства
|
||||||
|
*/
|
||||||
|
suspend fun getContacts(): List<Contact> = withContext(Dispatchers.IO) {
|
||||||
|
val contacts = mutableListOf<Contact>()
|
||||||
|
|
||||||
|
val projection = arrayOf(
|
||||||
|
ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
|
||||||
|
ContactsContract.Contacts.DISPLAY_NAME,
|
||||||
|
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||||
|
ContactsContract.Contacts.PHOTO_URI
|
||||||
|
)
|
||||||
|
|
||||||
|
val cursor: Cursor? = context.contentResolver.query(
|
||||||
|
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||||
|
projection,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
ContactsContract.Contacts.DISPLAY_NAME + " ASC"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor?.use {
|
||||||
|
val idColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
|
||||||
|
val nameColumn = it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
|
||||||
|
val numberColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||||
|
val photoColumn = it.getColumnIndex(ContactsContract.Contacts.PHOTO_URI)
|
||||||
|
|
||||||
|
while (it.moveToNext()) {
|
||||||
|
val id = it.getString(idColumn)
|
||||||
|
val name = it.getString(nameColumn) ?: "Неизвестно"
|
||||||
|
val number = it.getString(numberColumn) ?: "Нет номера"
|
||||||
|
val photoUri = it.getString(photoColumn)?.let { uri -> Uri.parse(uri) }
|
||||||
|
|
||||||
|
contacts.add(Contact(id, name, number, photoUri))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext contacts.distinctBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение контакта по ID
|
||||||
|
*/
|
||||||
|
suspend fun getContactById(contactId: String): Contact? = withContext(Dispatchers.IO) {
|
||||||
|
val projection = arrayOf(
|
||||||
|
ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
|
||||||
|
ContactsContract.Contacts.DISPLAY_NAME,
|
||||||
|
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||||
|
ContactsContract.Contacts.PHOTO_URI
|
||||||
|
)
|
||||||
|
|
||||||
|
val selection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?"
|
||||||
|
val selectionArgs = arrayOf(contactId)
|
||||||
|
|
||||||
|
val cursor: Cursor? = context.contentResolver.query(
|
||||||
|
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||||
|
projection,
|
||||||
|
selection,
|
||||||
|
selectionArgs,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
var contact: Contact? = null
|
||||||
|
|
||||||
|
cursor?.use {
|
||||||
|
if (it.moveToFirst()) {
|
||||||
|
val idColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
|
||||||
|
val nameColumn = it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
|
||||||
|
val numberColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||||
|
val photoColumn = it.getColumnIndex(ContactsContract.Contacts.PHOTO_URI)
|
||||||
|
|
||||||
|
val id = it.getString(idColumn)
|
||||||
|
val name = it.getString(nameColumn) ?: "Неизвестно"
|
||||||
|
val number = it.getString(numberColumn) ?: "Нет номера"
|
||||||
|
val photoUri = it.getString(photoColumn)?.let { uri -> Uri.parse(uri) }
|
||||||
|
|
||||||
|
contact = Contact(id, name, number, photoUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext contact
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/src/main/java/com/example/womansafe/util/DateUtils.kt
Normal file
130
app/src/main/java/com/example/womansafe/util/DateUtils.kt
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package com.example.womansafe.util
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import java.text.ParseException
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.Period
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Утилитарный класс для работы с датами на устройствах с разными версиями API
|
||||||
|
*/
|
||||||
|
object DateUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует LocalDate в строку с учетом API устройства
|
||||||
|
*/
|
||||||
|
fun formatDate(date: LocalDate?, pattern: String): String {
|
||||||
|
date ?: return ""
|
||||||
|
|
||||||
|
// Для устройств с API 26+ используем Java 8 Time API
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val formatter = DateTimeFormatter.ofPattern(pattern)
|
||||||
|
date.format(formatter)
|
||||||
|
} else {
|
||||||
|
// Для более старых устройств используем легаси API
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
calendar.set(date.year, date.monthValue - 1, date.dayOfMonth)
|
||||||
|
|
||||||
|
val format = SimpleDateFormat(pattern, Locale.getDefault())
|
||||||
|
format.format(calendar.time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, являются ли две даты одним и тем же днем
|
||||||
|
*/
|
||||||
|
fun isSameDay(date1: LocalDate, date2: LocalDate): Boolean {
|
||||||
|
return date1.year == date2.year &&
|
||||||
|
date1.monthValue == date2.monthValue &&
|
||||||
|
date1.dayOfMonth == date2.dayOfMonth
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит строку в LocalDate с учетом API устройства
|
||||||
|
* @param dateStr строка с датой
|
||||||
|
* @param pattern шаблон формата даты
|
||||||
|
* @return LocalDate или null, если парсинг не удался
|
||||||
|
*/
|
||||||
|
fun parseDate(dateStr: String?, pattern: String): LocalDate? {
|
||||||
|
dateStr ?: return null
|
||||||
|
if (dateStr.isBlank()) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val formatter = DateTimeFormatter.ofPattern(pattern)
|
||||||
|
LocalDate.parse(dateStr, formatter)
|
||||||
|
} else {
|
||||||
|
val format = SimpleDateFormat(pattern, Locale.getDefault())
|
||||||
|
val date = format.parse(dateStr) ?: return null
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
calendar.time = date
|
||||||
|
LocalDate.of(
|
||||||
|
calendar.get(Calendar.YEAR),
|
||||||
|
calendar.get(Calendar.MONTH) + 1,
|
||||||
|
calendar.get(Calendar.DAY_OF_MONTH)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: DateTimeParseException) {
|
||||||
|
return null
|
||||||
|
} catch (e: ParseException) {
|
||||||
|
return null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает текущую дату
|
||||||
|
* @return текущая дата как LocalDate
|
||||||
|
*/
|
||||||
|
fun getCurrentDate(): LocalDate {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
LocalDate.now()
|
||||||
|
} else {
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
LocalDate.of(
|
||||||
|
calendar.get(Calendar.YEAR),
|
||||||
|
calendar.get(Calendar.MONTH) + 1,
|
||||||
|
calendar.get(Calendar.DAY_OF_MONTH)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вычисляет разницу в днях между двумя датами
|
||||||
|
* @param startDate начальная дата
|
||||||
|
* @param endDate конечная дата
|
||||||
|
* @return количество дней между датами (может быть отрицательным)
|
||||||
|
*/
|
||||||
|
fun daysBetween(startDate: LocalDate, endDate: LocalDate): Int {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Period.between(startDate, endDate).days + Period.between(startDate, endDate).months * 30 + Period.between(startDate, endDate).years * 365
|
||||||
|
} else {
|
||||||
|
val startCalendar = Calendar.getInstance()
|
||||||
|
startCalendar.set(startDate.year, startDate.monthValue - 1, startDate.dayOfMonth)
|
||||||
|
|
||||||
|
val endCalendar = Calendar.getInstance()
|
||||||
|
endCalendar.set(endDate.year, endDate.monthValue - 1, endDate.dayOfMonth)
|
||||||
|
|
||||||
|
val diffInMillis = endCalendar.timeInMillis - startCalendar.timeInMillis
|
||||||
|
TimeUnit.MILLISECONDS.toDays(diffInMillis).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, находится ли дата между двумя другими датами (включительно)
|
||||||
|
* @param date проверяемая дата
|
||||||
|
* @param startDate начальная дата диапазона
|
||||||
|
* @param endDate конечная дата диапазона
|
||||||
|
* @return true, если дата находится в диапазоне (включительно)
|
||||||
|
*/
|
||||||
|
fun isDateInRange(date: LocalDate, startDate: LocalDate, endDate: LocalDate): Boolean {
|
||||||
|
return (date.isEqual(startDate) || date.isAfter(startDate)) &&
|
||||||
|
(date.isEqual(endDate) || date.isBefore(endDate))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.example.womansafe.util
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для управления разрешениями в приложении
|
||||||
|
*/
|
||||||
|
class PermissionManager(private val context: Context) {
|
||||||
|
|
||||||
|
private val preferenceManager = PreferenceManager.getInstance(context)
|
||||||
|
|
||||||
|
// Проверка разрешения
|
||||||
|
fun isPermissionGranted(permission: String): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
permission
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение статуса разрешения
|
||||||
|
fun savePermissionStatus(permission: String, granted: Boolean) {
|
||||||
|
preferenceManager.savePermissionGranted(permission, granted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение сохраненного статуса разрешения
|
||||||
|
fun getSavedPermissionStatus(permission: String): Boolean {
|
||||||
|
return preferenceManager.isPermissionGranted(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение списка необходимых разрешений
|
||||||
|
fun getRequiredPermissions(): List<String> {
|
||||||
|
return listOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||||
|
Manifest.permission.CAMERA,
|
||||||
|
Manifest.permission.READ_CONTACTS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение непредоставленных разрешений
|
||||||
|
fun getMissingPermissions(): List<String> {
|
||||||
|
return getRequiredPermissions().filter { !isPermissionGranted(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие настроек приложения для управления разрешениями
|
||||||
|
fun openAppSettings() {
|
||||||
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||||
|
val uri = Uri.fromParts("package", context.packageName, null)
|
||||||
|
intent.data = uri
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Разрешения
|
||||||
|
const val PERMISSION_LOCATION = Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
const val PERMISSION_CAMERA = Manifest.permission.CAMERA
|
||||||
|
const val PERMISSION_CONTACTS = Manifest.permission.READ_CONTACTS
|
||||||
|
|
||||||
|
// Синглтон для доступа к PermissionManager
|
||||||
|
@Volatile private var INSTANCE: PermissionManager? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): PermissionManager {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: PermissionManager(context).also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable для запроса разрешений
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RequestPermissions(
|
||||||
|
permissions: List<String>,
|
||||||
|
onAllPermissionsGranted: () -> Unit = {},
|
||||||
|
onPermissionDenied: (List<String>) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val permissionManager = remember { PermissionManager.getInstance(context) }
|
||||||
|
|
||||||
|
// Проверка всех разрешений
|
||||||
|
val allPermissionsGranted = permissions.all {
|
||||||
|
permissionManager.isPermissionGranted(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрашиваем разрешения, если они не предоставлены
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { permissionsMap ->
|
||||||
|
// Сохраняем статусы разрешений
|
||||||
|
permissionsMap.forEach { (permission, granted) ->
|
||||||
|
permissionManager.savePermissionStatus(permission, granted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, все ли разрешения предоставлены
|
||||||
|
val deniedPermissions = permissionsMap.filterValues { !it }.keys.toList()
|
||||||
|
if (deniedPermissions.isEmpty()) {
|
||||||
|
onAllPermissionsGranted()
|
||||||
|
} else {
|
||||||
|
onPermissionDenied(deniedPermissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрашиваем необходимые разрешения при первом запуске composable
|
||||||
|
LaunchedEffect(permissions) {
|
||||||
|
if (!allPermissionsGranted) {
|
||||||
|
launcher.launch(permissions.toTypedArray())
|
||||||
|
} else {
|
||||||
|
onAllPermissionsGranted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.example.womansafe.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для управления безопасным хранением данных пользователя
|
||||||
|
*/
|
||||||
|
class PreferenceManager(context: Context) {
|
||||||
|
|
||||||
|
private val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val sharedPreferences: SharedPreferences = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
PREFERENCE_NAME,
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сохранение токена
|
||||||
|
fun saveAuthToken(token: String?) {
|
||||||
|
sharedPreferences.edit().putString(KEY_AUTH_TOKEN, token).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение токена
|
||||||
|
fun getAuthToken(): String? {
|
||||||
|
return sharedPreferences.getString(KEY_AUTH_TOKEN, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение разрешений
|
||||||
|
fun savePermissionGranted(permission: String, granted: Boolean) {
|
||||||
|
sharedPreferences.edit().putBoolean(permission, granted).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение статуса разрешения
|
||||||
|
fun isPermissionGranted(permission: String): Boolean {
|
||||||
|
return sharedPreferences.getBoolean(permission, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка данных при выходе
|
||||||
|
fun clearAuthData() {
|
||||||
|
sharedPreferences.edit().remove(KEY_AUTH_TOKEN).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFERENCE_NAME = "woman_safe_prefs"
|
||||||
|
private const val KEY_AUTH_TOKEN = "auth_token"
|
||||||
|
|
||||||
|
// Ключи для отслеживания разрешений
|
||||||
|
const val PERMISSION_LOCATION = "permission_location"
|
||||||
|
const val PERMISSION_CAMERA = "permission_camera"
|
||||||
|
const val PERMISSION_CONTACTS = "permission_contacts"
|
||||||
|
|
||||||
|
// Синглтон для доступа к PreferenceManager
|
||||||
|
@Volatile private var INSTANCE: PreferenceManager? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): PreferenceManager {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: PreferenceManager(context.applicationContext).also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.example.womansafe.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модификатор, который исправляет проблему с ACTION_HOVER_EXIT в Jetpack Compose
|
||||||
|
*/
|
||||||
|
fun Modifier.fixTouchEvents(): Modifier = composed {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
|
this.pointerInput(interactionSource) {
|
||||||
|
awaitEachGesture {
|
||||||
|
// Просто ждем первое нажатие и отпускание, чтобы гарантировать правильную очистку событий
|
||||||
|
awaitFirstDown(requireUnconsumed = false)
|
||||||
|
waitForUpOrCancellation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/src/main/java/com/example/womansafe/utils/DateUtils.kt
Normal file
59
app/src/main/java/com/example/womansafe/utils/DateUtils.kt
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package com.example.womansafe.utils
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вспомогательный класс для работы с датами
|
||||||
|
*/
|
||||||
|
object DateUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматтер для отображения дат в удобном формате
|
||||||
|
*/
|
||||||
|
val READABLE_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматтер для отображения месяца и года
|
||||||
|
*/
|
||||||
|
val MONTH_YEAR_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MMMM yyyy")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, находится ли дата в указанном диапазоне (включая начало и конец)
|
||||||
|
*/
|
||||||
|
fun isDateInRange(date: LocalDate, start: LocalDate?, end: LocalDate?): Boolean {
|
||||||
|
if (start == null || end == null) return false
|
||||||
|
return (date.isEqual(start) || date.isAfter(start)) &&
|
||||||
|
(date.isEqual(end) || date.isBefore(end))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вычисляет количество дней между двумя датами
|
||||||
|
*/
|
||||||
|
fun daysBetween(start: LocalDate, end: LocalDate): Long {
|
||||||
|
return ChronoUnit.DAYS.between(start, end).absoluteValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, является ли дата сегодняшней
|
||||||
|
*/
|
||||||
|
fun isToday(date: LocalDate): Boolean {
|
||||||
|
return date.isEqual(LocalDate.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует дату в читаемый формат
|
||||||
|
*/
|
||||||
|
fun formatReadableDate(date: LocalDate?): String {
|
||||||
|
return date?.format(READABLE_DATE_FORMATTER) ?: "Нет данных"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует месяц и год
|
||||||
|
*/
|
||||||
|
fun formatMonthYear(date: LocalDate): String {
|
||||||
|
return date.format(MONTH_YEAR_FORMATTER)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/src/main/res/xml/network_security_config.xml
Normal file
9
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="false">192.168.0.112</domain>
|
||||||
|
<domain includeSubdomains="false">192.168.0.103</domain>
|
||||||
|
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||||
|
<domain includeSubdomains="false">localhost</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -2,5 +2,4 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
|
||||||
}
|
}
|
||||||
56
docs/CALENDAR_API_RESPONSE.json
Normal file
56
docs/CALENDAR_API_RESPONSE.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": "uuid-1",
|
||||||
|
"date": "2025-09-10",
|
||||||
|
"type": "MENSTRUATION",
|
||||||
|
"mood": "GOOD",
|
||||||
|
"symptoms": ["CRAMPS", "FATIGUE"],
|
||||||
|
"notes": "День начала цикла",
|
||||||
|
"flow_intensity": 3,
|
||||||
|
"is_predicted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid-2",
|
||||||
|
"date": "2025-09-11",
|
||||||
|
"type": "MENSTRUATION",
|
||||||
|
"mood": "NEUTRAL",
|
||||||
|
"symptoms": ["CRAMPS"],
|
||||||
|
"notes": "",
|
||||||
|
"flow_intensity": 2,
|
||||||
|
"is_predicted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid-3",
|
||||||
|
"date": "2025-09-12",
|
||||||
|
"type": "MENSTRUATION",
|
||||||
|
"mood": "GOOD",
|
||||||
|
"symptoms": [],
|
||||||
|
"notes": "",
|
||||||
|
"flow_intensity": 1,
|
||||||
|
"is_predicted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid-4",
|
||||||
|
"date": "2025-10-08",
|
||||||
|
"type": "MENSTRUATION",
|
||||||
|
"is_predicted": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid-5",
|
||||||
|
"date": "2025-09-24",
|
||||||
|
"type": "OVULATION",
|
||||||
|
"is_predicted": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cycle_info": {
|
||||||
|
"average_cycle_length": 28,
|
||||||
|
"average_period_length": 5,
|
||||||
|
"last_period_start": "2025-09-10",
|
||||||
|
"next_period_predicted": "2025-10-08",
|
||||||
|
"next_ovulation_predicted": "2025-09-24",
|
||||||
|
"fertile_window_start": "2025-09-21",
|
||||||
|
"fertile_window_end": "2025-09-25"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.13.0"
|
agp = "8.13.0"
|
||||||
kotlin = "2.0.21"
|
kotlin = "1.9.20"
|
||||||
coreKtx = "1.10.1"
|
coreKtx = "1.10.1"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.1.5"
|
junitVersion = "1.1.5"
|
||||||
espressoCore = "3.5.1"
|
espressoCore = "3.5.1"
|
||||||
lifecycleRuntimeKtx = "2.6.1"
|
lifecycleRuntimeKtx = "2.6.1"
|
||||||
activityCompose = "1.8.0"
|
activityCompose = "1.8.0"
|
||||||
composeBom = "2024.09.00"
|
composeBom = "2023.08.00"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -28,5 +28,3 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
|
|||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user