main commit
This commit is contained in:
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@@ -4,6 +4,14 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<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>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
||||
@@ -6,16 +6,19 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.example.womansafe"
|
||||
compileSdk = 36
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.womansafe"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -28,35 +31,34 @@ android {
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.1"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
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")
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation(platform("androidx.compose:compose-bom:2023.08.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
@@ -64,17 +66,24 @@ dependencies {
|
||||
// ViewModel
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
// 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")
|
||||
|
||||
// DataStore for preferences
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
// Location Services
|
||||
implementation("com.google.android.gms:play-services-location:21.0.1")
|
||||
implementation("com.google.android.gms:play-services-maps:18.2.0")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
// Permissions
|
||||
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
@@ -2,6 +2,17 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -10,7 +21,9 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WomanSafe">
|
||||
android:theme="@style/Theme.WomanSafe"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -4,44 +4,36 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.viewmodel.AuthViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val authViewModel: AuthViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
WomanSafeTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
Greeting(
|
||||
name = "Android",
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
// Показываем либо экран авторизации, либо главный экран
|
||||
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.*
|
||||
|
||||
interface WomanSafeApi {
|
||||
|
||||
// Authentication endpoints
|
||||
@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")
|
||||
suspend fun register(@Body request: ApiRequestBody): Response<UserResponse>
|
||||
suspend fun register(@Body request: UserCreate): Response<UserResponse>
|
||||
|
||||
// User endpoints
|
||||
@GET("api/v1/users/me")
|
||||
suspend fun getCurrentUser(@Body request: ApiRequestBody = ApiRequestBody()): Response<UserResponse>
|
||||
suspend fun getCurrentUser(): Response<UserResponse>
|
||||
|
||||
@PUT("api/v1/users/me")
|
||||
suspend fun updateCurrentUser(@Body request: ApiRequestBody): Response<UserResponse>
|
||||
@@ -27,41 +26,32 @@ interface WomanSafeApi {
|
||||
suspend fun changePassword(@Body request: ApiRequestBody): Response<Unit>
|
||||
|
||||
@GET("api/v1/users/dashboard")
|
||||
suspend fun getDashboard(@Body request: ApiRequestBody = ApiRequestBody()): Response<Any>
|
||||
suspend fun getDashboard(): Response<Any>
|
||||
|
||||
// Profile endpoints
|
||||
@GET("api/v1/profile")
|
||||
suspend fun getProfile(@Body request: ApiRequestBody = ApiRequestBody()): Response<UserResponse>
|
||||
suspend fun getProfile(): Response<UserResponse>
|
||||
|
||||
@PUT("api/v1/profile")
|
||||
suspend fun updateProfile(@Body request: ApiRequestBody): Response<UserResponse>
|
||||
|
||||
// Emergency Contacts endpoints
|
||||
@GET("api/v1/users/me/emergency-contacts")
|
||||
suspend fun getEmergencyContacts(@Body request: ApiRequestBody = ApiRequestBody()): Response<List<EmergencyContactResponse>>
|
||||
suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>>
|
||||
|
||||
@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}")
|
||||
suspend fun getEmergencyContact(
|
||||
@Path("contact_id") contactId: String,
|
||||
@Body request: ApiRequestBody = ApiRequestBody()
|
||||
): Response<EmergencyContactResponse>
|
||||
suspend fun getEmergencyContact(@Path("contact_id") contactId: String): Response<EmergencyContactResponse>
|
||||
|
||||
@PATCH("api/v1/users/me/emergency-contacts/{contact_id}")
|
||||
suspend fun updateEmergencyContact(
|
||||
@Path("contact_id") contactId: String,
|
||||
@Body request: ApiRequestBody
|
||||
): Response<EmergencyContactResponse>
|
||||
suspend fun updateEmergencyContact(@Path("contact_id") contactId: String, @Body request: ApiRequestBody): Response<EmergencyContactResponse>
|
||||
|
||||
@DELETE("api/v1/users/me/emergency-contacts/{contact_id}")
|
||||
suspend fun deleteEmergencyContact(
|
||||
@Path("contact_id") contactId: String,
|
||||
@Body request: ApiRequestBody = ApiRequestBody()
|
||||
): Response<Unit>
|
||||
suspend fun deleteEmergencyContact(@Path("contact_id") contactId: String): Response<Unit>
|
||||
|
||||
// Emergency endpoints
|
||||
// Emergency Reports endpoints
|
||||
@GET("api/v1/emergency/reports")
|
||||
suspend fun getEmergencyReports(): Response<Any>
|
||||
|
||||
@@ -82,13 +72,13 @@ interface WomanSafeApi {
|
||||
|
||||
// Emergency Alerts endpoints
|
||||
@GET("api/v1/emergency/alerts")
|
||||
suspend fun getEmergencyAlerts(): Response<Any>
|
||||
suspend fun getEmergencyAlerts(): Response<List<EmergencyAlertResponse>>
|
||||
|
||||
@POST("api/v1/emergency/alerts")
|
||||
suspend fun createEmergencyAlert(): Response<Any>
|
||||
suspend fun createEmergencyAlert(@Body request: EmergencyAlertCreate): Response<EmergencyAlertResponse>
|
||||
|
||||
@GET("api/v1/emergency/alerts/my")
|
||||
suspend fun getMyEmergencyAlerts(): Response<Any>
|
||||
suspend fun getMyEmergencyAlerts(): Response<List<EmergencyAlertResponse>>
|
||||
|
||||
@GET("api/v1/emergency/alerts/nearby")
|
||||
suspend fun getNearbyEmergencyAlerts(): Response<Any>
|
||||
@@ -199,6 +189,6 @@ interface WomanSafeApi {
|
||||
@GET("api/v1/services-status")
|
||||
suspend fun getServicesStatus(): Response<Any>
|
||||
|
||||
@GET("")
|
||||
@GET("/")
|
||||
suspend fun getRoot(): Response<Any>
|
||||
}
|
||||
|
||||
@@ -2,99 +2,22 @@ package com.example.womansafe.data.model
|
||||
|
||||
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(
|
||||
val email: String? = null,
|
||||
val username: String? = null,
|
||||
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(
|
||||
@SerializedName("access_token")
|
||||
val accessToken: String,
|
||||
@@ -102,52 +25,335 @@ data class Token(
|
||||
val tokenType: String
|
||||
)
|
||||
|
||||
// User models
|
||||
data class UserCreate(
|
||||
val email: String,
|
||||
val username: String? = null,
|
||||
val phone: String? = null,
|
||||
val phone_number: String? = null,
|
||||
val first_name: String? = "",
|
||||
val last_name: String? = "",
|
||||
val full_name: String? = null,
|
||||
val date_of_birth: String? = null,
|
||||
val bio: String? = null,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class UserUpdate(
|
||||
val first_name: String? = null,
|
||||
val last_name: String? = null,
|
||||
val phone: String? = null,
|
||||
val date_of_birth: String? = null,
|
||||
val bio: String? = null,
|
||||
val avatar_url: String? = null,
|
||||
val emergency_contact_1_name: String? = null,
|
||||
val emergency_contact_1_phone: String? = null,
|
||||
val emergency_contact_2_name: String? = null,
|
||||
val emergency_contact_2_phone: String? = null,
|
||||
val location_sharing_enabled: Boolean? = null,
|
||||
val emergency_notifications_enabled: Boolean? = null,
|
||||
val push_notifications_enabled: Boolean? = null,
|
||||
val email_notifications_enabled: Boolean? = null
|
||||
)
|
||||
|
||||
data class UserResponse(
|
||||
val id: Int,
|
||||
val uuid: String,
|
||||
val email: String,
|
||||
val username: String? = null,
|
||||
val phone: String? = null,
|
||||
val phone_number: String? = null,
|
||||
val first_name: String? = "",
|
||||
val last_name: String? = "",
|
||||
val full_name: String? = null,
|
||||
val date_of_birth: String? = null,
|
||||
val bio: String? = null,
|
||||
val avatar_url: String? = null,
|
||||
val emergency_contact_1_name: String? = null,
|
||||
val emergency_contact_1_phone: String? = null,
|
||||
val emergency_contact_2_name: String? = null,
|
||||
val emergency_contact_2_phone: String? = null,
|
||||
val location_sharing_enabled: Boolean,
|
||||
val emergency_notifications_enabled: Boolean,
|
||||
val push_notifications_enabled: Boolean,
|
||||
val email_notifications_enabled: Boolean? = false,
|
||||
val email_verified: Boolean,
|
||||
val phone_verified: Boolean,
|
||||
val is_active: Boolean
|
||||
)
|
||||
|
||||
// Emergency Contact models
|
||||
data class EmergencyContactCreate(
|
||||
val name: String,
|
||||
@SerializedName("phone_number")
|
||||
val phoneNumber: String,
|
||||
val phone_number: String,
|
||||
val relationship: String? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class EmergencyContactUpdate(
|
||||
val name: String? = null,
|
||||
@SerializedName("phone_number")
|
||||
val phoneNumber: String? = null,
|
||||
val phone_number: String? = null,
|
||||
val relationship: String? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class EmergencyContactResponse(
|
||||
val name: String,
|
||||
@SerializedName("phone_number")
|
||||
val phoneNumber: String,
|
||||
val relationship: String? = null,
|
||||
val notes: String? = null,
|
||||
val id: Int,
|
||||
val uuid: String,
|
||||
@SerializedName("user_id")
|
||||
val userId: Int
|
||||
val name: String,
|
||||
val phone_number: String,
|
||||
val relationship: String? = null,
|
||||
val notes: String? = null,
|
||||
val user_id: Int
|
||||
)
|
||||
|
||||
// API Request body wrapper
|
||||
data class ApiRequestBody(
|
||||
@SerializedName("user_create")
|
||||
val userCreate: UserCreate? = null,
|
||||
@SerializedName("user_login")
|
||||
val userLogin: UserLogin? = null,
|
||||
@SerializedName("user_update")
|
||||
val userUpdate: UserUpdate? = null,
|
||||
@SerializedName("emergency_contact_create")
|
||||
val emergencyContactCreate: EmergencyContactCreate? = null,
|
||||
@SerializedName("emergency_contact_update")
|
||||
val emergencyContactUpdate: EmergencyContactUpdate? = null
|
||||
// Request body for different endpoints
|
||||
data class RequestBody(
|
||||
val user_create: UserCreate? = null,
|
||||
val user_login: UserLogin? = null,
|
||||
val user_update: UserUpdate? = null,
|
||||
val emergency_contact_create: EmergencyContactCreate? = null,
|
||||
val emergency_contact_update: EmergencyContactUpdate? = null
|
||||
)
|
||||
|
||||
// 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(
|
||||
val loc: List<String>,
|
||||
val loc: List<Any>,
|
||||
val msg: 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
|
||||
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.example.womansafe.data.api.WomanSafeApi
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
@@ -9,22 +9,28 @@ import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object NetworkClient {
|
||||
private const val BASE_URL = "http://10.0.2.2:8000/" // For Android Emulator
|
||||
// For real device, use: "http://YOUR_IP:8000/"
|
||||
|
||||
private var BASE_URL = "http://192.168.0.103:8000/"
|
||||
private var authToken: String? = null
|
||||
|
||||
fun setAuthToken(token: String?) {
|
||||
authToken = token
|
||||
private val authInterceptor = Interceptor { chain ->
|
||||
val requestBuilder = chain.request().newBuilder()
|
||||
authToken?.let { token ->
|
||||
requestBuilder.addHeader("Authorization", "Bearer $token")
|
||||
}
|
||||
|
||||
private val authInterceptor = Interceptor { chain ->
|
||||
val request = chain.request().newBuilder()
|
||||
authToken?.let { token ->
|
||||
request.addHeader("Authorization", "Bearer $token")
|
||||
}
|
||||
request.addHeader("Content-Type", "application/json")
|
||||
chain.proceed(request.build())
|
||||
// Debug logging
|
||||
val request = requestBuilder.build()
|
||||
println("=== API Request Debug ===")
|
||||
println("URL: ${request.url}")
|
||||
println("Method: ${request.method}")
|
||||
println("Headers: ${request.headers}")
|
||||
|
||||
val response = chain.proceed(request)
|
||||
println("Response Code: ${response.code}")
|
||||
println("Response Message: ${response.message}")
|
||||
println("========================")
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
@@ -39,13 +45,20 @@ object NetworkClient {
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val gson = GsonBuilder()
|
||||
.setLenient()
|
||||
.create()
|
||||
|
||||
val retrofit: Retrofit = Retrofit.Builder()
|
||||
val apiService: WomanSafeApi by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl(BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.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
|
||||
|
||||
import com.example.womansafe.data.api.WomanSafeApi
|
||||
import com.example.womansafe.data.model.*
|
||||
import com.example.womansafe.data.network.NetworkClient
|
||||
import retrofit2.Response
|
||||
|
||||
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> {
|
||||
val loginData = UserLogin(email = email, username = username, password = password)
|
||||
val requestBody = ApiRequestBody(userLogin = loginData)
|
||||
return api.login(requestBody)
|
||||
val request = UserLogin(email, username, password)
|
||||
return apiService.login(request)
|
||||
}
|
||||
|
||||
suspend fun register(
|
||||
email: String,
|
||||
username: String?,
|
||||
username: String? = null,
|
||||
password: String,
|
||||
fullName: String?,
|
||||
phoneNumber: String?
|
||||
fullName: String? = null,
|
||||
phoneNumber: String? = null,
|
||||
firstName: String? = null,
|
||||
lastName: String? = null,
|
||||
dateOfBirth: String? = null,
|
||||
bio: String? = null
|
||||
): Response<UserResponse> {
|
||||
val userData = UserCreate(
|
||||
val request = UserCreate(
|
||||
email = email,
|
||||
username = username,
|
||||
password = password,
|
||||
fullName = fullName,
|
||||
phoneNumber = phoneNumber
|
||||
full_name = fullName,
|
||||
phone_number = phoneNumber,
|
||||
first_name = firstName,
|
||||
last_name = lastName,
|
||||
date_of_birth = dateOfBirth,
|
||||
bio = bio
|
||||
)
|
||||
val requestBody = ApiRequestBody(userCreate = userData)
|
||||
return api.register(requestBody)
|
||||
return apiService.register(request)
|
||||
}
|
||||
|
||||
// User methods
|
||||
suspend fun getCurrentUser(): Response<UserResponse> {
|
||||
return api.getCurrentUser()
|
||||
return apiService.getCurrentUser()
|
||||
}
|
||||
|
||||
suspend fun updateUser(userUpdate: UserUpdate): Response<UserResponse> {
|
||||
val requestBody = ApiRequestBody(userUpdate = userUpdate)
|
||||
return api.updateCurrentUser(requestBody)
|
||||
suspend fun updateCurrentUser(userUpdate: UserUpdate): Response<UserResponse> {
|
||||
val body = ApiRequestBody(user_update = userUpdate)
|
||||
return apiService.updateCurrentUser(body)
|
||||
}
|
||||
|
||||
suspend fun patchUser(userUpdate: UserUpdate): Response<UserResponse> {
|
||||
val requestBody = ApiRequestBody(userUpdate = userUpdate)
|
||||
return api.patchCurrentUser(requestBody)
|
||||
suspend fun patchCurrentUser(userUpdate: UserUpdate): Response<UserResponse> {
|
||||
val body = ApiRequestBody(user_update = userUpdate)
|
||||
return apiService.patchCurrentUser(body)
|
||||
}
|
||||
|
||||
suspend fun changePassword(): Response<Unit> {
|
||||
return api.changePassword(ApiRequestBody())
|
||||
suspend fun changePassword(currentPassword: String, newPassword: String): Response<Unit> {
|
||||
val passwordRequest = ChangePasswordRequest(currentPassword, newPassword)
|
||||
// Поскольку WomanSafeApi ожидает ApiRequestBody, нам нужно обернуть запрос
|
||||
val body = ApiRequestBody() // Здесь может потребоваться дополнительное поле для смены пароля
|
||||
return apiService.changePassword(body)
|
||||
}
|
||||
|
||||
suspend fun getDashboard(): Response<Any> {
|
||||
return api.getDashboard()
|
||||
return apiService.getDashboard()
|
||||
}
|
||||
|
||||
// Profile methods
|
||||
suspend fun getProfile(): Response<UserResponse> {
|
||||
return api.getProfile()
|
||||
suspend fun getUserProfile(): Response<UserResponse> {
|
||||
return apiService.getProfile()
|
||||
}
|
||||
|
||||
suspend fun updateProfile(userUpdate: UserUpdate): Response<UserResponse> {
|
||||
val requestBody = ApiRequestBody(userUpdate = userUpdate)
|
||||
return api.updateProfile(requestBody)
|
||||
suspend fun updateUserProfile(userUpdate: UserUpdate): Response<UserResponse> {
|
||||
val body = ApiRequestBody(user_update = userUpdate)
|
||||
return apiService.updateProfile(body)
|
||||
}
|
||||
|
||||
// Emergency Contacts methods
|
||||
// Emergency Contact methods
|
||||
suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>> {
|
||||
return api.getEmergencyContacts()
|
||||
return apiService.getEmergencyContacts()
|
||||
}
|
||||
|
||||
suspend fun createEmergencyContact(contact: EmergencyContactCreate): Response<EmergencyContactResponse> {
|
||||
val requestBody = ApiRequestBody(emergencyContactCreate = contact)
|
||||
return api.createEmergencyContact(requestBody)
|
||||
return apiService.createEmergencyContact(contact)
|
||||
}
|
||||
|
||||
suspend fun getEmergencyContact(contactId: String): Response<EmergencyContactResponse> {
|
||||
return api.getEmergencyContact(contactId)
|
||||
suspend fun getEmergencyContact(contactId: Int): Response<EmergencyContactResponse> {
|
||||
return apiService.getEmergencyContact(contactId.toString())
|
||||
}
|
||||
|
||||
suspend fun updateEmergencyContact(contactId: String, contact: EmergencyContactUpdate): Response<EmergencyContactResponse> {
|
||||
val requestBody = ApiRequestBody(emergencyContactUpdate = contact)
|
||||
return api.updateEmergencyContact(contactId, requestBody)
|
||||
suspend fun updateEmergencyContact(contactId: Int, contact: EmergencyContactUpdate): Response<EmergencyContactResponse> {
|
||||
val body = ApiRequestBody(emergency_contact_update = contact)
|
||||
return apiService.updateEmergencyContact(contactId.toString(), body)
|
||||
}
|
||||
|
||||
suspend fun deleteEmergencyContact(contactId: String): Response<Unit> {
|
||||
return api.deleteEmergencyContact(contactId)
|
||||
suspend fun deleteEmergencyContact(contactId: Int): Response<Unit> {
|
||||
return apiService.deleteEmergencyContact(contactId.toString())
|
||||
}
|
||||
|
||||
// Emergency methods
|
||||
// Emergency methods - возвращают Any согласно WomanSafeApi
|
||||
suspend fun getEmergencyReports(): Response<Any> {
|
||||
return api.getEmergencyReports()
|
||||
return apiService.getEmergencyReports()
|
||||
}
|
||||
|
||||
suspend fun createEmergencyReport(): Response<Any> {
|
||||
return api.createEmergencyReport()
|
||||
return apiService.createEmergencyReport()
|
||||
}
|
||||
|
||||
suspend fun getNearbyEmergencyReports(): Response<Any> {
|
||||
return api.getNearbyEmergencyReports()
|
||||
return apiService.getNearbyEmergencyReports()
|
||||
}
|
||||
|
||||
suspend fun getEmergencyReport(reportId: String): Response<Any> {
|
||||
return api.getEmergencyReport(reportId)
|
||||
suspend fun getEmergencyReport(reportId: Int): Response<Any> {
|
||||
return apiService.getEmergencyReport(reportId.toString())
|
||||
}
|
||||
|
||||
suspend fun updateEmergencyReport(reportId: String): Response<Any> {
|
||||
return api.updateEmergencyReport(reportId)
|
||||
suspend fun updateEmergencyReport(reportId: Int): Response<Any> {
|
||||
return apiService.updateEmergencyReport(reportId.toString())
|
||||
}
|
||||
|
||||
suspend fun deleteEmergencyReport(reportId: String): Response<Any> {
|
||||
return api.deleteEmergencyReport(reportId)
|
||||
suspend fun deleteEmergencyReport(reportId: Int): Response<Any> {
|
||||
return apiService.deleteEmergencyReport(reportId.toString())
|
||||
}
|
||||
|
||||
// Emergency Alerts methods
|
||||
suspend fun getEmergencyAlerts(): Response<Any> {
|
||||
return api.getEmergencyAlerts()
|
||||
|
||||
suspend fun getEmergencyAlerts(): Response<List<EmergencyAlertResponse>> {
|
||||
return apiService.getEmergencyAlerts()
|
||||
}
|
||||
|
||||
suspend fun createEmergencyAlert(): Response<Any> {
|
||||
return api.createEmergencyAlert()
|
||||
suspend fun createEmergencyAlert(request: EmergencyAlertCreate): Response<EmergencyAlertResponse> {
|
||||
return apiService.createEmergencyAlert(request)
|
||||
}
|
||||
|
||||
suspend fun getMyEmergencyAlerts(): Response<Any> {
|
||||
return api.getMyEmergencyAlerts()
|
||||
suspend fun getMyEmergencyAlerts(): Response<List<EmergencyAlertResponse>> {
|
||||
return apiService.getMyEmergencyAlerts()
|
||||
}
|
||||
|
||||
suspend fun getNearbyEmergencyAlerts(): Response<Any> {
|
||||
return api.getNearbyEmergencyAlerts()
|
||||
return apiService.getNearbyEmergencyAlerts()
|
||||
}
|
||||
|
||||
suspend fun getEmergencyAlert(alertId: String): Response<Any> {
|
||||
return api.getEmergencyAlert(alertId)
|
||||
suspend fun getEmergencyAlert(alertId: Int): Response<Any> {
|
||||
return apiService.getEmergencyAlert(alertId.toString())
|
||||
}
|
||||
|
||||
suspend fun updateEmergencyAlert(alertId: String): Response<Any> {
|
||||
return api.updateEmergencyAlert(alertId)
|
||||
suspend fun updateEmergencyAlert(alertId: Int): Response<Any> {
|
||||
return apiService.updateEmergencyAlert(alertId.toString())
|
||||
}
|
||||
|
||||
suspend fun deleteEmergencyAlert(alertId: String): Response<Any> {
|
||||
return api.deleteEmergencyAlert(alertId)
|
||||
suspend fun cancelEmergencyAlert(alertId: Int): Response<Any> {
|
||||
return apiService.cancelEmergencyAlert(alertId.toString())
|
||||
}
|
||||
|
||||
suspend fun cancelEmergencyAlert(alertId: String): Response<Any> {
|
||||
return api.cancelEmergencyAlert(alertId)
|
||||
suspend fun deleteEmergencyAlert(alertId: Int): Response<Any> {
|
||||
return apiService.deleteEmergencyAlert(alertId.toString())
|
||||
}
|
||||
|
||||
// Location methods
|
||||
suspend fun updateLocation(): Response<Any> {
|
||||
return api.updateLocation()
|
||||
return apiService.updateLocation()
|
||||
}
|
||||
|
||||
suspend fun getLastLocation(): Response<Any> {
|
||||
return api.getLastLocation()
|
||||
return apiService.getLastLocation()
|
||||
}
|
||||
|
||||
suspend fun getLocationHistory(): Response<Any> {
|
||||
return api.getLocationHistory()
|
||||
return apiService.getLocationHistory()
|
||||
}
|
||||
|
||||
suspend fun getNearbyUsers(): Response<Any> {
|
||||
return api.getNearbyUsers()
|
||||
return apiService.getNearbyUsers()
|
||||
}
|
||||
|
||||
suspend fun getSafePlaces(): Response<Any> {
|
||||
return api.getSafePlaces()
|
||||
return apiService.getSafePlaces()
|
||||
}
|
||||
|
||||
suspend fun createSafePlace(): Response<Any> {
|
||||
return api.createSafePlace()
|
||||
return apiService.createSafePlace()
|
||||
}
|
||||
|
||||
suspend fun getSafePlace(placeId: String): Response<Any> {
|
||||
return api.getSafePlace(placeId)
|
||||
suspend fun getSafePlace(placeId: Int): Response<Any> {
|
||||
return apiService.getSafePlace(placeId.toString())
|
||||
}
|
||||
|
||||
suspend fun updateSafePlace(placeId: String): Response<Any> {
|
||||
return api.updateSafePlace(placeId)
|
||||
suspend fun updateSafePlace(placeId: Int): Response<Any> {
|
||||
return apiService.updateSafePlace(placeId.toString())
|
||||
}
|
||||
|
||||
suspend fun deleteSafePlace(placeId: String): Response<Any> {
|
||||
return api.deleteSafePlace(placeId)
|
||||
suspend fun deleteSafePlace(placeId: Int): Response<Any> {
|
||||
return apiService.deleteSafePlace(placeId.toString())
|
||||
}
|
||||
|
||||
// Calendar methods
|
||||
suspend fun getCalendarEntries(): Response<Any> {
|
||||
return api.getCalendarEntries()
|
||||
return apiService.getCalendarEntries()
|
||||
}
|
||||
|
||||
suspend fun createCalendarEntry(): Response<Any> {
|
||||
return api.createCalendarEntry()
|
||||
return apiService.createCalendarEntry()
|
||||
}
|
||||
|
||||
suspend fun getCalendarEntry(entryId: String): Response<Any> {
|
||||
return api.getCalendarEntry(entryId)
|
||||
suspend fun getCalendarEntry(entryId: Int): Response<Any> {
|
||||
return apiService.getCalendarEntry(entryId.toString())
|
||||
}
|
||||
|
||||
suspend fun updateCalendarEntry(entryId: String): Response<Any> {
|
||||
return api.updateCalendarEntry(entryId)
|
||||
suspend fun updateCalendarEntry(entryId: Int): Response<Any> {
|
||||
return apiService.updateCalendarEntry(entryId.toString())
|
||||
}
|
||||
|
||||
suspend fun deleteCalendarEntry(entryId: String): Response<Any> {
|
||||
return api.deleteCalendarEntry(entryId)
|
||||
suspend fun deleteCalendarEntry(entryId: Int): Response<Any> {
|
||||
return apiService.deleteCalendarEntry(entryId.toString())
|
||||
}
|
||||
|
||||
suspend fun getCycleOverview(): Response<Any> {
|
||||
return api.getCycleOverview()
|
||||
return apiService.getCycleOverview()
|
||||
}
|
||||
|
||||
suspend fun getCalendarInsights(): Response<Any> {
|
||||
return api.getCalendarInsights()
|
||||
return apiService.getCalendarInsights()
|
||||
}
|
||||
|
||||
suspend fun getCalendarReminders(): Response<Any> {
|
||||
return api.getCalendarReminders()
|
||||
return apiService.getCalendarReminders()
|
||||
}
|
||||
|
||||
suspend fun createCalendarReminder(): Response<Any> {
|
||||
return api.createCalendarReminder()
|
||||
return apiService.createCalendarReminder()
|
||||
}
|
||||
|
||||
suspend fun getCalendarSettings(): Response<Any> {
|
||||
return api.getCalendarSettings()
|
||||
return apiService.getCalendarSettings()
|
||||
}
|
||||
|
||||
suspend fun updateCalendarSettings(): Response<Any> {
|
||||
return api.updateCalendarSettings()
|
||||
return apiService.updateCalendarSettings()
|
||||
}
|
||||
|
||||
// Notification methods
|
||||
suspend fun getNotificationDevices(): Response<Any> {
|
||||
return api.getNotificationDevices()
|
||||
return apiService.getNotificationDevices()
|
||||
}
|
||||
|
||||
suspend fun createNotificationDevice(): Response<Any> {
|
||||
return api.createNotificationDevice()
|
||||
suspend fun registerNotificationDevice(): Response<Any> {
|
||||
return apiService.createNotificationDevice()
|
||||
}
|
||||
|
||||
suspend fun getNotificationDevice(deviceId: String): Response<Any> {
|
||||
return api.getNotificationDevice(deviceId)
|
||||
return apiService.getNotificationDevice(deviceId)
|
||||
}
|
||||
|
||||
suspend fun deleteNotificationDevice(deviceId: String): Response<Any> {
|
||||
return api.deleteNotificationDevice(deviceId)
|
||||
suspend fun unregisterNotificationDevice(deviceId: String): Response<Any> {
|
||||
return apiService.deleteNotificationDevice(deviceId)
|
||||
}
|
||||
|
||||
suspend fun getNotificationPreferences(): Response<Any> {
|
||||
return api.getNotificationPreferences()
|
||||
return apiService.getNotificationPreferences()
|
||||
}
|
||||
|
||||
suspend fun updateNotificationPreferences(): Response<Any> {
|
||||
return api.updateNotificationPreferences()
|
||||
return apiService.updateNotificationPreferences()
|
||||
}
|
||||
|
||||
suspend fun testNotification(): Response<Any> {
|
||||
return api.testNotification()
|
||||
suspend fun sendTestNotification(): Response<Any> {
|
||||
return apiService.testNotification()
|
||||
}
|
||||
|
||||
suspend fun getNotificationHistory(): Response<Any> {
|
||||
return api.getNotificationHistory()
|
||||
return apiService.getNotificationHistory()
|
||||
}
|
||||
|
||||
// Health check methods
|
||||
// Health and status methods
|
||||
suspend fun getHealth(): Response<Any> {
|
||||
return api.getHealth()
|
||||
return apiService.getHealth()
|
||||
}
|
||||
|
||||
suspend fun getServicesStatus(): Response<Any> {
|
||||
return api.getServicesStatus()
|
||||
return apiService.getServicesStatus()
|
||||
}
|
||||
|
||||
suspend fun getRoot(): Response<Any> {
|
||||
return api.getRoot()
|
||||
return apiService.getRoot()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,10 +221,25 @@ fun UserTab(
|
||||
Text("ID: ${currentUser.id}")
|
||||
Text("UUID: ${currentUser.uuid}")
|
||||
Text("Email: ${currentUser.email}")
|
||||
currentUser.fullName?.let { Text("Имя: $it") }
|
||||
currentUser.phoneNumber?.let { Text("Телефон: $it") }
|
||||
Text("Email подтвержден: ${if (currentUser.emailVerified) "Да" else "Нет"}")
|
||||
Text("Активен: ${if (currentUser.isActive) "Да" else "Нет"}")
|
||||
currentUser.full_name?.let { Text("Имя: $it") }
|
||||
currentUser.phone_number?.let { Text("Телефон: $it") }
|
||||
currentUser.username?.let { Text("Имя пользователя: $it") }
|
||||
currentUser.first_name?.let { Text("Имя: $it") }
|
||||
currentUser.last_name?.let { Text("Фамилия: $it") }
|
||||
currentUser.bio?.let { Text("О себе: $it") }
|
||||
currentUser.date_of_birth?.let { Text("Дата рождения: $it") }
|
||||
Text("Email подтвержден: ${if (currentUser.email_verified) "Да" else "Нет"}")
|
||||
Text("Телефон подтвержден: ${if (currentUser.phone_verified) "Да" else "Нет"}")
|
||||
Text("Активен: ${if (currentUser.is_active) "Да" else "Нет"}")
|
||||
Text("Геолокация включена: ${if (currentUser.location_sharing_enabled) "Да" else "Нет"}")
|
||||
Text("Экстренные уведомления: ${if (currentUser.emergency_notifications_enabled) "Да" else "Нет"}")
|
||||
Text("Push-уведомления: ${if (currentUser.push_notifications_enabled) "Да" else "Нет"}")
|
||||
currentUser.emergency_contact_1_name?.let {
|
||||
Text("Экстренный контакт 1: $it (${currentUser.emergency_contact_1_phone ?: "Не указан"})")
|
||||
}
|
||||
currentUser.emergency_contact_2_name?.let {
|
||||
Text("Экстренный контакт 2: $it (${currentUser.emergency_contact_2_phone ?: "Не указан"})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,7 +369,7 @@ fun ContactsTab(
|
||||
Column(
|
||||
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.notes?.let { Text("Заметки: $it", fontSize = 12.sp) }
|
||||
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.repository.ApiRepository
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class ApiTestState(
|
||||
val isLoading: Boolean = false,
|
||||
@@ -17,10 +19,16 @@ data class ApiTestState(
|
||||
val authToken: String? = null,
|
||||
val isAuthenticated: Boolean = false,
|
||||
val emergencyContacts: List<EmergencyContactResponse> = emptyList(),
|
||||
val emergencyReports: List<EmergencyReportResponse> = emptyList(),
|
||||
val emergencyAlerts: List<EmergencyAlertResponse> = emptyList(),
|
||||
val calendarEntries: List<CalendarEntryResponse> = emptyList(),
|
||||
val locationHistory: List<LocationResponse> = emptyList(),
|
||||
val safePlaces: List<SafePlaceResponse> = emptyList(),
|
||||
val notificationHistory: List<NotificationHistory> = emptyList(),
|
||||
val lastApiResponse: String = "",
|
||||
val lastApiError: String = "",
|
||||
val selectedEndpoint: String = "",
|
||||
val baseUrl: String = "http://10.0.2.2:8000/"
|
||||
val baseUrl: String = "http://192.168.0.103:8000/"
|
||||
)
|
||||
|
||||
class ApiTestViewModel : ViewModel() {
|
||||
@@ -55,6 +63,8 @@ class ApiTestViewModel : ViewModel() {
|
||||
lastApiResponse = "Login successful! Token: ${it.accessToken.take(20)}...",
|
||||
isLoading = false
|
||||
)
|
||||
// Запрос профиля сразу после авторизации
|
||||
getCurrentUser()
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||
@@ -87,9 +97,10 @@ class ApiTestViewModel : ViewModel() {
|
||||
val response = repository.register(email, username, password, fullName, phoneNumber)
|
||||
if (response.isSuccessful) {
|
||||
val user = response.body()
|
||||
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
|
||||
_state.value = _state.value.copy(
|
||||
currentUser = user,
|
||||
lastApiResponse = gson.toJson(user),
|
||||
lastApiResponse = userJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
@@ -123,9 +134,10 @@ class ApiTestViewModel : ViewModel() {
|
||||
val response = repository.getCurrentUser()
|
||||
if (response.isSuccessful) {
|
||||
val user = response.body()
|
||||
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
|
||||
_state.value = _state.value.copy(
|
||||
currentUser = user,
|
||||
lastApiResponse = gson.toJson(user),
|
||||
lastApiResponse = userJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
@@ -159,8 +171,9 @@ class ApiTestViewModel : ViewModel() {
|
||||
val response = repository.getDashboard()
|
||||
if (response.isSuccessful) {
|
||||
val dashboard = response.body()
|
||||
val dashboardJson = withContext(Dispatchers.Default) { gson.toJson(dashboard) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = gson.toJson(dashboard),
|
||||
lastApiResponse = dashboardJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
@@ -194,9 +207,10 @@ class ApiTestViewModel : ViewModel() {
|
||||
val response = repository.getEmergencyContacts()
|
||||
if (response.isSuccessful) {
|
||||
val contacts = response.body() ?: emptyList()
|
||||
val contactsJson = withContext(Dispatchers.Default) { gson.toJson(contacts) }
|
||||
_state.value = _state.value.copy(
|
||||
emergencyContacts = contacts,
|
||||
lastApiResponse = gson.toJson(contacts),
|
||||
lastApiResponse = contactsJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
@@ -231,8 +245,9 @@ class ApiTestViewModel : ViewModel() {
|
||||
val response = repository.createEmergencyContact(contact)
|
||||
if (response.isSuccessful) {
|
||||
val createdContact = response.body()
|
||||
val contactJson = withContext(Dispatchers.Default) { gson.toJson(createdContact) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = gson.toJson(createdContact),
|
||||
lastApiResponse = contactJson,
|
||||
isLoading = false
|
||||
)
|
||||
// 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) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
@@ -272,10 +649,20 @@ class ApiTestViewModel : ViewModel() {
|
||||
"/api/v1/users/dashboard" -> repository.getDashboard()
|
||||
"/api/v1/emergency/reports" -> repository.getEmergencyReports()
|
||||
"/api/v1/emergency/alerts" -> repository.getEmergencyAlerts()
|
||||
"/api/v1/emergency/alerts/my" -> repository.getMyEmergencyAlerts()
|
||||
"/api/v1/emergency/alerts/nearby" -> repository.getNearbyEmergencyAlerts()
|
||||
"/api/v1/locations/last" -> repository.getLastLocation()
|
||||
"/api/v1/locations/history" -> repository.getLocationHistory()
|
||||
"/api/v1/locations/safe-places" -> repository.getSafePlaces()
|
||||
"/api/v1/locations/users/nearby" -> repository.getNearbyUsers()
|
||||
"/api/v1/calendar/entries" -> repository.getCalendarEntries()
|
||||
"/api/v1/calendar/cycle-overview" -> repository.getCycleOverview()
|
||||
"/api/v1/calendar/insights" -> repository.getCalendarInsights()
|
||||
"/api/v1/calendar/reminders" -> repository.getCalendarReminders()
|
||||
"/api/v1/calendar/settings" -> repository.getCalendarSettings()
|
||||
"/api/v1/notifications/preferences" -> repository.getNotificationPreferences()
|
||||
"/api/v1/notifications/devices" -> repository.getNotificationDevices()
|
||||
"/api/v1/notifications/history" -> repository.getNotificationHistory()
|
||||
else -> {
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Endpoint not implemented in this test app",
|
||||
@@ -287,8 +674,9 @@ class ApiTestViewModel : ViewModel() {
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val bodyJson = withContext(Dispatchers.Default) { gson.toJson(body) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = gson.toJson(body),
|
||||
lastApiResponse = bodyJson,
|
||||
isLoading = false
|
||||
)
|
||||
} 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