diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index b268ef3..8627841 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,6 +4,14 @@
+
+
+
+
+
+
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5d15fc6..39f3f90 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
-}
\ No newline at end of file
+ // 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")
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 165b2c3..c6849cf 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,17 @@
+
+
+
+
+
+
+
+
+
+
+
+ android:theme="@style/Theme.WomanSafe"
+ android:usesCleartextTraffic="true"
+ android:networkSecurityConfig="@xml/network_security_config">
- 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")
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/womansafe/data/api/WomanSafeApi.kt b/app/src/main/java/com/example/womansafe/data/api/WomanSafeApi.kt
index fd0de55..481b219 100644
--- a/app/src/main/java/com/example/womansafe/data/api/WomanSafeApi.kt
+++ b/app/src/main/java/com/example/womansafe/data/api/WomanSafeApi.kt
@@ -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
+ suspend fun login(@Body request: UserLogin): Response
@POST("api/v1/auth/register")
- suspend fun register(@Body request: ApiRequestBody): Response
+ suspend fun register(@Body request: UserCreate): Response
// User endpoints
@GET("api/v1/users/me")
- suspend fun getCurrentUser(@Body request: ApiRequestBody = ApiRequestBody()): Response
+ suspend fun getCurrentUser(): Response
@PUT("api/v1/users/me")
suspend fun updateCurrentUser(@Body request: ApiRequestBody): Response
@@ -27,41 +26,32 @@ interface WomanSafeApi {
suspend fun changePassword(@Body request: ApiRequestBody): Response
@GET("api/v1/users/dashboard")
- suspend fun getDashboard(@Body request: ApiRequestBody = ApiRequestBody()): Response
+ suspend fun getDashboard(): Response
// Profile endpoints
@GET("api/v1/profile")
- suspend fun getProfile(@Body request: ApiRequestBody = ApiRequestBody()): Response
+ suspend fun getProfile(): Response
@PUT("api/v1/profile")
suspend fun updateProfile(@Body request: ApiRequestBody): Response
// Emergency Contacts endpoints
@GET("api/v1/users/me/emergency-contacts")
- suspend fun getEmergencyContacts(@Body request: ApiRequestBody = ApiRequestBody()): Response>
+ suspend fun getEmergencyContacts(): Response>
@POST("api/v1/users/me/emergency-contacts")
- suspend fun createEmergencyContact(@Body request: ApiRequestBody): Response
+ suspend fun createEmergencyContact(@Body request: EmergencyContactCreate): Response
@GET("api/v1/users/me/emergency-contacts/{contact_id}")
- suspend fun getEmergencyContact(
- @Path("contact_id") contactId: String,
- @Body request: ApiRequestBody = ApiRequestBody()
- ): Response
+ suspend fun getEmergencyContact(@Path("contact_id") contactId: String): Response
@PATCH("api/v1/users/me/emergency-contacts/{contact_id}")
- suspend fun updateEmergencyContact(
- @Path("contact_id") contactId: String,
- @Body request: ApiRequestBody
- ): Response
+ suspend fun updateEmergencyContact(@Path("contact_id") contactId: String, @Body request: ApiRequestBody): Response
@DELETE("api/v1/users/me/emergency-contacts/{contact_id}")
- suspend fun deleteEmergencyContact(
- @Path("contact_id") contactId: String,
- @Body request: ApiRequestBody = ApiRequestBody()
- ): Response
+ suspend fun deleteEmergencyContact(@Path("contact_id") contactId: String): Response
- // Emergency endpoints
+ // Emergency Reports endpoints
@GET("api/v1/emergency/reports")
suspend fun getEmergencyReports(): Response
@@ -82,13 +72,13 @@ interface WomanSafeApi {
// Emergency Alerts endpoints
@GET("api/v1/emergency/alerts")
- suspend fun getEmergencyAlerts(): Response
+ suspend fun getEmergencyAlerts(): Response>
@POST("api/v1/emergency/alerts")
- suspend fun createEmergencyAlert(): Response
+ suspend fun createEmergencyAlert(@Body request: EmergencyAlertCreate): Response
@GET("api/v1/emergency/alerts/my")
- suspend fun getMyEmergencyAlerts(): Response
+ suspend fun getMyEmergencyAlerts(): Response>
@GET("api/v1/emergency/alerts/nearby")
suspend fun getNearbyEmergencyAlerts(): Response
@@ -199,6 +189,6 @@ interface WomanSafeApi {
@GET("api/v1/services-status")
suspend fun getServicesStatus(): Response
- @GET("")
+ @GET("/")
suspend fun getRoot(): Response
}
diff --git a/app/src/main/java/com/example/womansafe/data/model/ApiModels.kt b/app/src/main/java/com/example/womansafe/data/model/ApiModels.kt
index 5ab662c..c187c44 100644
--- a/app/src/main/java/com/example/womansafe/data/model/ApiModels.kt
+++ b/app/src/main/java/com/example/womansafe/data/model/ApiModels.kt
@@ -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? = null,
+ val recent_activities: List? = 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? = 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? = 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,
+ val symptom_frequency: Map
+)
+
+data class CycleOverview(
+ val current_phase: String,
+ val next_period_date: String,
+ val cycle_day: Int,
+ val fertile_window: List
+)
+
+// 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,
+ val loc: List,
val msg: String,
val type: String
)
diff --git a/app/src/main/java/com/example/womansafe/data/model/CalendarModels.kt b/app/src/main/java/com/example/womansafe/data/model/CalendarModels.kt
new file mode 100644
index 0000000..c11883c
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/data/model/CalendarModels.kt
@@ -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 = 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, // Длины последних циклов
+ val periodLengthAverage: Float,
+ val commonSymptoms: List,
+ val moodPatterns: Map
+)
diff --git a/app/src/main/java/com/example/womansafe/data/model/EmergencyContactModels.kt b/app/src/main/java/com/example/womansafe/data/model/EmergencyContactModels.kt
new file mode 100644
index 0000000..c3e8c17
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/data/model/EmergencyContactModels.kt
@@ -0,0 +1,4 @@
+package com.example.womansafe.data.model
+
+// Этот файл оставлен пустым, так как все модели экстренных контактов
+// теперь находятся в ApiModels.kt для избежания дублирования
diff --git a/app/src/main/java/com/example/womansafe/data/model/EmergencyModels.kt b/app/src/main/java/com/example/womansafe/data/model/EmergencyModels.kt
new file mode 100644
index 0000000..29a9484
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/data/model/EmergencyModels.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
+)
diff --git a/app/src/main/java/com/example/womansafe/data/model/UserModels.kt b/app/src/main/java/com/example/womansafe/data/model/UserModels.kt
new file mode 100644
index 0000000..0d981df
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/data/model/UserModels.kt
@@ -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?
+)
+
diff --git a/app/src/main/java/com/example/womansafe/data/network/NetworkClient.kt b/app/src/main/java/com/example/womansafe/data/network/NetworkClient.kt
index 06a0c05..4c1bef3 100644
--- a/app/src/main/java/com/example/womansafe/data/network/NetworkClient.kt
+++ b/app/src/main/java/com/example/womansafe/data/network/NetworkClient.kt
@@ -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 request = chain.request().newBuilder()
+ val requestBuilder = chain.request().newBuilder()
authToken?.let { token ->
- request.addHeader("Authorization", "Bearer $token")
+ requestBuilder.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 apiService: WomanSafeApi by lazy {
+ Retrofit.Builder()
+ .baseUrl(BASE_URL)
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ .create(WomanSafeApi::class.java)
+ }
- val retrofit: Retrofit = Retrofit.Builder()
- .baseUrl(BASE_URL)
- .client(okHttpClient)
- .addConverterFactory(GsonConverterFactory.create(gson))
- .build()
+ fun setAuthToken(token: String?) {
+ authToken = token
+ }
+
+ fun updateBaseUrl(newUrl: String) {
+ BASE_URL = if (!newUrl.endsWith("/")) "$newUrl/" else newUrl
+ }
}
diff --git a/app/src/main/java/com/example/womansafe/data/network/RetrofitClient.kt b/app/src/main/java/com/example/womansafe/data/network/RetrofitClient.kt
new file mode 100644
index 0000000..8c9f67e
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/data/network/RetrofitClient.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/example/womansafe/data/repository/ApiRepository.kt b/app/src/main/java/com/example/womansafe/data/repository/ApiRepository.kt
index e3d3947..e7b7f9f 100644
--- a/app/src/main/java/com/example/womansafe/data/repository/ApiRepository.kt
+++ b/app/src/main/java/com/example/womansafe/data/repository/ApiRepository.kt
@@ -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 {
- 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 {
- 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 {
- return api.getCurrentUser()
+ return apiService.getCurrentUser()
}
- suspend fun updateUser(userUpdate: UserUpdate): Response {
- val requestBody = ApiRequestBody(userUpdate = userUpdate)
- return api.updateCurrentUser(requestBody)
+ suspend fun updateCurrentUser(userUpdate: UserUpdate): Response {
+ val body = ApiRequestBody(user_update = userUpdate)
+ return apiService.updateCurrentUser(body)
}
- suspend fun patchUser(userUpdate: UserUpdate): Response {
- val requestBody = ApiRequestBody(userUpdate = userUpdate)
- return api.patchCurrentUser(requestBody)
+ suspend fun patchCurrentUser(userUpdate: UserUpdate): Response {
+ val body = ApiRequestBody(user_update = userUpdate)
+ return apiService.patchCurrentUser(body)
}
- suspend fun changePassword(): Response {
- return api.changePassword(ApiRequestBody())
+ suspend fun changePassword(currentPassword: String, newPassword: String): Response {
+ val passwordRequest = ChangePasswordRequest(currentPassword, newPassword)
+ // Поскольку WomanSafeApi ожидает ApiRequestBody, нам нужно обернуть запрос
+ val body = ApiRequestBody() // Здесь может потребоваться дополнительное поле для смены пароля
+ return apiService.changePassword(body)
}
suspend fun getDashboard(): Response {
- return api.getDashboard()
+ return apiService.getDashboard()
}
- // Profile methods
- suspend fun getProfile(): Response {
- return api.getProfile()
+ suspend fun getUserProfile(): Response {
+ return apiService.getProfile()
}
- suspend fun updateProfile(userUpdate: UserUpdate): Response {
- val requestBody = ApiRequestBody(userUpdate = userUpdate)
- return api.updateProfile(requestBody)
+ suspend fun updateUserProfile(userUpdate: UserUpdate): Response {
+ val body = ApiRequestBody(user_update = userUpdate)
+ return apiService.updateProfile(body)
}
- // Emergency Contacts methods
+ // Emergency Contact methods
suspend fun getEmergencyContacts(): Response> {
- return api.getEmergencyContacts()
+ return apiService.getEmergencyContacts()
}
suspend fun createEmergencyContact(contact: EmergencyContactCreate): Response {
- val requestBody = ApiRequestBody(emergencyContactCreate = contact)
- return api.createEmergencyContact(requestBody)
+ return apiService.createEmergencyContact(contact)
}
- suspend fun getEmergencyContact(contactId: String): Response {
- return api.getEmergencyContact(contactId)
+ suspend fun getEmergencyContact(contactId: Int): Response {
+ return apiService.getEmergencyContact(contactId.toString())
}
- suspend fun updateEmergencyContact(contactId: String, contact: EmergencyContactUpdate): Response {
- val requestBody = ApiRequestBody(emergencyContactUpdate = contact)
- return api.updateEmergencyContact(contactId, requestBody)
+ suspend fun updateEmergencyContact(contactId: Int, contact: EmergencyContactUpdate): Response {
+ val body = ApiRequestBody(emergency_contact_update = contact)
+ return apiService.updateEmergencyContact(contactId.toString(), body)
}
- suspend fun deleteEmergencyContact(contactId: String): Response {
- return api.deleteEmergencyContact(contactId)
+ suspend fun deleteEmergencyContact(contactId: Int): Response {
+ return apiService.deleteEmergencyContact(contactId.toString())
}
- // Emergency methods
+ // Emergency methods - возвращают Any согласно WomanSafeApi
suspend fun getEmergencyReports(): Response {
- return api.getEmergencyReports()
+ return apiService.getEmergencyReports()
}
suspend fun createEmergencyReport(): Response {
- return api.createEmergencyReport()
+ return apiService.createEmergencyReport()
}
suspend fun getNearbyEmergencyReports(): Response {
- return api.getNearbyEmergencyReports()
+ return apiService.getNearbyEmergencyReports()
}
- suspend fun getEmergencyReport(reportId: String): Response {
- return api.getEmergencyReport(reportId)
+ suspend fun getEmergencyReport(reportId: Int): Response {
+ return apiService.getEmergencyReport(reportId.toString())
}
- suspend fun updateEmergencyReport(reportId: String): Response {
- return api.updateEmergencyReport(reportId)
+ suspend fun updateEmergencyReport(reportId: Int): Response {
+ return apiService.updateEmergencyReport(reportId.toString())
}
- suspend fun deleteEmergencyReport(reportId: String): Response {
- return api.deleteEmergencyReport(reportId)
+ suspend fun deleteEmergencyReport(reportId: Int): Response {
+ return apiService.deleteEmergencyReport(reportId.toString())
}
- // Emergency Alerts methods
- suspend fun getEmergencyAlerts(): Response {
- return api.getEmergencyAlerts()
+
+ suspend fun getEmergencyAlerts(): Response> {
+ return apiService.getEmergencyAlerts()
}
- suspend fun createEmergencyAlert(): Response {
- return api.createEmergencyAlert()
+ suspend fun createEmergencyAlert(request: EmergencyAlertCreate): Response {
+ return apiService.createEmergencyAlert(request)
}
- suspend fun getMyEmergencyAlerts(): Response {
- return api.getMyEmergencyAlerts()
+ suspend fun getMyEmergencyAlerts(): Response> {
+ return apiService.getMyEmergencyAlerts()
}
suspend fun getNearbyEmergencyAlerts(): Response {
- return api.getNearbyEmergencyAlerts()
+ return apiService.getNearbyEmergencyAlerts()
}
- suspend fun getEmergencyAlert(alertId: String): Response {
- return api.getEmergencyAlert(alertId)
+ suspend fun getEmergencyAlert(alertId: Int): Response {
+ return apiService.getEmergencyAlert(alertId.toString())
}
- suspend fun updateEmergencyAlert(alertId: String): Response {
- return api.updateEmergencyAlert(alertId)
+ suspend fun updateEmergencyAlert(alertId: Int): Response {
+ return apiService.updateEmergencyAlert(alertId.toString())
}
- suspend fun deleteEmergencyAlert(alertId: String): Response {
- return api.deleteEmergencyAlert(alertId)
+ suspend fun cancelEmergencyAlert(alertId: Int): Response {
+ return apiService.cancelEmergencyAlert(alertId.toString())
}
- suspend fun cancelEmergencyAlert(alertId: String): Response {
- return api.cancelEmergencyAlert(alertId)
+ suspend fun deleteEmergencyAlert(alertId: Int): Response {
+ return apiService.deleteEmergencyAlert(alertId.toString())
}
// Location methods
suspend fun updateLocation(): Response {
- return api.updateLocation()
+ return apiService.updateLocation()
}
suspend fun getLastLocation(): Response {
- return api.getLastLocation()
+ return apiService.getLastLocation()
}
suspend fun getLocationHistory(): Response {
- return api.getLocationHistory()
+ return apiService.getLocationHistory()
}
suspend fun getNearbyUsers(): Response {
- return api.getNearbyUsers()
+ return apiService.getNearbyUsers()
}
suspend fun getSafePlaces(): Response {
- return api.getSafePlaces()
+ return apiService.getSafePlaces()
}
suspend fun createSafePlace(): Response {
- return api.createSafePlace()
+ return apiService.createSafePlace()
}
- suspend fun getSafePlace(placeId: String): Response {
- return api.getSafePlace(placeId)
+ suspend fun getSafePlace(placeId: Int): Response {
+ return apiService.getSafePlace(placeId.toString())
}
- suspend fun updateSafePlace(placeId: String): Response {
- return api.updateSafePlace(placeId)
+ suspend fun updateSafePlace(placeId: Int): Response {
+ return apiService.updateSafePlace(placeId.toString())
}
- suspend fun deleteSafePlace(placeId: String): Response {
- return api.deleteSafePlace(placeId)
+ suspend fun deleteSafePlace(placeId: Int): Response {
+ return apiService.deleteSafePlace(placeId.toString())
}
// Calendar methods
suspend fun getCalendarEntries(): Response {
- return api.getCalendarEntries()
+ return apiService.getCalendarEntries()
}
suspend fun createCalendarEntry(): Response {
- return api.createCalendarEntry()
+ return apiService.createCalendarEntry()
}
- suspend fun getCalendarEntry(entryId: String): Response {
- return api.getCalendarEntry(entryId)
+ suspend fun getCalendarEntry(entryId: Int): Response {
+ return apiService.getCalendarEntry(entryId.toString())
}
- suspend fun updateCalendarEntry(entryId: String): Response {
- return api.updateCalendarEntry(entryId)
+ suspend fun updateCalendarEntry(entryId: Int): Response {
+ return apiService.updateCalendarEntry(entryId.toString())
}
- suspend fun deleteCalendarEntry(entryId: String): Response {
- return api.deleteCalendarEntry(entryId)
+ suspend fun deleteCalendarEntry(entryId: Int): Response {
+ return apiService.deleteCalendarEntry(entryId.toString())
}
suspend fun getCycleOverview(): Response {
- return api.getCycleOverview()
+ return apiService.getCycleOverview()
}
suspend fun getCalendarInsights(): Response {
- return api.getCalendarInsights()
+ return apiService.getCalendarInsights()
}
suspend fun getCalendarReminders(): Response {
- return api.getCalendarReminders()
+ return apiService.getCalendarReminders()
}
suspend fun createCalendarReminder(): Response {
- return api.createCalendarReminder()
+ return apiService.createCalendarReminder()
}
suspend fun getCalendarSettings(): Response {
- return api.getCalendarSettings()
+ return apiService.getCalendarSettings()
}
suspend fun updateCalendarSettings(): Response {
- return api.updateCalendarSettings()
+ return apiService.updateCalendarSettings()
}
// Notification methods
suspend fun getNotificationDevices(): Response {
- return api.getNotificationDevices()
+ return apiService.getNotificationDevices()
}
- suspend fun createNotificationDevice(): Response {
- return api.createNotificationDevice()
+ suspend fun registerNotificationDevice(): Response {
+ return apiService.createNotificationDevice()
}
suspend fun getNotificationDevice(deviceId: String): Response {
- return api.getNotificationDevice(deviceId)
+ return apiService.getNotificationDevice(deviceId)
}
- suspend fun deleteNotificationDevice(deviceId: String): Response {
- return api.deleteNotificationDevice(deviceId)
+ suspend fun unregisterNotificationDevice(deviceId: String): Response {
+ return apiService.deleteNotificationDevice(deviceId)
}
suspend fun getNotificationPreferences(): Response {
- return api.getNotificationPreferences()
+ return apiService.getNotificationPreferences()
}
suspend fun updateNotificationPreferences(): Response {
- return api.updateNotificationPreferences()
+ return apiService.updateNotificationPreferences()
}
- suspend fun testNotification(): Response {
- return api.testNotification()
+ suspend fun sendTestNotification(): Response {
+ return apiService.testNotification()
}
suspend fun getNotificationHistory(): Response {
- return api.getNotificationHistory()
+ return apiService.getNotificationHistory()
}
- // Health check methods
+ // Health and status methods
suspend fun getHealth(): Response {
- return api.getHealth()
+ return apiService.getHealth()
}
suspend fun getServicesStatus(): Response {
- return api.getServicesStatus()
+ return apiService.getServicesStatus()
}
suspend fun getRoot(): Response {
- return api.getRoot()
+ return apiService.getRoot()
}
}
diff --git a/app/src/main/java/com/example/womansafe/ui/components/TabComponents.kt b/app/src/main/java/com/example/womansafe/ui/components/TabComponents.kt
index 820df2e..d185f22 100644
--- a/app/src/main/java/com/example/womansafe/ui/components/TabComponents.kt
+++ b/app/src/main/java/com/example/womansafe/ui/components/TabComponents.kt
@@ -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)
diff --git a/app/src/main/java/com/example/womansafe/ui/navigation/BottomNavigation.kt b/app/src/main/java/com/example/womansafe/ui/navigation/BottomNavigation.kt
new file mode 100644
index 0000000..27d387b
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/navigation/BottomNavigation.kt
@@ -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
+ }
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/womansafe/ui/screens/AuthScreen.kt b/app/src/main/java/com/example/womansafe/ui/screens/AuthScreen.kt
new file mode 100644
index 0000000..7249294
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/screens/AuthScreen.kt
@@ -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)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/womansafe/ui/screens/CalendarScreen.kt b/app/src/main/java/com/example/womansafe/ui/screens/CalendarScreen.kt
new file mode 100644
index 0000000..beed86b
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/screens/CalendarScreen.kt
@@ -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>,
+ 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,
+ 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,
+ 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, String, Int?) -> Unit
+) {
+ var selectedEventType by remember { mutableStateOf(CalendarEventType.MENSTRUATION) }
+ var selectedMood by remember { mutableStateOf(null) }
+ var selectedSymptoms by remember { mutableStateOf(setOf()) }
+ var notes by remember { mutableStateOf("") }
+ var flowIntensity by remember { mutableStateOf(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,
+ predictions: CyclePrediction?,
+ date: LocalDate
+): List {
+ val colors = mutableListOf()
+
+ 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)
+}
diff --git a/app/src/main/java/com/example/womansafe/ui/screens/EmergencyContactsScreen.kt b/app/src/main/java/com/example/womansafe/ui/screens/EmergencyContactsScreen.kt
new file mode 100644
index 0000000..3b9efdc
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/screens/EmergencyContactsScreen.kt
@@ -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("Отмена")
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/example/womansafe/ui/screens/EmergencyScreen.kt b/app/src/main/java/com/example/womansafe/ui/screens/EmergencyScreen.kt
new file mode 100644
index 0000000..6d9ac6e
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/screens/EmergencyScreen.kt
@@ -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,
+ 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("Отмена")
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/example/womansafe/ui/screens/HomeScreen.kt b/app/src/main/java/com/example/womansafe/ui/screens/HomeScreen.kt
new file mode 100644
index 0000000..01ba767
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/screens/HomeScreen.kt
@@ -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) {
+ 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) -> Unit
+) {
+ var selectedContacts by remember { mutableStateOf(setOf()) }
+ 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("Отмена")
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/example/womansafe/ui/screens/MainScreen.kt b/app/src/main/java/com/example/womansafe/ui/screens/MainScreen.kt
new file mode 100644
index 0000000..145362b
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/screens/MainScreen.kt
@@ -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() }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/womansafe/ui/screens/ProfileScreen.kt b/app/src/main/java/com/example/womansafe/ui/screens/ProfileScreen.kt
new file mode 100644
index 0000000..55f866d
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/screens/ProfileScreen.kt
@@ -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
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/womansafe/ui/screens/ProfileSettingsScreen.kt b/app/src/main/java/com/example/womansafe/ui/screens/ProfileSettingsScreen.kt
new file mode 100644
index 0000000..fcdf1a4
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/screens/ProfileSettingsScreen.kt
@@ -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("Отмена")
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/example/womansafe/ui/viewmodel/ApiTestViewModel.kt b/app/src/main/java/com/example/womansafe/ui/viewmodel/ApiTestViewModel.kt
index c4e2f7b..b7a9d10 100644
--- a/app/src/main/java/com/example/womansafe/ui/viewmodel/ApiTestViewModel.kt
+++ b/app/src/main/java/com/example/womansafe/ui/viewmodel/ApiTestViewModel.kt
@@ -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 = emptyList(),
+ val emergencyReports: List = emptyList(),
+ val emergencyAlerts: List = emptyList(),
+ val calendarEntries: List = emptyList(),
+ val locationHistory: List = emptyList(),
+ val safePlaces: List = emptyList(),
+ val notificationHistory: List = 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 {
diff --git a/app/src/main/java/com/example/womansafe/ui/viewmodel/AuthViewModel.kt b/app/src/main/java/com/example/womansafe/ui/viewmodel/AuthViewModel.kt
new file mode 100644
index 0000000..366b92d
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/viewmodel/AuthViewModel.kt
@@ -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? = 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
+)
diff --git a/app/src/main/java/com/example/womansafe/ui/viewmodel/CalendarViewModel.kt b/app/src/main/java/com/example/womansafe/ui/viewmodel/CalendarViewModel.kt
new file mode 100644
index 0000000..4b932e6
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/viewmodel/CalendarViewModel.kt
@@ -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> = 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 = 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>()
+
+ // Прогноз месячных
+ 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()
+ 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()
+ }
+}
diff --git a/app/src/main/java/com/example/womansafe/ui/viewmodel/EmergencyContactsViewModel.kt b/app/src/main/java/com/example/womansafe/ui/viewmodel/EmergencyContactsViewModel.kt
new file mode 100644
index 0000000..fecc4f7
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/viewmodel/EmergencyContactsViewModel.kt
@@ -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 = emptyList(),
+ val isLoading: Boolean = false,
+ val error: String? = null
+)
diff --git a/app/src/main/java/com/example/womansafe/ui/viewmodel/EmergencyViewModel.kt b/app/src/main/java/com/example/womansafe/ui/viewmodel/EmergencyViewModel.kt
new file mode 100644
index 0000000..bfb105e
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/viewmodel/EmergencyViewModel.kt
@@ -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 = 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 = _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 = 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)
+ }
+}
diff --git a/app/src/main/java/com/example/womansafe/ui/viewmodel/ProfileSettingsViewModel.kt b/app/src/main/java/com/example/womansafe/ui/viewmodel/ProfileSettingsViewModel.kt
new file mode 100644
index 0000000..b01364b
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/ui/viewmodel/ProfileSettingsViewModel.kt
@@ -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}"
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/womansafe/util/DateUtils.kt b/app/src/main/java/com/example/womansafe/util/DateUtils.kt
new file mode 100644
index 0000000..e73585b
--- /dev/null
+++ b/app/src/main/java/com/example/womansafe/util/DateUtils.kt
@@ -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))
+ }
+}
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..e50be59
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,9 @@
+
+
+
+ 192.168.0.112
+ 192.168.0.103
+ 10.0.2.2
+ localhost
+
+