main commit
This commit is contained in:
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@@ -4,6 +4,14 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DropdownSelection timestamp="2025-09-25T20:36:01.018251800Z">
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="LocalEmulator" identifier="path=/home/trevor/.android/avd/Medium_Phone.avd" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</DropdownSelection>
|
||||||
|
<DialogSelection />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ plugins {
|
|||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -28,35 +31,34 @@ 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.1"
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(platform("androidx.compose:compose-bom:2023.08.00"))
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation(libs.androidx.compose.material3)
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
|
|
||||||
// JSON
|
|
||||||
implementation("com.google.code.gson:gson:2.10.1")
|
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||||
@@ -64,17 +66,24 @@ dependencies {
|
|||||||
// ViewModel
|
// ViewModel
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||||
|
|
||||||
// Coroutines
|
// Networking
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
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")
|
||||||
|
|
||||||
// DataStore for preferences
|
// Location Services
|
||||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
implementation("com.google.android.gms:play-services-location:21.0.1")
|
||||||
|
implementation("com.google.android.gms:play-services-maps:18.2.0")
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
// Permissions
|
||||||
androidTestImplementation(libs.androidx.junit)
|
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
// Testing
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
testImplementation("junit:junit:4.13.2")
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
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")
|
||||||
}
|
}
|
||||||
@@ -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,9 @@
|
|||||||
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">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -4,44 +4,36 @@ 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.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
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)
|
||||||
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)
|
) {
|
||||||
)
|
// Показываем либо экран авторизации, либо главный экран
|
||||||
|
if (authViewModel.uiState.isLoggedIn) {
|
||||||
|
MainScreen(authViewModel = authViewModel)
|
||||||
|
} else {
|
||||||
|
AuthScreen(viewModel = authViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
|
||||||
Text(
|
|
||||||
text = "Hello $name!",
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun GreetingPreview() {
|
|
||||||
WomanSafeTheme {
|
|
||||||
Greeting("Android")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -5,17 +5,16 @@ import retrofit2.Response
|
|||||||
import retrofit2.http.*
|
import retrofit2.http.*
|
||||||
|
|
||||||
interface WomanSafeApi {
|
interface WomanSafeApi {
|
||||||
|
|
||||||
// Authentication endpoints
|
// Authentication endpoints
|
||||||
@POST("api/v1/auth/login")
|
@POST("api/v1/auth/login")
|
||||||
suspend fun login(@Body request: ApiRequestBody): Response<Token>
|
suspend fun login(@Body request: UserLogin): Response<Token>
|
||||||
|
|
||||||
@POST("api/v1/auth/register")
|
@POST("api/v1/auth/register")
|
||||||
suspend fun register(@Body request: ApiRequestBody): Response<UserResponse>
|
suspend fun register(@Body request: UserCreate): Response<UserResponse>
|
||||||
|
|
||||||
// User endpoints
|
// User endpoints
|
||||||
@GET("api/v1/users/me")
|
@GET("api/v1/users/me")
|
||||||
suspend fun getCurrentUser(@Body request: ApiRequestBody = ApiRequestBody()): Response<UserResponse>
|
suspend fun getCurrentUser(): Response<UserResponse>
|
||||||
|
|
||||||
@PUT("api/v1/users/me")
|
@PUT("api/v1/users/me")
|
||||||
suspend fun updateCurrentUser(@Body request: ApiRequestBody): Response<UserResponse>
|
suspend fun updateCurrentUser(@Body request: ApiRequestBody): Response<UserResponse>
|
||||||
@@ -27,41 +26,32 @@ interface WomanSafeApi {
|
|||||||
suspend fun changePassword(@Body request: ApiRequestBody): Response<Unit>
|
suspend fun changePassword(@Body request: ApiRequestBody): Response<Unit>
|
||||||
|
|
||||||
@GET("api/v1/users/dashboard")
|
@GET("api/v1/users/dashboard")
|
||||||
suspend fun getDashboard(@Body request: ApiRequestBody = ApiRequestBody()): Response<Any>
|
suspend fun getDashboard(): Response<Any>
|
||||||
|
|
||||||
// Profile endpoints
|
// Profile endpoints
|
||||||
@GET("api/v1/profile")
|
@GET("api/v1/profile")
|
||||||
suspend fun getProfile(@Body request: ApiRequestBody = ApiRequestBody()): Response<UserResponse>
|
suspend fun getProfile(): Response<UserResponse>
|
||||||
|
|
||||||
@PUT("api/v1/profile")
|
@PUT("api/v1/profile")
|
||||||
suspend fun updateProfile(@Body request: ApiRequestBody): Response<UserResponse>
|
suspend fun updateProfile(@Body request: ApiRequestBody): Response<UserResponse>
|
||||||
|
|
||||||
// Emergency Contacts endpoints
|
// Emergency Contacts endpoints
|
||||||
@GET("api/v1/users/me/emergency-contacts")
|
@GET("api/v1/users/me/emergency-contacts")
|
||||||
suspend fun getEmergencyContacts(@Body request: ApiRequestBody = ApiRequestBody()): Response<List<EmergencyContactResponse>>
|
suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>>
|
||||||
|
|
||||||
@POST("api/v1/users/me/emergency-contacts")
|
@POST("api/v1/users/me/emergency-contacts")
|
||||||
suspend fun createEmergencyContact(@Body request: ApiRequestBody): Response<EmergencyContactResponse>
|
suspend fun createEmergencyContact(@Body request: EmergencyContactCreate): Response<EmergencyContactResponse>
|
||||||
|
|
||||||
@GET("api/v1/users/me/emergency-contacts/{contact_id}")
|
@GET("api/v1/users/me/emergency-contacts/{contact_id}")
|
||||||
suspend fun getEmergencyContact(
|
suspend fun getEmergencyContact(@Path("contact_id") contactId: String): Response<EmergencyContactResponse>
|
||||||
@Path("contact_id") contactId: String,
|
|
||||||
@Body request: ApiRequestBody = ApiRequestBody()
|
|
||||||
): Response<EmergencyContactResponse>
|
|
||||||
|
|
||||||
@PATCH("api/v1/users/me/emergency-contacts/{contact_id}")
|
@PATCH("api/v1/users/me/emergency-contacts/{contact_id}")
|
||||||
suspend fun updateEmergencyContact(
|
suspend fun updateEmergencyContact(@Path("contact_id") contactId: String, @Body request: ApiRequestBody): Response<EmergencyContactResponse>
|
||||||
@Path("contact_id") contactId: String,
|
|
||||||
@Body request: ApiRequestBody
|
|
||||||
): Response<EmergencyContactResponse>
|
|
||||||
|
|
||||||
@DELETE("api/v1/users/me/emergency-contacts/{contact_id}")
|
@DELETE("api/v1/users/me/emergency-contacts/{contact_id}")
|
||||||
suspend fun deleteEmergencyContact(
|
suspend fun deleteEmergencyContact(@Path("contact_id") contactId: String): Response<Unit>
|
||||||
@Path("contact_id") contactId: String,
|
|
||||||
@Body request: ApiRequestBody = ApiRequestBody()
|
|
||||||
): Response<Unit>
|
|
||||||
|
|
||||||
// Emergency endpoints
|
// Emergency Reports endpoints
|
||||||
@GET("api/v1/emergency/reports")
|
@GET("api/v1/emergency/reports")
|
||||||
suspend fun getEmergencyReports(): Response<Any>
|
suspend fun getEmergencyReports(): Response<Any>
|
||||||
|
|
||||||
@@ -82,13 +72,13 @@ interface WomanSafeApi {
|
|||||||
|
|
||||||
// Emergency Alerts endpoints
|
// Emergency Alerts endpoints
|
||||||
@GET("api/v1/emergency/alerts")
|
@GET("api/v1/emergency/alerts")
|
||||||
suspend fun getEmergencyAlerts(): Response<Any>
|
suspend fun getEmergencyAlerts(): Response<List<EmergencyAlertResponse>>
|
||||||
|
|
||||||
@POST("api/v1/emergency/alerts")
|
@POST("api/v1/emergency/alerts")
|
||||||
suspend fun createEmergencyAlert(): Response<Any>
|
suspend fun createEmergencyAlert(@Body request: EmergencyAlertCreate): Response<EmergencyAlertResponse>
|
||||||
|
|
||||||
@GET("api/v1/emergency/alerts/my")
|
@GET("api/v1/emergency/alerts/my")
|
||||||
suspend fun getMyEmergencyAlerts(): Response<Any>
|
suspend fun getMyEmergencyAlerts(): Response<List<EmergencyAlertResponse>>
|
||||||
|
|
||||||
@GET("api/v1/emergency/alerts/nearby")
|
@GET("api/v1/emergency/alerts/nearby")
|
||||||
suspend fun getNearbyEmergencyAlerts(): Response<Any>
|
suspend fun getNearbyEmergencyAlerts(): Response<Any>
|
||||||
@@ -199,6 +189,6 @@ interface WomanSafeApi {
|
|||||||
@GET("api/v1/services-status")
|
@GET("api/v1/services-status")
|
||||||
suspend fun getServicesStatus(): Response<Any>
|
suspend fun getServicesStatus(): Response<Any>
|
||||||
|
|
||||||
@GET("")
|
@GET("/")
|
||||||
suspend fun getRoot(): Response<Any>
|
suspend fun getRoot(): Response<Any>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,99 +2,22 @@ package com.example.womansafe.data.model
|
|||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
// Authentication models
|
// 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(
|
data class UserLogin(
|
||||||
val email: String? = null,
|
val email: String? = null,
|
||||||
val username: String? = null,
|
val username: String? = null,
|
||||||
val password: String
|
val password: String
|
||||||
)
|
)
|
||||||
|
|
||||||
data class UserCreate(
|
|
||||||
val email: String,
|
|
||||||
val username: String? = null,
|
|
||||||
val phone: String? = null,
|
|
||||||
@SerializedName("phone_number")
|
|
||||||
val phoneNumber: String? = null,
|
|
||||||
@SerializedName("first_name")
|
|
||||||
val firstName: String? = "",
|
|
||||||
@SerializedName("last_name")
|
|
||||||
val lastName: String? = "",
|
|
||||||
@SerializedName("full_name")
|
|
||||||
val fullName: String? = null,
|
|
||||||
@SerializedName("date_of_birth")
|
|
||||||
val dateOfBirth: String? = null,
|
|
||||||
val bio: String? = null,
|
|
||||||
val password: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UserUpdate(
|
|
||||||
@SerializedName("first_name")
|
|
||||||
val firstName: String? = null,
|
|
||||||
@SerializedName("last_name")
|
|
||||||
val lastName: String? = null,
|
|
||||||
val phone: String? = null,
|
|
||||||
@SerializedName("date_of_birth")
|
|
||||||
val dateOfBirth: String? = null,
|
|
||||||
val bio: String? = null,
|
|
||||||
@SerializedName("avatar_url")
|
|
||||||
val avatarUrl: String? = null,
|
|
||||||
@SerializedName("emergency_contact_1_name")
|
|
||||||
val emergencyContact1Name: String? = null,
|
|
||||||
@SerializedName("emergency_contact_1_phone")
|
|
||||||
val emergencyContact1Phone: String? = null,
|
|
||||||
@SerializedName("emergency_contact_2_name")
|
|
||||||
val emergencyContact2Name: String? = null,
|
|
||||||
@SerializedName("emergency_contact_2_phone")
|
|
||||||
val emergencyContact2Phone: String? = null,
|
|
||||||
@SerializedName("location_sharing_enabled")
|
|
||||||
val locationSharingEnabled: Boolean? = null,
|
|
||||||
@SerializedName("emergency_notifications_enabled")
|
|
||||||
val emergencyNotificationsEnabled: Boolean? = null,
|
|
||||||
@SerializedName("push_notifications_enabled")
|
|
||||||
val pushNotificationsEnabled: Boolean? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UserResponse(
|
|
||||||
val email: String,
|
|
||||||
val username: String? = null,
|
|
||||||
val phone: String? = null,
|
|
||||||
@SerializedName("phone_number")
|
|
||||||
val phoneNumber: String? = null,
|
|
||||||
@SerializedName("first_name")
|
|
||||||
val firstName: String? = "",
|
|
||||||
@SerializedName("last_name")
|
|
||||||
val lastName: String? = "",
|
|
||||||
@SerializedName("full_name")
|
|
||||||
val fullName: String? = null,
|
|
||||||
@SerializedName("date_of_birth")
|
|
||||||
val dateOfBirth: String? = null,
|
|
||||||
val bio: String? = null,
|
|
||||||
val id: Int,
|
|
||||||
val uuid: String,
|
|
||||||
@SerializedName("avatar_url")
|
|
||||||
val avatarUrl: String? = null,
|
|
||||||
@SerializedName("emergency_contact_1_name")
|
|
||||||
val emergencyContact1Name: String? = null,
|
|
||||||
@SerializedName("emergency_contact_1_phone")
|
|
||||||
val emergencyContact1Phone: String? = null,
|
|
||||||
@SerializedName("emergency_contact_2_name")
|
|
||||||
val emergencyContact2Name: String? = null,
|
|
||||||
@SerializedName("emergency_contact_2_phone")
|
|
||||||
val emergencyContact2Phone: String? = null,
|
|
||||||
@SerializedName("location_sharing_enabled")
|
|
||||||
val locationSharingEnabled: Boolean,
|
|
||||||
@SerializedName("emergency_notifications_enabled")
|
|
||||||
val emergencyNotificationsEnabled: Boolean,
|
|
||||||
@SerializedName("push_notifications_enabled")
|
|
||||||
val pushNotificationsEnabled: Boolean,
|
|
||||||
@SerializedName("email_verified")
|
|
||||||
val emailVerified: Boolean,
|
|
||||||
@SerializedName("phone_verified")
|
|
||||||
val phoneVerified: Boolean,
|
|
||||||
@SerializedName("is_active")
|
|
||||||
val isActive: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Token(
|
data class Token(
|
||||||
@SerializedName("access_token")
|
@SerializedName("access_token")
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
@@ -102,52 +25,335 @@ data class Token(
|
|||||||
val tokenType: String
|
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
|
// Emergency Contact models
|
||||||
data class EmergencyContactCreate(
|
data class EmergencyContactCreate(
|
||||||
val name: String,
|
val name: String,
|
||||||
@SerializedName("phone_number")
|
val phone_number: String,
|
||||||
val phoneNumber: String,
|
|
||||||
val relationship: String? = null,
|
val relationship: String? = null,
|
||||||
val notes: String? = null
|
val notes: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EmergencyContactUpdate(
|
data class EmergencyContactUpdate(
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
@SerializedName("phone_number")
|
val phone_number: String? = null,
|
||||||
val phoneNumber: String? = null,
|
|
||||||
val relationship: String? = null,
|
val relationship: String? = null,
|
||||||
val notes: String? = null
|
val notes: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EmergencyContactResponse(
|
data class EmergencyContactResponse(
|
||||||
val name: String,
|
|
||||||
@SerializedName("phone_number")
|
|
||||||
val phoneNumber: String,
|
|
||||||
val relationship: String? = null,
|
|
||||||
val notes: String? = null,
|
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val uuid: String,
|
val uuid: String,
|
||||||
@SerializedName("user_id")
|
val name: String,
|
||||||
val userId: Int
|
val phone_number: String,
|
||||||
|
val relationship: String? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val user_id: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
// API Request body wrapper
|
// Request body for different endpoints
|
||||||
data class ApiRequestBody(
|
data class RequestBody(
|
||||||
@SerializedName("user_create")
|
val user_create: UserCreate? = null,
|
||||||
val userCreate: UserCreate? = null,
|
val user_login: UserLogin? = null,
|
||||||
@SerializedName("user_login")
|
val user_update: UserUpdate? = null,
|
||||||
val userLogin: UserLogin? = null,
|
val emergency_contact_create: EmergencyContactCreate? = null,
|
||||||
@SerializedName("user_update")
|
val emergency_contact_update: EmergencyContactUpdate? = null
|
||||||
val userUpdate: UserUpdate? = null,
|
|
||||||
@SerializedName("emergency_contact_create")
|
|
||||||
val emergencyContactCreate: EmergencyContactCreate? = null,
|
|
||||||
@SerializedName("emergency_contact_update")
|
|
||||||
val emergencyContactUpdate: EmergencyContactUpdate? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error models
|
// 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 CalendarEntry(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CalendarEntryResponse(
|
||||||
|
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(
|
data class ValidationError(
|
||||||
val loc: List<String>,
|
val loc: List<Any>,
|
||||||
val msg: String,
|
val msg: String,
|
||||||
val type: String
|
val type: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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: Int? = 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()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Настройки цикла
|
||||||
|
data class CycleSettings(
|
||||||
|
val averageCycleLength: Int = 28, // Средняя длина цикла
|
||||||
|
val averagePeriodLength: Int = 5, // Средняя длина менструации
|
||||||
|
val lastPeriodStart: LocalDate? = null, // Последняя менструация
|
||||||
|
val reminderDaysBefore: Int = 2, // За сколько дней напоминать
|
||||||
|
val enablePredictions: Boolean = true,
|
||||||
|
val enableReminders: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Прогнозы цикла
|
||||||
|
data class CyclePrediction(
|
||||||
|
val nextPeriodStart: LocalDate,
|
||||||
|
val nextPeriodEnd: LocalDate,
|
||||||
|
val nextOvulation: LocalDate,
|
||||||
|
val fertileWindowStart: LocalDate,
|
||||||
|
val fertileWindowEnd: LocalDate,
|
||||||
|
val confidence: Float = 0.8f // Уверенность в прогнозе 0-1
|
||||||
|
)
|
||||||
|
|
||||||
|
// Статистика цикла
|
||||||
|
data class CycleStatistics(
|
||||||
|
val averageCycleLength: Float,
|
||||||
|
val cycleVariation: Float, // Отклонение в днях
|
||||||
|
val lastCycles: List<Int>, // Длины последних циклов
|
||||||
|
val periodLengthAverage: Float,
|
||||||
|
val commonSymptoms: List<SymptomType>,
|
||||||
|
val moodPatterns: Map<CalendarEventType, MoodType>
|
||||||
|
)
|
||||||
@@ -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?
|
||||||
|
)
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.example.womansafe.data.network
|
package com.example.womansafe.data.network
|
||||||
|
|
||||||
import com.google.gson.GsonBuilder
|
import com.example.womansafe.data.api.WomanSafeApi
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
@@ -9,22 +9,28 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
object NetworkClient {
|
object NetworkClient {
|
||||||
private const val BASE_URL = "http://10.0.2.2:8000/" // For Android Emulator
|
private var BASE_URL = "http://192.168.0.103:8000/"
|
||||||
// For real device, use: "http://YOUR_IP:8000/"
|
|
||||||
|
|
||||||
private var authToken: String? = null
|
private var authToken: String? = null
|
||||||
|
|
||||||
fun setAuthToken(token: String?) {
|
private val authInterceptor = Interceptor { chain ->
|
||||||
authToken = token
|
val requestBuilder = chain.request().newBuilder()
|
||||||
|
authToken?.let { token ->
|
||||||
|
requestBuilder.addHeader("Authorization", "Bearer $token")
|
||||||
}
|
}
|
||||||
|
|
||||||
private val authInterceptor = Interceptor { chain ->
|
// Debug logging
|
||||||
val request = chain.request().newBuilder()
|
val request = requestBuilder.build()
|
||||||
authToken?.let { token ->
|
println("=== API Request Debug ===")
|
||||||
request.addHeader("Authorization", "Bearer $token")
|
println("URL: ${request.url}")
|
||||||
}
|
println("Method: ${request.method}")
|
||||||
request.addHeader("Content-Type", "application/json")
|
println("Headers: ${request.headers}")
|
||||||
chain.proceed(request.build())
|
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
println("Response Code: ${response.code}")
|
||||||
|
println("Response Message: ${response.message}")
|
||||||
|
println("========================")
|
||||||
|
|
||||||
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||||
@@ -39,13 +45,20 @@ object NetworkClient {
|
|||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val gson = GsonBuilder()
|
val apiService: WomanSafeApi by lazy {
|
||||||
.setLenient()
|
Retrofit.Builder()
|
||||||
.create()
|
|
||||||
|
|
||||||
val retrofit: Retrofit = Retrofit.Builder()
|
|
||||||
.baseUrl(BASE_URL)
|
.baseUrl(BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
.build()
|
.build()
|
||||||
|
.create(WomanSafeApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAuthToken(token: String?) {
|
||||||
|
authToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBaseUrl(newUrl: String) {
|
||||||
|
BASE_URL = if (!newUrl.endsWith("/")) "$newUrl/" else newUrl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,277 +1,283 @@
|
|||||||
package com.example.womansafe.data.repository
|
package com.example.womansafe.data.repository
|
||||||
|
|
||||||
import com.example.womansafe.data.api.WomanSafeApi
|
|
||||||
import com.example.womansafe.data.model.*
|
import com.example.womansafe.data.model.*
|
||||||
import com.example.womansafe.data.network.NetworkClient
|
import com.example.womansafe.data.network.NetworkClient
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
|
|
||||||
class ApiRepository {
|
class ApiRepository {
|
||||||
private val api = NetworkClient.retrofit.create(WomanSafeApi::class.java)
|
private val apiService = NetworkClient.apiService
|
||||||
|
|
||||||
// Authentication methods
|
// Auth methods
|
||||||
suspend fun login(email: String?, username: String?, password: String): Response<Token> {
|
suspend fun login(email: String?, username: String?, password: String): Response<Token> {
|
||||||
val loginData = UserLogin(email = email, username = username, password = password)
|
val request = UserLogin(email, username, password)
|
||||||
val requestBody = ApiRequestBody(userLogin = loginData)
|
return apiService.login(request)
|
||||||
return api.login(requestBody)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun register(
|
suspend fun register(
|
||||||
email: String,
|
email: String,
|
||||||
username: String?,
|
username: String? = null,
|
||||||
password: String,
|
password: String,
|
||||||
fullName: String?,
|
fullName: String? = null,
|
||||||
phoneNumber: String?
|
phoneNumber: String? = null,
|
||||||
|
firstName: String? = null,
|
||||||
|
lastName: String? = null,
|
||||||
|
dateOfBirth: String? = null,
|
||||||
|
bio: String? = null
|
||||||
): Response<UserResponse> {
|
): Response<UserResponse> {
|
||||||
val userData = UserCreate(
|
val request = UserCreate(
|
||||||
email = email,
|
email = email,
|
||||||
username = username,
|
username = username,
|
||||||
password = password,
|
password = password,
|
||||||
fullName = fullName,
|
full_name = fullName,
|
||||||
phoneNumber = phoneNumber
|
phone_number = phoneNumber,
|
||||||
|
first_name = firstName,
|
||||||
|
last_name = lastName,
|
||||||
|
date_of_birth = dateOfBirth,
|
||||||
|
bio = bio
|
||||||
)
|
)
|
||||||
val requestBody = ApiRequestBody(userCreate = userData)
|
return apiService.register(request)
|
||||||
return api.register(requestBody)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// User methods
|
// User methods
|
||||||
suspend fun getCurrentUser(): Response<UserResponse> {
|
suspend fun getCurrentUser(): Response<UserResponse> {
|
||||||
return api.getCurrentUser()
|
return apiService.getCurrentUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateUser(userUpdate: UserUpdate): Response<UserResponse> {
|
suspend fun updateCurrentUser(userUpdate: UserUpdate): Response<UserResponse> {
|
||||||
val requestBody = ApiRequestBody(userUpdate = userUpdate)
|
val body = ApiRequestBody(user_update = userUpdate)
|
||||||
return api.updateCurrentUser(requestBody)
|
return apiService.updateCurrentUser(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun patchUser(userUpdate: UserUpdate): Response<UserResponse> {
|
suspend fun patchCurrentUser(userUpdate: UserUpdate): Response<UserResponse> {
|
||||||
val requestBody = ApiRequestBody(userUpdate = userUpdate)
|
val body = ApiRequestBody(user_update = userUpdate)
|
||||||
return api.patchCurrentUser(requestBody)
|
return apiService.patchCurrentUser(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun changePassword(): Response<Unit> {
|
suspend fun changePassword(currentPassword: String, newPassword: String): Response<Unit> {
|
||||||
return api.changePassword(ApiRequestBody())
|
val passwordRequest = ChangePasswordRequest(currentPassword, newPassword)
|
||||||
|
// Поскольку WomanSafeApi ожидает ApiRequestBody, нам нужно обернуть запрос
|
||||||
|
val body = ApiRequestBody() // Здесь может потребоваться дополнительное поле для смены пароля
|
||||||
|
return apiService.changePassword(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getDashboard(): Response<Any> {
|
suspend fun getDashboard(): Response<Any> {
|
||||||
return api.getDashboard()
|
return apiService.getDashboard()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile methods
|
suspend fun getUserProfile(): Response<UserResponse> {
|
||||||
suspend fun getProfile(): Response<UserResponse> {
|
return apiService.getProfile()
|
||||||
return api.getProfile()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateProfile(userUpdate: UserUpdate): Response<UserResponse> {
|
suspend fun updateUserProfile(userUpdate: UserUpdate): Response<UserResponse> {
|
||||||
val requestBody = ApiRequestBody(userUpdate = userUpdate)
|
val body = ApiRequestBody(user_update = userUpdate)
|
||||||
return api.updateProfile(requestBody)
|
return apiService.updateProfile(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emergency Contacts methods
|
// Emergency Contact methods
|
||||||
suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>> {
|
suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>> {
|
||||||
return api.getEmergencyContacts()
|
return apiService.getEmergencyContacts()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createEmergencyContact(contact: EmergencyContactCreate): Response<EmergencyContactResponse> {
|
suspend fun createEmergencyContact(contact: EmergencyContactCreate): Response<EmergencyContactResponse> {
|
||||||
val requestBody = ApiRequestBody(emergencyContactCreate = contact)
|
return apiService.createEmergencyContact(contact)
|
||||||
return api.createEmergencyContact(requestBody)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getEmergencyContact(contactId: String): Response<EmergencyContactResponse> {
|
suspend fun getEmergencyContact(contactId: Int): Response<EmergencyContactResponse> {
|
||||||
return api.getEmergencyContact(contactId)
|
return apiService.getEmergencyContact(contactId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateEmergencyContact(contactId: String, contact: EmergencyContactUpdate): Response<EmergencyContactResponse> {
|
suspend fun updateEmergencyContact(contactId: Int, contact: EmergencyContactUpdate): Response<EmergencyContactResponse> {
|
||||||
val requestBody = ApiRequestBody(emergencyContactUpdate = contact)
|
val body = ApiRequestBody(emergency_contact_update = contact)
|
||||||
return api.updateEmergencyContact(contactId, requestBody)
|
return apiService.updateEmergencyContact(contactId.toString(), body)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteEmergencyContact(contactId: String): Response<Unit> {
|
suspend fun deleteEmergencyContact(contactId: Int): Response<Unit> {
|
||||||
return api.deleteEmergencyContact(contactId)
|
return apiService.deleteEmergencyContact(contactId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emergency methods
|
// Emergency methods - возвращают Any согласно WomanSafeApi
|
||||||
suspend fun getEmergencyReports(): Response<Any> {
|
suspend fun getEmergencyReports(): Response<Any> {
|
||||||
return api.getEmergencyReports()
|
return apiService.getEmergencyReports()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createEmergencyReport(): Response<Any> {
|
suspend fun createEmergencyReport(): Response<Any> {
|
||||||
return api.createEmergencyReport()
|
return apiService.createEmergencyReport()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getNearbyEmergencyReports(): Response<Any> {
|
suspend fun getNearbyEmergencyReports(): Response<Any> {
|
||||||
return api.getNearbyEmergencyReports()
|
return apiService.getNearbyEmergencyReports()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getEmergencyReport(reportId: String): Response<Any> {
|
suspend fun getEmergencyReport(reportId: Int): Response<Any> {
|
||||||
return api.getEmergencyReport(reportId)
|
return apiService.getEmergencyReport(reportId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateEmergencyReport(reportId: String): Response<Any> {
|
suspend fun updateEmergencyReport(reportId: Int): Response<Any> {
|
||||||
return api.updateEmergencyReport(reportId)
|
return apiService.updateEmergencyReport(reportId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteEmergencyReport(reportId: String): Response<Any> {
|
suspend fun deleteEmergencyReport(reportId: Int): Response<Any> {
|
||||||
return api.deleteEmergencyReport(reportId)
|
return apiService.deleteEmergencyReport(reportId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emergency Alerts methods
|
|
||||||
suspend fun getEmergencyAlerts(): Response<Any> {
|
suspend fun getEmergencyAlerts(): Response<List<EmergencyAlertResponse>> {
|
||||||
return api.getEmergencyAlerts()
|
return apiService.getEmergencyAlerts()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createEmergencyAlert(): Response<Any> {
|
suspend fun createEmergencyAlert(request: EmergencyAlertCreate): Response<EmergencyAlertResponse> {
|
||||||
return api.createEmergencyAlert()
|
return apiService.createEmergencyAlert(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMyEmergencyAlerts(): Response<Any> {
|
suspend fun getMyEmergencyAlerts(): Response<List<EmergencyAlertResponse>> {
|
||||||
return api.getMyEmergencyAlerts()
|
return apiService.getMyEmergencyAlerts()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getNearbyEmergencyAlerts(): Response<Any> {
|
suspend fun getNearbyEmergencyAlerts(): Response<Any> {
|
||||||
return api.getNearbyEmergencyAlerts()
|
return apiService.getNearbyEmergencyAlerts()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getEmergencyAlert(alertId: String): Response<Any> {
|
suspend fun getEmergencyAlert(alertId: Int): Response<Any> {
|
||||||
return api.getEmergencyAlert(alertId)
|
return apiService.getEmergencyAlert(alertId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateEmergencyAlert(alertId: String): Response<Any> {
|
suspend fun updateEmergencyAlert(alertId: Int): Response<Any> {
|
||||||
return api.updateEmergencyAlert(alertId)
|
return apiService.updateEmergencyAlert(alertId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteEmergencyAlert(alertId: String): Response<Any> {
|
suspend fun cancelEmergencyAlert(alertId: Int): Response<Any> {
|
||||||
return api.deleteEmergencyAlert(alertId)
|
return apiService.cancelEmergencyAlert(alertId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun cancelEmergencyAlert(alertId: String): Response<Any> {
|
suspend fun deleteEmergencyAlert(alertId: Int): Response<Any> {
|
||||||
return api.cancelEmergencyAlert(alertId)
|
return apiService.deleteEmergencyAlert(alertId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location methods
|
// Location methods
|
||||||
suspend fun updateLocation(): Response<Any> {
|
suspend fun updateLocation(): Response<Any> {
|
||||||
return api.updateLocation()
|
return apiService.updateLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLastLocation(): Response<Any> {
|
suspend fun getLastLocation(): Response<Any> {
|
||||||
return api.getLastLocation()
|
return apiService.getLastLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLocationHistory(): Response<Any> {
|
suspend fun getLocationHistory(): Response<Any> {
|
||||||
return api.getLocationHistory()
|
return apiService.getLocationHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getNearbyUsers(): Response<Any> {
|
suspend fun getNearbyUsers(): Response<Any> {
|
||||||
return api.getNearbyUsers()
|
return apiService.getNearbyUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSafePlaces(): Response<Any> {
|
suspend fun getSafePlaces(): Response<Any> {
|
||||||
return api.getSafePlaces()
|
return apiService.getSafePlaces()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createSafePlace(): Response<Any> {
|
suspend fun createSafePlace(): Response<Any> {
|
||||||
return api.createSafePlace()
|
return apiService.createSafePlace()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSafePlace(placeId: String): Response<Any> {
|
suspend fun getSafePlace(placeId: Int): Response<Any> {
|
||||||
return api.getSafePlace(placeId)
|
return apiService.getSafePlace(placeId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateSafePlace(placeId: String): Response<Any> {
|
suspend fun updateSafePlace(placeId: Int): Response<Any> {
|
||||||
return api.updateSafePlace(placeId)
|
return apiService.updateSafePlace(placeId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteSafePlace(placeId: String): Response<Any> {
|
suspend fun deleteSafePlace(placeId: Int): Response<Any> {
|
||||||
return api.deleteSafePlace(placeId)
|
return apiService.deleteSafePlace(placeId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calendar methods
|
// Calendar methods
|
||||||
suspend fun getCalendarEntries(): Response<Any> {
|
suspend fun getCalendarEntries(): Response<Any> {
|
||||||
return api.getCalendarEntries()
|
return apiService.getCalendarEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createCalendarEntry(): Response<Any> {
|
suspend fun createCalendarEntry(): Response<Any> {
|
||||||
return api.createCalendarEntry()
|
return apiService.createCalendarEntry()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCalendarEntry(entryId: String): Response<Any> {
|
suspend fun getCalendarEntry(entryId: Int): Response<Any> {
|
||||||
return api.getCalendarEntry(entryId)
|
return apiService.getCalendarEntry(entryId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateCalendarEntry(entryId: String): Response<Any> {
|
suspend fun updateCalendarEntry(entryId: Int): Response<Any> {
|
||||||
return api.updateCalendarEntry(entryId)
|
return apiService.updateCalendarEntry(entryId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteCalendarEntry(entryId: String): Response<Any> {
|
suspend fun deleteCalendarEntry(entryId: Int): Response<Any> {
|
||||||
return api.deleteCalendarEntry(entryId)
|
return apiService.deleteCalendarEntry(entryId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCycleOverview(): Response<Any> {
|
suspend fun getCycleOverview(): Response<Any> {
|
||||||
return api.getCycleOverview()
|
return apiService.getCycleOverview()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCalendarInsights(): Response<Any> {
|
suspend fun getCalendarInsights(): Response<Any> {
|
||||||
return api.getCalendarInsights()
|
return apiService.getCalendarInsights()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCalendarReminders(): Response<Any> {
|
suspend fun getCalendarReminders(): Response<Any> {
|
||||||
return api.getCalendarReminders()
|
return apiService.getCalendarReminders()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createCalendarReminder(): Response<Any> {
|
suspend fun createCalendarReminder(): Response<Any> {
|
||||||
return api.createCalendarReminder()
|
return apiService.createCalendarReminder()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCalendarSettings(): Response<Any> {
|
suspend fun getCalendarSettings(): Response<Any> {
|
||||||
return api.getCalendarSettings()
|
return apiService.getCalendarSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateCalendarSettings(): Response<Any> {
|
suspend fun updateCalendarSettings(): Response<Any> {
|
||||||
return api.updateCalendarSettings()
|
return apiService.updateCalendarSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification methods
|
// Notification methods
|
||||||
suspend fun getNotificationDevices(): Response<Any> {
|
suspend fun getNotificationDevices(): Response<Any> {
|
||||||
return api.getNotificationDevices()
|
return apiService.getNotificationDevices()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createNotificationDevice(): Response<Any> {
|
suspend fun registerNotificationDevice(): Response<Any> {
|
||||||
return api.createNotificationDevice()
|
return apiService.createNotificationDevice()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getNotificationDevice(deviceId: String): Response<Any> {
|
suspend fun getNotificationDevice(deviceId: String): Response<Any> {
|
||||||
return api.getNotificationDevice(deviceId)
|
return apiService.getNotificationDevice(deviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteNotificationDevice(deviceId: String): Response<Any> {
|
suspend fun unregisterNotificationDevice(deviceId: String): Response<Any> {
|
||||||
return api.deleteNotificationDevice(deviceId)
|
return apiService.deleteNotificationDevice(deviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getNotificationPreferences(): Response<Any> {
|
suspend fun getNotificationPreferences(): Response<Any> {
|
||||||
return api.getNotificationPreferences()
|
return apiService.getNotificationPreferences()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateNotificationPreferences(): Response<Any> {
|
suspend fun updateNotificationPreferences(): Response<Any> {
|
||||||
return api.updateNotificationPreferences()
|
return apiService.updateNotificationPreferences()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun testNotification(): Response<Any> {
|
suspend fun sendTestNotification(): Response<Any> {
|
||||||
return api.testNotification()
|
return apiService.testNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getNotificationHistory(): Response<Any> {
|
suspend fun getNotificationHistory(): Response<Any> {
|
||||||
return api.getNotificationHistory()
|
return apiService.getNotificationHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check methods
|
// Health and status methods
|
||||||
suspend fun getHealth(): Response<Any> {
|
suspend fun getHealth(): Response<Any> {
|
||||||
return api.getHealth()
|
return apiService.getHealth()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getServicesStatus(): Response<Any> {
|
suspend fun getServicesStatus(): Response<Any> {
|
||||||
return api.getServicesStatus()
|
return apiService.getServicesStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getRoot(): Response<Any> {
|
suspend fun getRoot(): Response<Any> {
|
||||||
return api.getRoot()
|
return apiService.getRoot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,10 +221,25 @@ fun UserTab(
|
|||||||
Text("ID: ${currentUser.id}")
|
Text("ID: ${currentUser.id}")
|
||||||
Text("UUID: ${currentUser.uuid}")
|
Text("UUID: ${currentUser.uuid}")
|
||||||
Text("Email: ${currentUser.email}")
|
Text("Email: ${currentUser.email}")
|
||||||
currentUser.fullName?.let { Text("Имя: $it") }
|
currentUser.full_name?.let { Text("Имя: $it") }
|
||||||
currentUser.phoneNumber?.let { Text("Телефон: $it") }
|
currentUser.phone_number?.let { Text("Телефон: $it") }
|
||||||
Text("Email подтвержден: ${if (currentUser.emailVerified) "Да" else "Нет"}")
|
currentUser.username?.let { Text("Имя пользователя: $it") }
|
||||||
Text("Активен: ${if (currentUser.isActive) "Да" else "Нет"}")
|
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 ?: "Не указан"})")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,7 +369,7 @@ fun ContactsTab(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(12.dp)
|
modifier = Modifier.padding(12.dp)
|
||||||
) {
|
) {
|
||||||
Text("${contact.name} - ${contact.phoneNumber}")
|
Text("${contact.name} - ${contact.phone_number}")
|
||||||
contact.relationship?.let { Text("Отношение: $it", fontSize = 12.sp) }
|
contact.relationship?.let { Text("Отношение: $it", fontSize = 12.sp) }
|
||||||
contact.notes?.let { Text("Заметки: $it", fontSize = 12.sp) }
|
contact.notes?.let { Text("Заметки: $it", fontSize = 12.sp) }
|
||||||
Text("ID: ${contact.id}", fontSize = 10.sp, color = Color.Gray)
|
Text("ID: ${contact.id}", fontSize = 10.sp, color = Color.Gray)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
640
app/src/main/java/com/example/womansafe/ui/screens/AuthScreen.kt
Normal file
640
app/src/main/java/com/example/womansafe/ui/screens/AuthScreen.kt
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
@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),
|
||||||
|
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,879 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
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.*
|
||||||
|
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.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.womansafe.data.model.*
|
||||||
|
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||||
|
import com.example.womansafe.util.DateUtils
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarScreen(
|
||||||
|
calendarViewModel: CalendarViewModel,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val uiState = calendarViewModel.uiState
|
||||||
|
val scrollState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Автоскролл к выбранной дате
|
||||||
|
LaunchedEffect(uiState.selectedDate) {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val daysDiff = ChronoUnit.DAYS.between(today, uiState.selectedDate).toInt()
|
||||||
|
if (daysDiff in -30..30) {
|
||||||
|
scrollState.animateScrollToItem(daysDiff + 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Заголовок с месяцем и навигацией
|
||||||
|
MonthHeader(
|
||||||
|
currentMonth = uiState.currentMonth,
|
||||||
|
onPreviousMonth = { calendarViewModel.changeMonth(-1) },
|
||||||
|
onNextMonth = { calendarViewModel.changeMonth(1) },
|
||||||
|
onSettingsClick = { calendarViewModel.showSettingsDialog() }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Горизонтальный скролл дней
|
||||||
|
HorizontalDayScroll(
|
||||||
|
selectedDate = uiState.selectedDate,
|
||||||
|
events = uiState.events,
|
||||||
|
predictions = uiState.predictions,
|
||||||
|
onDateSelect = { calendarViewModel.selectDate(it) },
|
||||||
|
scrollState = scrollState
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Информация о выбранном дне
|
||||||
|
SelectedDateInfo(
|
||||||
|
selectedDate = uiState.selectedDate,
|
||||||
|
events = uiState.events[uiState.selectedDate] ?: emptyList(),
|
||||||
|
predictions = uiState.predictions,
|
||||||
|
onAddEvent = { calendarViewModel.showEventDialog() },
|
||||||
|
onRemoveEvent = { type -> calendarViewModel.removeEvent(uiState.selectedDate, type) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Прогнозы и статистика
|
||||||
|
if (uiState.predictions != null || uiState.statistics != null) {
|
||||||
|
PredictionsCard(
|
||||||
|
predictions = uiState.predictions,
|
||||||
|
statistics = uiState.statistics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог добавления события
|
||||||
|
if (uiState.showEventDialog) {
|
||||||
|
AddEventDialog(
|
||||||
|
selectedDate = uiState.selectedDate,
|
||||||
|
onDismiss = { calendarViewModel.hideEventDialog() },
|
||||||
|
onAddEvent = { type, mood, symptoms, notes, flowIntensity ->
|
||||||
|
calendarViewModel.addEvent(
|
||||||
|
date = uiState.selectedDate,
|
||||||
|
type = type,
|
||||||
|
mood = mood,
|
||||||
|
symptoms = symptoms,
|
||||||
|
notes = notes,
|
||||||
|
flowIntensity = flowIntensity
|
||||||
|
)
|
||||||
|
calendarViewModel.hideEventDialog()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог настроек цикла
|
||||||
|
if (uiState.showSettingsDialog) {
|
||||||
|
CycleSettingsDialog(
|
||||||
|
settings = uiState.settings,
|
||||||
|
onDismiss = { calendarViewModel.hideSettingsDialog() },
|
||||||
|
onSave = { settings ->
|
||||||
|
calendarViewModel.updateCycleSettings(settings)
|
||||||
|
calendarViewModel.hideSettingsDialog()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка ошибок
|
||||||
|
uiState.error?.let { error ->
|
||||||
|
LaunchedEffect(error) {
|
||||||
|
// TODO: Показать snackbar с ошибкой
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonthHeader(
|
||||||
|
currentMonth: LocalDate,
|
||||||
|
onPreviousMonth: () -> Unit,
|
||||||
|
onNextMonth: () -> Unit,
|
||||||
|
onSettingsClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onPreviousMonth) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.KeyboardArrowLeft,
|
||||||
|
contentDescription = "Предыдущий месяц"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = DateUtils.formatDate(currentMonth, "LLLL yyyy"),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
IconButton(onClick = onSettingsClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Settings,
|
||||||
|
contentDescription = "Настройки цикла"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onNextMonth) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = "Следующий месяц"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HorizontalDayScroll(
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
events: Map<LocalDate, List<CalendarEvent>>,
|
||||||
|
predictions: CyclePrediction?,
|
||||||
|
onDateSelect: (LocalDate) -> Unit,
|
||||||
|
scrollState: LazyListState
|
||||||
|
) {
|
||||||
|
val today = DateUtils.getCurrentDate()
|
||||||
|
|
||||||
|
// Генерируем дни для показа (от -30 до +60 дней от сегодня)
|
||||||
|
val days = (-30..60).map { dayOffset ->
|
||||||
|
today.plusDays(dayOffset.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
state = scrollState,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
items(days) { date ->
|
||||||
|
DayItem(
|
||||||
|
date = date,
|
||||||
|
isSelected = date.isEqual(selectedDate),
|
||||||
|
isToday = DateUtils.isSameDay(date, today),
|
||||||
|
events = events[date] ?: emptyList(),
|
||||||
|
predictions = predictions,
|
||||||
|
onClick = { onDateSelect(date) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayItem(
|
||||||
|
date: LocalDate,
|
||||||
|
isSelected: Boolean,
|
||||||
|
isToday: Boolean,
|
||||||
|
events: List<CalendarEvent>,
|
||||||
|
predictions: CyclePrediction?,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val backgroundColor = when {
|
||||||
|
isSelected -> MaterialTheme.colorScheme.primary
|
||||||
|
isToday -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
else -> MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
|
||||||
|
val textColor = when {
|
||||||
|
isSelected -> MaterialTheme.colorScheme.onPrimary
|
||||||
|
isToday -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение дня недели и числа месяца
|
||||||
|
val dayOfWeek = DateUtils.formatDate(date, "EEE")
|
||||||
|
val dayOfMonth = date.dayOfMonth
|
||||||
|
|
||||||
|
// Определяем цвет индикатора событий
|
||||||
|
val eventIndicatorColor = getEventIndicatorColor(events, predictions, date)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(60.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(backgroundColor)
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
// День недели
|
||||||
|
Text(
|
||||||
|
text = dayOfWeek,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = textColor.copy(alpha = 0.7f),
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
// Число
|
||||||
|
Text(
|
||||||
|
text = dayOfMonth.toString(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = if (isSelected || isToday) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
|
||||||
|
// Индикаторы событий
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
modifier = Modifier.height(8.dp)
|
||||||
|
) {
|
||||||
|
eventIndicatorColor.forEach { color ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(6.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectedDateInfo(
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
events: List<CalendarEvent>,
|
||||||
|
predictions: CyclePrediction?,
|
||||||
|
onAddEvent: () -> Unit,
|
||||||
|
onRemoveEvent: (CalendarEventType) -> 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
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = DateUtils.formatDate(selectedDate, "d MMMM yyyy"),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(onClick = onAddEvent) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Add,
|
||||||
|
contentDescription = "Добавить событие"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (events.isEmpty() && !isPredictedDate(selectedDate, predictions)) {
|
||||||
|
Text(
|
||||||
|
text = "Нет событий на этот день",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Показываем события
|
||||||
|
events.forEach { event ->
|
||||||
|
EventItem(
|
||||||
|
event = event,
|
||||||
|
onRemove = { onRemoveEvent(event.type) }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем прогнозы для этого дня
|
||||||
|
predictions?.let { pred ->
|
||||||
|
when {
|
||||||
|
DateUtils.isDateInRange(selectedDate, pred.nextPeriodStart, pred.nextPeriodEnd) -> {
|
||||||
|
PredictionItem(
|
||||||
|
type = "Прогноз месячных",
|
||||||
|
color = MaterialTheme.colorScheme.error.copy(alpha = 0.3f),
|
||||||
|
confidence = pred.confidence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selectedDate.isEqual(pred.nextOvulation) -> {
|
||||||
|
PredictionItem(
|
||||||
|
type = "Прогноз овуляции",
|
||||||
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
|
||||||
|
confidence = pred.confidence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DateUtils.isDateInRange(selectedDate, pred.fertileWindowStart, pred.fertileWindowEnd) -> {
|
||||||
|
PredictionItem(
|
||||||
|
type = "Период фертильности",
|
||||||
|
color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f),
|
||||||
|
confidence = pred.confidence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventItem(
|
||||||
|
event: CalendarEvent,
|
||||||
|
onRemove: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(getEventColor(event.type).copy(alpha = 0.1f))
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = getEventTypeName(event.type),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = getEventColor(event.type)
|
||||||
|
)
|
||||||
|
|
||||||
|
event.mood?.let { mood ->
|
||||||
|
Text(
|
||||||
|
text = "Настроение: ${getMoodName(mood)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.symptoms.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Симптомы: ${event.symptoms.size}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.notes.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = event.notes,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.isActual) {
|
||||||
|
IconButton(onClick = onRemove) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Close,
|
||||||
|
contentDescription = "Удалить событие",
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PredictionItem(
|
||||||
|
type: String,
|
||||||
|
color: Color,
|
||||||
|
confidence: Float
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(color)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Info,
|
||||||
|
contentDescription = "Прогноз",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = type,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Вероятность: ${(confidence * 100).toInt()}%",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PredictionsCard(
|
||||||
|
predictions: CyclePrediction?,
|
||||||
|
statistics: CycleStatistics?
|
||||||
|
) {
|
||||||
|
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))
|
||||||
|
|
||||||
|
predictions?.let { pred ->
|
||||||
|
PredictionRow("Следующие месячные", pred.nextPeriodStart)
|
||||||
|
PredictionRow("Овуляция", pred.nextOvulation)
|
||||||
|
}
|
||||||
|
|
||||||
|
statistics?.let { stats ->
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Средняя длина цикла: ${stats.averageCycleLength.toInt()} дней",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Вариация: ±${stats.cycleVariation.toInt()} дней",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PredictionRow(
|
||||||
|
label: String,
|
||||||
|
date: LocalDate
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = DateUtils.formatDate(date, "d MMM"),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun AddEventDialog(
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAddEvent: (CalendarEventType, MoodType?, List<SymptomType>, String, Int?) -> Unit
|
||||||
|
) {
|
||||||
|
var selectedEventType by remember { mutableStateOf(CalendarEventType.MENSTRUATION) }
|
||||||
|
var selectedMood by remember { mutableStateOf<MoodType?>(null) }
|
||||||
|
var selectedSymptoms by remember { mutableStateOf(setOf<SymptomType>()) }
|
||||||
|
var notes by remember { mutableStateOf("") }
|
||||||
|
var flowIntensity by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text("Добавить событие на ${DateUtils.formatDate(selectedDate, "d MMMM")}")
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Тип события
|
||||||
|
Text(
|
||||||
|
text = "Тип события",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
listOf(
|
||||||
|
CalendarEventType.MENSTRUATION to "Месячные",
|
||||||
|
CalendarEventType.OVULATION to "Овуляция"
|
||||||
|
).forEach { (type, name) ->
|
||||||
|
FilterChip(
|
||||||
|
onClick = { selectedEventType = type },
|
||||||
|
label = { Text(name) },
|
||||||
|
selected = selectedEventType == type,
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интенсивность (только для месячных)
|
||||||
|
if (selectedEventType == CalendarEventType.MENSTRUATION) {
|
||||||
|
Text(
|
||||||
|
text = "Интенсивность",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
(1..5).forEach { intensity ->
|
||||||
|
FilterChip(
|
||||||
|
onClick = { flowIntensity = intensity },
|
||||||
|
label = { Text(intensity.toString()) },
|
||||||
|
selected = flowIntensity == intensity,
|
||||||
|
modifier = Modifier.padding(end = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настроение
|
||||||
|
Text(
|
||||||
|
text = "Настроение",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
MoodType.values().forEach { mood ->
|
||||||
|
FilterChip(
|
||||||
|
onClick = {
|
||||||
|
selectedMood = if (selectedMood == mood) null else mood
|
||||||
|
},
|
||||||
|
label = { Text(getMoodEmoji(mood)) },
|
||||||
|
selected = selectedMood == mood,
|
||||||
|
modifier = Modifier.padding(end = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Симптомы
|
||||||
|
Text(
|
||||||
|
text = "Симптомы",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
// Создаем строки по 2 симптома вручную, избегая chunked
|
||||||
|
val symptoms = SymptomType.values().toList()
|
||||||
|
var i = 0
|
||||||
|
while (i < symptoms.size) {
|
||||||
|
Row {
|
||||||
|
// Первый симптом в строке
|
||||||
|
FilterChip(
|
||||||
|
onClick = {
|
||||||
|
val symptom = symptoms[i]
|
||||||
|
selectedSymptoms = if (selectedSymptoms.contains(symptom)) {
|
||||||
|
selectedSymptoms - symptom
|
||||||
|
} else {
|
||||||
|
selectedSymptoms + symptom
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(getSymptomName(symptoms[i])) },
|
||||||
|
selected = selectedSymptoms.contains(symptoms[i]),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Второй симптом в строке (если есть)
|
||||||
|
if (i + 1 < symptoms.size) {
|
||||||
|
FilterChip(
|
||||||
|
onClick = {
|
||||||
|
val symptom = symptoms[i + 1]
|
||||||
|
selectedSymptoms = if (selectedSymptoms.contains(symptom)) {
|
||||||
|
selectedSymptoms - symptom
|
||||||
|
} else {
|
||||||
|
selectedSymptoms + symptom
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(getSymptomName(symptoms[i + 1])) },
|
||||||
|
selected = selectedSymptoms.contains(symptoms[i + 1]),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Пустое место для выравнивания
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заметки
|
||||||
|
OutlinedTextField(
|
||||||
|
value = notes,
|
||||||
|
onValueChange = { notes = it },
|
||||||
|
label = { Text("Заметки") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onAddEvent(
|
||||||
|
selectedEventType,
|
||||||
|
selectedMood,
|
||||||
|
selectedSymptoms.toList(),
|
||||||
|
notes,
|
||||||
|
flowIntensity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Добавить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun CycleSettingsDialog(
|
||||||
|
settings: CycleSettings,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (CycleSettings) -> Unit
|
||||||
|
) {
|
||||||
|
var cycleLength by remember { mutableStateOf(settings.averageCycleLength.toString()) }
|
||||||
|
var periodLength by remember { mutableStateOf(settings.averagePeriodLength.toString()) }
|
||||||
|
var lastPeriodDate by remember { mutableStateOf(settings.lastPeriodStart) }
|
||||||
|
var reminderDays by remember { mutableStateOf(settings.reminderDaysBefore.toString()) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Настройки цикла") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = cycleLength,
|
||||||
|
onValueChange = { cycleLength = it },
|
||||||
|
label = { Text("Средняя длина цикла (дни)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = periodLength,
|
||||||
|
onValueChange = { periodLength = it },
|
||||||
|
label = { Text("Длина менструации (дни)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = reminderDays,
|
||||||
|
onValueChange = { reminderDays = it },
|
||||||
|
label = { Text("Напоминать за (дни)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: Добавить выбор даты последних месячных
|
||||||
|
lastPeriodDate?.let { date ->
|
||||||
|
Text(
|
||||||
|
text = "Последние месячные: ${date.format(DateTimeFormatter.ofPattern("d MMMM yyyy"))}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val newSettings = settings.copy(
|
||||||
|
averageCycleLength = cycleLength.toIntOrNull() ?: settings.averageCycleLength,
|
||||||
|
averagePeriodLength = periodLength.toIntOrNull() ?: settings.averagePeriodLength,
|
||||||
|
reminderDaysBefore = reminderDays.toIntOrNull() ?: settings.reminderDaysBefore,
|
||||||
|
lastPeriodStart = lastPeriodDate
|
||||||
|
)
|
||||||
|
onSave(newSettings)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Сохранить")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательные функции
|
||||||
|
private fun getEventIndicatorColor(
|
||||||
|
events: List<CalendarEvent>,
|
||||||
|
predictions: CyclePrediction?,
|
||||||
|
date: LocalDate
|
||||||
|
): List<Color> {
|
||||||
|
val colors = mutableListOf<Color>()
|
||||||
|
|
||||||
|
events.forEach { event ->
|
||||||
|
colors.add(getEventColor(event.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем цвета прогнозов
|
||||||
|
predictions?.let { pred ->
|
||||||
|
when {
|
||||||
|
DateUtils.isDateInRange(date, pred.nextPeriodStart, pred.nextPeriodEnd) -> {
|
||||||
|
if (!events.any { it.type == CalendarEventType.MENSTRUATION }) {
|
||||||
|
colors.add(Color.Red.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
date.isEqual(pred.nextOvulation) -> {
|
||||||
|
if (!events.any { it.type == CalendarEventType.OVULATION }) {
|
||||||
|
colors.add(Color.Blue.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DateUtils.isDateInRange(date, pred.fertileWindowStart, pred.fertileWindowEnd) -> {
|
||||||
|
if (!events.any { it.type == CalendarEventType.FERTILE_WINDOW }) {
|
||||||
|
colors.add(Color.Green.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return colors.take(3) // Максимум 3 индикатора
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEventColor(type: CalendarEventType): Color {
|
||||||
|
return when (type) {
|
||||||
|
CalendarEventType.MENSTRUATION -> Color.Red
|
||||||
|
CalendarEventType.OVULATION -> Color.Blue
|
||||||
|
CalendarEventType.FERTILE_WINDOW -> Color.Green
|
||||||
|
CalendarEventType.PREDICTED_MENSTRUATION -> Color.Red.copy(alpha = 0.5f)
|
||||||
|
CalendarEventType.PREDICTED_OVULATION -> Color.Blue.copy(alpha = 0.5f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEventTypeName(type: CalendarEventType): String {
|
||||||
|
return when (type) {
|
||||||
|
CalendarEventType.MENSTRUATION -> "Месячные"
|
||||||
|
CalendarEventType.OVULATION -> "Овуляция"
|
||||||
|
CalendarEventType.FERTILE_WINDOW -> "Фертильность"
|
||||||
|
CalendarEventType.PREDICTED_MENSTRUATION -> "Прогноз месячных"
|
||||||
|
CalendarEventType.PREDICTED_OVULATION -> "Прогноз овуляции"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMoodName(mood: MoodType): String {
|
||||||
|
return when (mood) {
|
||||||
|
MoodType.EXCELLENT -> "Отлично"
|
||||||
|
MoodType.GOOD -> "Хорошо"
|
||||||
|
MoodType.NORMAL -> "Нормально"
|
||||||
|
MoodType.BAD -> "Плохо"
|
||||||
|
MoodType.TERRIBLE -> "Ужасно"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMoodEmoji(mood: MoodType): String {
|
||||||
|
return when (mood) {
|
||||||
|
MoodType.EXCELLENT -> "😄"
|
||||||
|
MoodType.GOOD -> "😊"
|
||||||
|
MoodType.NORMAL -> "😐"
|
||||||
|
MoodType.BAD -> "😞"
|
||||||
|
MoodType.TERRIBLE -> "😫"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSymptomName(symptom: SymptomType): String {
|
||||||
|
return when (symptom) {
|
||||||
|
SymptomType.CRAMPS -> "Спазмы"
|
||||||
|
SymptomType.HEADACHE -> "Головная боль"
|
||||||
|
SymptomType.BLOATING -> "Вздутие"
|
||||||
|
SymptomType.BREAST_TENDERNESS -> "Болезненность груди"
|
||||||
|
SymptomType.MOOD_SWINGS -> "Перепады настроения"
|
||||||
|
SymptomType.FATIGUE -> "Усталость"
|
||||||
|
SymptomType.ACNE -> "Акне"
|
||||||
|
SymptomType.CRAVINGS -> "Тяга к еде"
|
||||||
|
SymptomType.BACK_PAIN -> "Боль в спине"
|
||||||
|
SymptomType.NAUSEA -> "Тошнота"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isPredictedDate(date: LocalDate, predictions: CyclePrediction?): Boolean {
|
||||||
|
predictions ?: return false
|
||||||
|
|
||||||
|
return DateUtils.isDateInRange(date, predictions.nextPeriodStart, predictions.nextPeriodEnd) ||
|
||||||
|
date.isEqual(predictions.nextOvulation) ||
|
||||||
|
DateUtils.isDateInRange(date, predictions.fertileWindowStart, predictions.fertileWindowEnd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
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.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
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EmergencyContactsScreen(
|
||||||
|
emergencyContactsViewModel: EmergencyContactsViewModel,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val uiState = emergencyContactsViewModel.uiState
|
||||||
|
var showAddDialog 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 = { showAddDialog = 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 = { showAddDialog = 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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка ошибок
|
||||||
|
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("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
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("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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.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
|
||||||
|
|
||||||
|
@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
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = BottomNavItem.Home.route,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
composable(BottomNavItem.Home.route) {
|
||||||
|
HomeScreen(authViewModel = authViewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(BottomNavItem.Emergency.route) {
|
||||||
|
EmergencyScreen(emergencyViewModel = EmergencyViewModel())
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(BottomNavItem.Calendar.route) {
|
||||||
|
CalendarScreen(calendarViewModel = CalendarViewModel())
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(BottomNavItem.Profile.route) {
|
||||||
|
ProfileScreen(
|
||||||
|
authViewModel = authViewModel,
|
||||||
|
onNavigateToContacts = { navController.navigate("emergency_contacts") },
|
||||||
|
onNavigateToSettings = { navController.navigate("profile_settings") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дополнительные экраны
|
||||||
|
composable("emergency_contacts") {
|
||||||
|
EmergencyContactsScreen(emergencyContactsViewModel = EmergencyContactsViewModel())
|
||||||
|
}
|
||||||
|
|
||||||
|
composable("profile_settings") {
|
||||||
|
ProfileSettingsScreen(
|
||||||
|
profileSettingsViewModel = ProfileSettingsViewModel(),
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ import com.example.womansafe.data.model.*
|
|||||||
import com.example.womansafe.data.network.NetworkClient
|
import com.example.womansafe.data.network.NetworkClient
|
||||||
import com.example.womansafe.data.repository.ApiRepository
|
import com.example.womansafe.data.repository.ApiRepository
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
data class ApiTestState(
|
data class ApiTestState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@@ -17,10 +19,16 @@ data class ApiTestState(
|
|||||||
val authToken: String? = null,
|
val authToken: String? = null,
|
||||||
val isAuthenticated: Boolean = false,
|
val isAuthenticated: Boolean = false,
|
||||||
val emergencyContacts: List<EmergencyContactResponse> = emptyList(),
|
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 lastApiResponse: String = "",
|
||||||
val lastApiError: String = "",
|
val lastApiError: String = "",
|
||||||
val selectedEndpoint: String = "",
|
val selectedEndpoint: String = "",
|
||||||
val baseUrl: String = "http://10.0.2.2:8000/"
|
val baseUrl: String = "http://192.168.0.103:8000/"
|
||||||
)
|
)
|
||||||
|
|
||||||
class ApiTestViewModel : ViewModel() {
|
class ApiTestViewModel : ViewModel() {
|
||||||
@@ -55,6 +63,8 @@ class ApiTestViewModel : ViewModel() {
|
|||||||
lastApiResponse = "Login successful! Token: ${it.accessToken.take(20)}...",
|
lastApiResponse = "Login successful! Token: ${it.accessToken.take(20)}...",
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
|
// Запрос профиля сразу после авторизации
|
||||||
|
getCurrentUser()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||||
@@ -87,9 +97,10 @@ class ApiTestViewModel : ViewModel() {
|
|||||||
val response = repository.register(email, username, password, fullName, phoneNumber)
|
val response = repository.register(email, username, password, fullName, phoneNumber)
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
val user = response.body()
|
val user = response.body()
|
||||||
|
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
currentUser = user,
|
currentUser = user,
|
||||||
lastApiResponse = gson.toJson(user),
|
lastApiResponse = userJson,
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -123,9 +134,10 @@ class ApiTestViewModel : ViewModel() {
|
|||||||
val response = repository.getCurrentUser()
|
val response = repository.getCurrentUser()
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
val user = response.body()
|
val user = response.body()
|
||||||
|
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
currentUser = user,
|
currentUser = user,
|
||||||
lastApiResponse = gson.toJson(user),
|
lastApiResponse = userJson,
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -159,8 +171,9 @@ class ApiTestViewModel : ViewModel() {
|
|||||||
val response = repository.getDashboard()
|
val response = repository.getDashboard()
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
val dashboard = response.body()
|
val dashboard = response.body()
|
||||||
|
val dashboardJson = withContext(Dispatchers.Default) { gson.toJson(dashboard) }
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
lastApiResponse = gson.toJson(dashboard),
|
lastApiResponse = dashboardJson,
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -194,9 +207,10 @@ class ApiTestViewModel : ViewModel() {
|
|||||||
val response = repository.getEmergencyContacts()
|
val response = repository.getEmergencyContacts()
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
val contacts = response.body() ?: emptyList()
|
val contacts = response.body() ?: emptyList()
|
||||||
|
val contactsJson = withContext(Dispatchers.Default) { gson.toJson(contacts) }
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
emergencyContacts = contacts,
|
emergencyContacts = contacts,
|
||||||
lastApiResponse = gson.toJson(contacts),
|
lastApiResponse = contactsJson,
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -231,8 +245,9 @@ class ApiTestViewModel : ViewModel() {
|
|||||||
val response = repository.createEmergencyContact(contact)
|
val response = repository.createEmergencyContact(contact)
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
val createdContact = response.body()
|
val createdContact = response.body()
|
||||||
|
val contactJson = withContext(Dispatchers.Default) { gson.toJson(createdContact) }
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
lastApiResponse = gson.toJson(createdContact),
|
lastApiResponse = contactJson,
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
// Refresh the contacts list
|
// Refresh the contacts list
|
||||||
@@ -255,6 +270,368 @@ class ApiTestViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
fun testGenericEndpoint(endpoint: String, method: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
@@ -272,10 +649,20 @@ class ApiTestViewModel : ViewModel() {
|
|||||||
"/api/v1/users/dashboard" -> repository.getDashboard()
|
"/api/v1/users/dashboard" -> repository.getDashboard()
|
||||||
"/api/v1/emergency/reports" -> repository.getEmergencyReports()
|
"/api/v1/emergency/reports" -> repository.getEmergencyReports()
|
||||||
"/api/v1/emergency/alerts" -> repository.getEmergencyAlerts()
|
"/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/last" -> repository.getLastLocation()
|
||||||
"/api/v1/locations/history" -> repository.getLocationHistory()
|
"/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/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/preferences" -> repository.getNotificationPreferences()
|
||||||
|
"/api/v1/notifications/devices" -> repository.getNotificationDevices()
|
||||||
|
"/api/v1/notifications/history" -> repository.getNotificationHistory()
|
||||||
else -> {
|
else -> {
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
lastApiError = "Endpoint not implemented in this test app",
|
lastApiError = "Endpoint not implemented in this test app",
|
||||||
@@ -287,8 +674,9 @@ class ApiTestViewModel : ViewModel() {
|
|||||||
|
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
val body = response.body()
|
val body = response.body()
|
||||||
|
val bodyJson = withContext(Dispatchers.Default) { gson.toJson(body) }
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
lastApiResponse = gson.toJson(body),
|
lastApiResponse = bodyJson,
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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("@")
|
||||||
|
val response = repository.login(
|
||||||
|
email = if (isEmail) usernameOrEmail else null,
|
||||||
|
username = if (!isEmail) usernameOrEmail else null,
|
||||||
|
password = password
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val token = response.body()
|
||||||
|
token?.let {
|
||||||
|
NetworkClient.setAuthToken(it.accessToken)
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isLoggedIn = true,
|
||||||
|
token = it.accessToken,
|
||||||
|
tokenType = it.tokenType
|
||||||
|
)
|
||||||
|
// Получаем профиль пользователя сразу после успешного входа
|
||||||
|
getCurrentUser()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка авторизации: ${response.code()} - ${response.message()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
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 {
|
||||||
|
val response = repository.register(
|
||||||
|
email = email,
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
fullName = fullName,
|
||||||
|
phoneNumber = phoneNumber
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val userResponse = response.body()
|
||||||
|
userResponse?.let {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isLoggedIn = true,
|
||||||
|
user = it,
|
||||||
|
registrationSuccess = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка регистрации: ${response.code()} - ${response.message()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
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 logout() {
|
||||||
|
NetworkClient.setAuthToken(null)
|
||||||
|
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,391 @@
|
|||||||
|
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 kotlinx.coroutines.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
data class CalendarUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val currentMonth: LocalDate = LocalDate.now(),
|
||||||
|
val selectedDate: LocalDate = LocalDate.now(),
|
||||||
|
val events: Map<LocalDate, List<CalendarEvent>> = emptyMap(),
|
||||||
|
val predictions: CyclePrediction? = null,
|
||||||
|
val settings: CycleSettings = CycleSettings(),
|
||||||
|
val statistics: CycleStatistics? = null,
|
||||||
|
val showEventDialog: Boolean = false,
|
||||||
|
val showSettingsDialog: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val lastRefreshed: Long = 0 // Время последнего успешного обновления данных
|
||||||
|
)
|
||||||
|
|
||||||
|
class CalendarViewModel : ViewModel() {
|
||||||
|
private val repository = ApiRepository()
|
||||||
|
|
||||||
|
var uiState by mutableStateOf(CalendarUiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var loadJob: Job? = null
|
||||||
|
private var retryCount = 0
|
||||||
|
private val maxRetryCount = 5
|
||||||
|
private val cacheValidityDuration = 10 * 60 * 1000 // 10 минут в миллисекундах
|
||||||
|
private var debounceJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadCalendarData()
|
||||||
|
loadCycleSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadCalendarData() {
|
||||||
|
// Если данные были обновлены недавно и это не первая загрузка, не делаем запрос
|
||||||
|
if (uiState.events.isNotEmpty() &&
|
||||||
|
System.currentTimeMillis() - uiState.lastRefreshed < cacheValidityDuration) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отменяем предыдущий запрос, если он еще выполняется
|
||||||
|
loadJob?.cancel()
|
||||||
|
|
||||||
|
loadJob = viewModelScope.launch {
|
||||||
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
|
try {
|
||||||
|
// Загружаем события календаря
|
||||||
|
val response = repository.getCalendarEntries()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
// Сбрасываем счетчик повторных попыток при успехе
|
||||||
|
retryCount = 0
|
||||||
|
|
||||||
|
// TODO: Преобразовать ответ API в события календаря
|
||||||
|
generatePredictions()
|
||||||
|
calculateStatistics()
|
||||||
|
|
||||||
|
// Обновляем время последнего успешного запроса
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
lastRefreshed = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
} else if (response.code() == 429) {
|
||||||
|
// Обработка Too Many Requests с экспоненциальным откатом
|
||||||
|
handleRateLimitExceeded()
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка загрузки данных календаря: ${response.code()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка сети: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleRateLimitExceeded() {
|
||||||
|
if (retryCount < maxRetryCount) {
|
||||||
|
retryCount++
|
||||||
|
|
||||||
|
// Экспоненциальная отсрочка: 2^попытка * 1000 мс (1с, 2с, 4с, 8с, 16с)
|
||||||
|
val delayTime = (2.0.pow(retryCount.toDouble()) * 1000).toLong()
|
||||||
|
|
||||||
|
uiState = uiState.copy(
|
||||||
|
error = "Слишком много запросов. Повторная попытка через ${delayTime/1000} сек..."
|
||||||
|
)
|
||||||
|
|
||||||
|
delay(delayTime)
|
||||||
|
loadCalendarData() // Повторная попытка после задержки
|
||||||
|
} else {
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Превышен лимит запросов. Попробуйте позже."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectDate(date: LocalDate) {
|
||||||
|
uiState = uiState.copy(selectedDate = date)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeMonth(direction: Int) {
|
||||||
|
val newMonth = uiState.currentMonth.plusMonths(direction.toLong())
|
||||||
|
uiState = uiState.copy(currentMonth = newMonth)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addEvent(
|
||||||
|
date: LocalDate,
|
||||||
|
type: CalendarEventType,
|
||||||
|
mood: MoodType? = null,
|
||||||
|
symptoms: List<SymptomType> = emptyList(),
|
||||||
|
notes: String = "",
|
||||||
|
flowIntensity: Int? = null
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val event = CalendarEvent(
|
||||||
|
date = date,
|
||||||
|
type = type,
|
||||||
|
mood = mood,
|
||||||
|
symptoms = symptoms,
|
||||||
|
notes = notes,
|
||||||
|
flowIntensity = flowIntensity
|
||||||
|
)
|
||||||
|
|
||||||
|
// Добавляем событие локально
|
||||||
|
val currentEvents = uiState.events.toMutableMap()
|
||||||
|
val dateEvents = currentEvents[date]?.toMutableList() ?: mutableListOf()
|
||||||
|
dateEvents.add(event)
|
||||||
|
currentEvents[date] = dateEvents
|
||||||
|
|
||||||
|
uiState = uiState.copy(events = currentEvents)
|
||||||
|
|
||||||
|
// Если это начало менструации, обновляем настройки и прогнозы
|
||||||
|
if (type == CalendarEventType.MENSTRUATION) {
|
||||||
|
updateLastPeriodDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересчитываем прогнозы и статистику
|
||||||
|
generatePredictions()
|
||||||
|
calculateStatistics()
|
||||||
|
|
||||||
|
// Сохраняем на сервер с дебаунсингом
|
||||||
|
debounceSaveEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дебаунсинг для сохранения событий
|
||||||
|
private fun debounceSaveEvent(event: CalendarEvent) {
|
||||||
|
debounceJob?.cancel()
|
||||||
|
debounceJob = viewModelScope.launch {
|
||||||
|
delay(300) // Дебаунс 300 мс
|
||||||
|
saveEventToServer(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeEvent(date: LocalDate, eventType: CalendarEventType) {
|
||||||
|
val currentEvents = uiState.events.toMutableMap()
|
||||||
|
val dateEvents = currentEvents[date]?.toMutableList()
|
||||||
|
dateEvents?.removeAll { it.type == eventType }
|
||||||
|
|
||||||
|
if (dateEvents.isNullOrEmpty()) {
|
||||||
|
currentEvents.remove(date)
|
||||||
|
} else {
|
||||||
|
currentEvents[date] = dateEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState = uiState.copy(events = currentEvents)
|
||||||
|
|
||||||
|
// Пересчитываем прогнозы
|
||||||
|
generatePredictions()
|
||||||
|
calculateStatistics()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showEventDialog() {
|
||||||
|
uiState = uiState.copy(showEventDialog = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideEventDialog() {
|
||||||
|
uiState = uiState.copy(showEventDialog = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showSettingsDialog() {
|
||||||
|
uiState = uiState.copy(showSettingsDialog = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideSettingsDialog() {
|
||||||
|
uiState = uiState.copy(showSettingsDialog = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCycleSettings(settings: CycleSettings) {
|
||||||
|
uiState = uiState.copy(settings = settings)
|
||||||
|
generatePredictions()
|
||||||
|
// TODO: Сохранить настройки на сервер с дебаунсингом
|
||||||
|
debounceSaveSettings(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun debounceSaveSettings(settings: CycleSettings) {
|
||||||
|
debounceJob?.cancel()
|
||||||
|
debounceJob = viewModelScope.launch {
|
||||||
|
delay(300) // Дебаунс 300 мс
|
||||||
|
saveCycleSettings(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateLastPeriodDate(date: LocalDate) {
|
||||||
|
val updatedSettings = uiState.settings.copy(lastPeriodStart = date)
|
||||||
|
uiState = uiState.copy(settings = updatedSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCycleSettings() {
|
||||||
|
// TODO: Загрузить настройки с сервера
|
||||||
|
// Пока используем настройки по умолчанию
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generatePredictions() {
|
||||||
|
val settings = uiState.settings
|
||||||
|
val lastPeriodStart = settings.lastPeriodStart ?: return
|
||||||
|
|
||||||
|
// Прогнозируем следующую менструацию
|
||||||
|
val nextPeriodStart = lastPeriodStart.plusDays(settings.averageCycleLength.toLong())
|
||||||
|
val nextPeriodEnd = nextPeriodStart.plusDays(settings.averagePeriodLength.toLong() - 1)
|
||||||
|
|
||||||
|
// Прогнозируем овуляцию (примерно за 14 дней до следующих месячных)
|
||||||
|
val nextOvulation = nextPeriodStart.minusDays(14)
|
||||||
|
|
||||||
|
// Окно фертильности (5 дней до овуляции + день овуляции)
|
||||||
|
val fertileWindowStart = nextOvulation.minusDays(4)
|
||||||
|
val fertileWindowEnd = nextOvulation.plusDays(1)
|
||||||
|
|
||||||
|
val prediction = CyclePrediction(
|
||||||
|
nextPeriodStart = nextPeriodStart,
|
||||||
|
nextPeriodEnd = nextPeriodEnd,
|
||||||
|
nextOvulation = nextOvulation,
|
||||||
|
fertileWindowStart = fertileWindowStart,
|
||||||
|
fertileWindowEnd = fertileWindowEnd
|
||||||
|
)
|
||||||
|
|
||||||
|
uiState = uiState.copy(predictions = prediction)
|
||||||
|
|
||||||
|
// Добавляем прогнозные события в календарь
|
||||||
|
addPredictedEvents(prediction)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addPredictedEvents(prediction: CyclePrediction) {
|
||||||
|
val currentEvents = uiState.events.toMutableMap()
|
||||||
|
|
||||||
|
// Удаляем старые прогнозы
|
||||||
|
currentEvents.forEach { (date, events) ->
|
||||||
|
currentEvents[date] = events.filter { it.isActual }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем новые прогнозы
|
||||||
|
val predictedEvents = mutableListOf<Pair<LocalDate, CalendarEvent>>()
|
||||||
|
|
||||||
|
// Прогноз месячных
|
||||||
|
var date = prediction.nextPeriodStart
|
||||||
|
while (!date.isAfter(prediction.nextPeriodEnd)) {
|
||||||
|
predictedEvents.add(
|
||||||
|
date to CalendarEvent(
|
||||||
|
date = date,
|
||||||
|
type = CalendarEventType.PREDICTED_MENSTRUATION,
|
||||||
|
isActual = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
date = date.plusDays(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прогноз овуляции
|
||||||
|
predictedEvents.add(
|
||||||
|
prediction.nextOvulation to CalendarEvent(
|
||||||
|
date = prediction.nextOvulation,
|
||||||
|
type = CalendarEventType.PREDICTED_OVULATION,
|
||||||
|
isActual = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Добавляем прогнозы в календарь
|
||||||
|
predictedEvents.forEach { (eventDate, event) ->
|
||||||
|
val dateEvents = currentEvents[eventDate]?.toMutableList() ?: mutableListOf()
|
||||||
|
dateEvents.add(event)
|
||||||
|
currentEvents[eventDate] = dateEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState = uiState.copy(events = currentEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateStatistics() {
|
||||||
|
val menstruationEvents = uiState.events.values.flatten()
|
||||||
|
.filter { it.type == CalendarEventType.MENSTRUATION && it.isActual }
|
||||||
|
.sortedBy { it.date }
|
||||||
|
|
||||||
|
if (menstruationEvents.size < 2) return
|
||||||
|
|
||||||
|
// Вычисляем длины циклов
|
||||||
|
val cycleLengths = mutableListOf<Int>()
|
||||||
|
for (i in 1 until menstruationEvents.size) {
|
||||||
|
val cycleLength = ChronoUnit.DAYS.between(
|
||||||
|
menstruationEvents[i-1].date,
|
||||||
|
menstruationEvents[i].date
|
||||||
|
).toInt()
|
||||||
|
cycleLengths.add(cycleLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cycleLengths.isEmpty()) return
|
||||||
|
|
||||||
|
val averageLength = cycleLengths.average().toFloat()
|
||||||
|
val variation = cycleLengths.map { abs(it - averageLength) }.average().toFloat()
|
||||||
|
|
||||||
|
// Собираем частые симптомы
|
||||||
|
val allSymptoms = uiState.events.values.flatten()
|
||||||
|
.flatMap { it.symptoms }
|
||||||
|
val symptomFrequency = allSymptoms.groupingBy { it }.eachCount()
|
||||||
|
val commonSymptoms = symptomFrequency.toList()
|
||||||
|
.sortedByDescending { it.second }
|
||||||
|
.take(5)
|
||||||
|
.map { it.first }
|
||||||
|
|
||||||
|
val statistics = CycleStatistics(
|
||||||
|
averageCycleLength = averageLength,
|
||||||
|
cycleVariation = variation,
|
||||||
|
lastCycles = cycleLengths.takeLast(6),
|
||||||
|
periodLengthAverage = uiState.settings.averagePeriodLength.toFloat(),
|
||||||
|
commonSymptoms = commonSymptoms,
|
||||||
|
moodPatterns = emptyMap() // TODO: Вычислить паттерны настроения
|
||||||
|
)
|
||||||
|
|
||||||
|
uiState = uiState.copy(statistics = statistics)
|
||||||
|
|
||||||
|
// Обновляем настройки на основе статистики
|
||||||
|
if (cycleLengths.size >= 3) {
|
||||||
|
val newSettings = uiState.settings.copy(
|
||||||
|
averageCycleLength = averageLength.roundToInt()
|
||||||
|
)
|
||||||
|
uiState = uiState.copy(settings = newSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun saveEventToServer(event: CalendarEvent) {
|
||||||
|
try {
|
||||||
|
// TODO: Реализовать сохранение на сервер через API
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(error = "Ошибка сохранения: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun saveCycleSettings(settings: CycleSettings) {
|
||||||
|
try {
|
||||||
|
// TODO: Реализовать сохранение настроек на сервер через API
|
||||||
|
} catch (e: Exception) {
|
||||||
|
uiState = uiState.copy(error = "Ошибка сохранения настроек: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка ошибки
|
||||||
|
fun clearError() {
|
||||||
|
uiState = uiState.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Принудительное обновление данных
|
||||||
|
fun forceRefresh() {
|
||||||
|
uiState = uiState.copy(lastRefreshed = 0)
|
||||||
|
retryCount = 0 // Сбрасываем счетчик попыток
|
||||||
|
loadCalendarData()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
loadJob?.cancel()
|
||||||
|
debounceJob?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
Reference in New Issue
Block a user