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 + +