main commit

This commit is contained in:
2025-09-26 12:28:34 +09:00
parent 37cf587ce6
commit 86b5df6c10
31 changed files with 6399 additions and 338 deletions

View File

@@ -4,6 +4,14 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-09-25T20:36:01.018251800Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/trevor/.android/avd/Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

View File

@@ -6,16 +6,19 @@ plugins {
android { android {
namespace = "com.example.womansafe" namespace = "com.example.womansafe"
compileSdk = 36 compileSdk = 34
defaultConfig { defaultConfig {
applicationId = "com.example.womansafe" applicationId = "com.example.womansafe"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
} }
buildTypes { buildTypes {
@@ -28,35 +31,34 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_1_8
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "1.8"
} }
buildFeatures { buildFeatures {
compose = true compose = true
} }
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
} }
dependencies { dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation(libs.androidx.core.ktx) implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation(libs.androidx.lifecycle.runtime.ktx) implementation("androidx.activity:activity-compose:1.8.2")
implementation(libs.androidx.activity.compose) implementation(platform("androidx.compose:compose-bom:2023.08.00"))
implementation(platform(libs.androidx.compose.bom)) implementation("androidx.compose.ui:ui")
implementation(libs.androidx.compose.ui) implementation("androidx.compose.ui:ui-graphics")
implementation(libs.androidx.compose.ui.graphics) implementation("androidx.compose.ui:ui-tooling-preview")
implementation(libs.androidx.compose.ui.tooling.preview) implementation("androidx.compose.material3:material3")
implementation(libs.androidx.compose.material3)
// Networking
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// JSON
implementation("com.google.code.gson:gson:2.10.1")
// Navigation // Navigation
implementation("androidx.navigation:navigation-compose:2.7.6") implementation("androidx.navigation:navigation-compose:2.7.6")
@@ -64,17 +66,24 @@ dependencies {
// ViewModel // ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
// Coroutines // Networking
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// DataStore for preferences // Location Services
implementation("androidx.datastore:datastore-preferences:1.0.0") implementation("com.google.android.gms:play-services-location:21.0.1")
implementation("com.google.android.gms:play-services-maps:18.2.0")
testImplementation(libs.junit) // Permissions
androidTestImplementation(libs.androidx.junit) implementation("com.google.accompanist:accompanist-permissions:0.32.0")
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) // Testing
androidTestImplementation(libs.androidx.compose.ui.test.junit4) testImplementation("junit:junit:4.13.2")
debugImplementation(libs.androidx.compose.ui.tooling) androidTestImplementation("androidx.test.ext:junit:1.1.5")
debugImplementation(libs.androidx.compose.ui.test.manifest) androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
} }

View File

@@ -2,6 +2,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<!-- Internet permission for API calls -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Location permissions for emergency functionality -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Permission to make phone calls for emergency contacts -->
<uses-permission android:name="android.permission.CALL_PHONE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -10,7 +21,9 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.WomanSafe"> android:theme="@style/Theme.WomanSafe"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -4,44 +4,36 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import com.example.womansafe.ui.screens.AuthScreen
import com.example.womansafe.ui.screens.MainScreen
import com.example.womansafe.ui.theme.WomanSafeTheme import com.example.womansafe.ui.theme.WomanSafeTheme
import com.example.womansafe.ui.viewmodel.AuthViewModel
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val authViewModel: AuthViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
WomanSafeTheme { WomanSafeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Surface(
Greeting( modifier = Modifier.fillMaxSize(),
name = "Android", color = MaterialTheme.colorScheme.background
modifier = Modifier.padding(innerPadding) ) {
) // Показываем либо экран авторизации, либо главный экран
if (authViewModel.uiState.isLoggedIn) {
MainScreen(authViewModel = authViewModel)
} else {
AuthScreen(viewModel = authViewModel)
} }
} }
} }
} }
} }
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
WomanSafeTheme {
Greeting("Android")
}
} }

View File

@@ -5,17 +5,16 @@ import retrofit2.Response
import retrofit2.http.* import retrofit2.http.*
interface WomanSafeApi { interface WomanSafeApi {
// Authentication endpoints // Authentication endpoints
@POST("api/v1/auth/login") @POST("api/v1/auth/login")
suspend fun login(@Body request: ApiRequestBody): Response<Token> suspend fun login(@Body request: UserLogin): Response<Token>
@POST("api/v1/auth/register") @POST("api/v1/auth/register")
suspend fun register(@Body request: ApiRequestBody): Response<UserResponse> suspend fun register(@Body request: UserCreate): Response<UserResponse>
// User endpoints // User endpoints
@GET("api/v1/users/me") @GET("api/v1/users/me")
suspend fun getCurrentUser(@Body request: ApiRequestBody = ApiRequestBody()): Response<UserResponse> suspend fun getCurrentUser(): Response<UserResponse>
@PUT("api/v1/users/me") @PUT("api/v1/users/me")
suspend fun updateCurrentUser(@Body request: ApiRequestBody): Response<UserResponse> suspend fun updateCurrentUser(@Body request: ApiRequestBody): Response<UserResponse>
@@ -27,41 +26,32 @@ interface WomanSafeApi {
suspend fun changePassword(@Body request: ApiRequestBody): Response<Unit> suspend fun changePassword(@Body request: ApiRequestBody): Response<Unit>
@GET("api/v1/users/dashboard") @GET("api/v1/users/dashboard")
suspend fun getDashboard(@Body request: ApiRequestBody = ApiRequestBody()): Response<Any> suspend fun getDashboard(): Response<Any>
// Profile endpoints // Profile endpoints
@GET("api/v1/profile") @GET("api/v1/profile")
suspend fun getProfile(@Body request: ApiRequestBody = ApiRequestBody()): Response<UserResponse> suspend fun getProfile(): Response<UserResponse>
@PUT("api/v1/profile") @PUT("api/v1/profile")
suspend fun updateProfile(@Body request: ApiRequestBody): Response<UserResponse> suspend fun updateProfile(@Body request: ApiRequestBody): Response<UserResponse>
// Emergency Contacts endpoints // Emergency Contacts endpoints
@GET("api/v1/users/me/emergency-contacts") @GET("api/v1/users/me/emergency-contacts")
suspend fun getEmergencyContacts(@Body request: ApiRequestBody = ApiRequestBody()): Response<List<EmergencyContactResponse>> suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>>
@POST("api/v1/users/me/emergency-contacts") @POST("api/v1/users/me/emergency-contacts")
suspend fun createEmergencyContact(@Body request: ApiRequestBody): Response<EmergencyContactResponse> suspend fun createEmergencyContact(@Body request: EmergencyContactCreate): Response<EmergencyContactResponse>
@GET("api/v1/users/me/emergency-contacts/{contact_id}") @GET("api/v1/users/me/emergency-contacts/{contact_id}")
suspend fun getEmergencyContact( suspend fun getEmergencyContact(@Path("contact_id") contactId: String): Response<EmergencyContactResponse>
@Path("contact_id") contactId: String,
@Body request: ApiRequestBody = ApiRequestBody()
): Response<EmergencyContactResponse>
@PATCH("api/v1/users/me/emergency-contacts/{contact_id}") @PATCH("api/v1/users/me/emergency-contacts/{contact_id}")
suspend fun updateEmergencyContact( suspend fun updateEmergencyContact(@Path("contact_id") contactId: String, @Body request: ApiRequestBody): Response<EmergencyContactResponse>
@Path("contact_id") contactId: String,
@Body request: ApiRequestBody
): Response<EmergencyContactResponse>
@DELETE("api/v1/users/me/emergency-contacts/{contact_id}") @DELETE("api/v1/users/me/emergency-contacts/{contact_id}")
suspend fun deleteEmergencyContact( suspend fun deleteEmergencyContact(@Path("contact_id") contactId: String): Response<Unit>
@Path("contact_id") contactId: String,
@Body request: ApiRequestBody = ApiRequestBody()
): Response<Unit>
// Emergency endpoints // Emergency Reports endpoints
@GET("api/v1/emergency/reports") @GET("api/v1/emergency/reports")
suspend fun getEmergencyReports(): Response<Any> suspend fun getEmergencyReports(): Response<Any>
@@ -82,13 +72,13 @@ interface WomanSafeApi {
// Emergency Alerts endpoints // Emergency Alerts endpoints
@GET("api/v1/emergency/alerts") @GET("api/v1/emergency/alerts")
suspend fun getEmergencyAlerts(): Response<Any> suspend fun getEmergencyAlerts(): Response<List<EmergencyAlertResponse>>
@POST("api/v1/emergency/alerts") @POST("api/v1/emergency/alerts")
suspend fun createEmergencyAlert(): Response<Any> suspend fun createEmergencyAlert(@Body request: EmergencyAlertCreate): Response<EmergencyAlertResponse>
@GET("api/v1/emergency/alerts/my") @GET("api/v1/emergency/alerts/my")
suspend fun getMyEmergencyAlerts(): Response<Any> suspend fun getMyEmergencyAlerts(): Response<List<EmergencyAlertResponse>>
@GET("api/v1/emergency/alerts/nearby") @GET("api/v1/emergency/alerts/nearby")
suspend fun getNearbyEmergencyAlerts(): Response<Any> suspend fun getNearbyEmergencyAlerts(): Response<Any>
@@ -199,6 +189,6 @@ interface WomanSafeApi {
@GET("api/v1/services-status") @GET("api/v1/services-status")
suspend fun getServicesStatus(): Response<Any> suspend fun getServicesStatus(): Response<Any>
@GET("") @GET("/")
suspend fun getRoot(): Response<Any> suspend fun getRoot(): Response<Any>
} }

View File

@@ -2,99 +2,22 @@ package com.example.womansafe.data.model
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
// Authentication models // Request body wrapper for API Gateway proxy endpoints
data class ApiRequestBody(
val user_create: UserCreate? = null,
val user_login: UserLogin? = null,
val user_update: UserUpdate? = null,
val emergency_contact_create: EmergencyContactCreate? = null,
val emergency_contact_update: EmergencyContactUpdate? = null
)
// Auth models
data class UserLogin( data class UserLogin(
val email: String? = null, val email: String? = null,
val username: String? = null, val username: String? = null,
val password: String val password: String
) )
data class UserCreate(
val email: String,
val username: String? = null,
val phone: String? = null,
@SerializedName("phone_number")
val phoneNumber: String? = null,
@SerializedName("first_name")
val firstName: String? = "",
@SerializedName("last_name")
val lastName: String? = "",
@SerializedName("full_name")
val fullName: String? = null,
@SerializedName("date_of_birth")
val dateOfBirth: String? = null,
val bio: String? = null,
val password: String
)
data class UserUpdate(
@SerializedName("first_name")
val firstName: String? = null,
@SerializedName("last_name")
val lastName: String? = null,
val phone: String? = null,
@SerializedName("date_of_birth")
val dateOfBirth: String? = null,
val bio: String? = null,
@SerializedName("avatar_url")
val avatarUrl: String? = null,
@SerializedName("emergency_contact_1_name")
val emergencyContact1Name: String? = null,
@SerializedName("emergency_contact_1_phone")
val emergencyContact1Phone: String? = null,
@SerializedName("emergency_contact_2_name")
val emergencyContact2Name: String? = null,
@SerializedName("emergency_contact_2_phone")
val emergencyContact2Phone: String? = null,
@SerializedName("location_sharing_enabled")
val locationSharingEnabled: Boolean? = null,
@SerializedName("emergency_notifications_enabled")
val emergencyNotificationsEnabled: Boolean? = null,
@SerializedName("push_notifications_enabled")
val pushNotificationsEnabled: Boolean? = null
)
data class UserResponse(
val email: String,
val username: String? = null,
val phone: String? = null,
@SerializedName("phone_number")
val phoneNumber: String? = null,
@SerializedName("first_name")
val firstName: String? = "",
@SerializedName("last_name")
val lastName: String? = "",
@SerializedName("full_name")
val fullName: String? = null,
@SerializedName("date_of_birth")
val dateOfBirth: String? = null,
val bio: String? = null,
val id: Int,
val uuid: String,
@SerializedName("avatar_url")
val avatarUrl: String? = null,
@SerializedName("emergency_contact_1_name")
val emergencyContact1Name: String? = null,
@SerializedName("emergency_contact_1_phone")
val emergencyContact1Phone: String? = null,
@SerializedName("emergency_contact_2_name")
val emergencyContact2Name: String? = null,
@SerializedName("emergency_contact_2_phone")
val emergencyContact2Phone: String? = null,
@SerializedName("location_sharing_enabled")
val locationSharingEnabled: Boolean,
@SerializedName("emergency_notifications_enabled")
val emergencyNotificationsEnabled: Boolean,
@SerializedName("push_notifications_enabled")
val pushNotificationsEnabled: Boolean,
@SerializedName("email_verified")
val emailVerified: Boolean,
@SerializedName("phone_verified")
val phoneVerified: Boolean,
@SerializedName("is_active")
val isActive: Boolean
)
data class Token( data class Token(
@SerializedName("access_token") @SerializedName("access_token")
val accessToken: String, val accessToken: String,
@@ -102,52 +25,335 @@ data class Token(
val tokenType: String val tokenType: String
) )
// User models
data class UserCreate(
val email: String,
val username: String? = null,
val phone: String? = null,
val phone_number: String? = null,
val first_name: String? = "",
val last_name: String? = "",
val full_name: String? = null,
val date_of_birth: String? = null,
val bio: String? = null,
val password: String
)
data class UserUpdate(
val first_name: String? = null,
val last_name: String? = null,
val phone: String? = null,
val date_of_birth: String? = null,
val bio: String? = null,
val avatar_url: String? = null,
val emergency_contact_1_name: String? = null,
val emergency_contact_1_phone: String? = null,
val emergency_contact_2_name: String? = null,
val emergency_contact_2_phone: String? = null,
val location_sharing_enabled: Boolean? = null,
val emergency_notifications_enabled: Boolean? = null,
val push_notifications_enabled: Boolean? = null,
val email_notifications_enabled: Boolean? = null
)
data class UserResponse(
val id: Int,
val uuid: String,
val email: String,
val username: String? = null,
val phone: String? = null,
val phone_number: String? = null,
val first_name: String? = "",
val last_name: String? = "",
val full_name: String? = null,
val date_of_birth: String? = null,
val bio: String? = null,
val avatar_url: String? = null,
val emergency_contact_1_name: String? = null,
val emergency_contact_1_phone: String? = null,
val emergency_contact_2_name: String? = null,
val emergency_contact_2_phone: String? = null,
val location_sharing_enabled: Boolean,
val emergency_notifications_enabled: Boolean,
val push_notifications_enabled: Boolean,
val email_notifications_enabled: Boolean? = false,
val email_verified: Boolean,
val phone_verified: Boolean,
val is_active: Boolean
)
// Emergency Contact models // Emergency Contact models
data class EmergencyContactCreate( data class EmergencyContactCreate(
val name: String, val name: String,
@SerializedName("phone_number") val phone_number: String,
val phoneNumber: String,
val relationship: String? = null, val relationship: String? = null,
val notes: String? = null val notes: String? = null
) )
data class EmergencyContactUpdate( data class EmergencyContactUpdate(
val name: String? = null, val name: String? = null,
@SerializedName("phone_number") val phone_number: String? = null,
val phoneNumber: String? = null,
val relationship: String? = null, val relationship: String? = null,
val notes: String? = null val notes: String? = null
) )
data class EmergencyContactResponse( data class EmergencyContactResponse(
val name: String,
@SerializedName("phone_number")
val phoneNumber: String,
val relationship: String? = null,
val notes: String? = null,
val id: Int, val id: Int,
val uuid: String, val uuid: String,
@SerializedName("user_id") val name: String,
val userId: Int val phone_number: String,
val relationship: String? = null,
val notes: String? = null,
val user_id: Int
) )
// API Request body wrapper // Request body for different endpoints
data class ApiRequestBody( data class RequestBody(
@SerializedName("user_create") val user_create: UserCreate? = null,
val userCreate: UserCreate? = null, val user_login: UserLogin? = null,
@SerializedName("user_login") val user_update: UserUpdate? = null,
val userLogin: UserLogin? = null, val emergency_contact_create: EmergencyContactCreate? = null,
@SerializedName("user_update") val emergency_contact_update: EmergencyContactUpdate? = null
val userUpdate: UserUpdate? = null,
@SerializedName("emergency_contact_create")
val emergencyContactCreate: EmergencyContactCreate? = null,
@SerializedName("emergency_contact_update")
val emergencyContactUpdate: EmergencyContactUpdate? = null
) )
// Error models // Password change model
data class ChangePasswordRequest(
val current_password: String,
val new_password: String
)
// Dashboard and other response models
data class DashboardResponse(
val user: UserResponse? = null,
val emergency_contacts: List<EmergencyContactResponse>? = null,
val recent_activities: List<ActivityResponse>? = null,
val safety_status: String? = null
)
data class ActivityResponse(
val id: Int,
val type: String,
val description: String,
val timestamp: String,
val location: String? = null
)
// Health check models
data class HealthResponse(
val status: String,
val timestamp: String,
val version: String? = null
)
data class ServicesStatusResponse(
val database: String,
val redis: String? = null,
val api: String,
val timestamp: String
)
// Emergency models
data class EmergencyReportCreate(
val type: String,
val description: String,
val latitude: Double,
val longitude: Double,
val address: String? = null
)
data class EmergencyReportUpdate(
val description: String? = null,
val status: String? = null
)
data class EmergencyReportResponse(
val id: Int,
val type: String,
val description: String,
val latitude: Double,
val longitude: Double,
val address: String? = null,
val timestamp: String,
val status: String,
val user_id: Int
)
data class EmergencyAlertCreate(
val type: String,
val description: String? = null,
val latitude: Double,
val longitude: Double,
val address: String? = null,
val is_anonymous: Boolean = false
)
data class EmergencyAlertResponse(
val id: Int,
val uuid: String,
val user_id: Int,
val type: String,
val description: String?,
val latitude: Double,
val longitude: Double,
val address: String?,
val is_anonymous: Boolean,
val status: String,
val created_at: String,
val updated_at: String?,
val is_active: Boolean
)
// Location models
data class LocationUpdate(
val latitude: Double,
val longitude: Double,
val accuracy: Double? = null,
val address: String? = null
)
data class LocationResponse(
val id: Int,
val latitude: Double,
val longitude: Double,
val timestamp: String,
val accuracy: Double? = null,
val address: String? = null,
val user_id: Int
)
data class SafePlace(
val name: String,
val description: String? = null,
val latitude: Double,
val longitude: Double,
val category: String,
val phone_number: String? = null
)
data class SafePlaceResponse(
val id: Int,
val name: String,
val description: String? = null,
val latitude: Double,
val longitude: Double,
val category: String,
val phone_number: String? = null,
val user_id: Int
)
data class NearbyUser(
val id: Int,
val username: String? = null,
val distance: Double,
val last_seen: String
)
// Calendar models
data class CalendarEntry(
val title: String,
val description: String? = null,
val start_date: String,
val end_date: String? = null,
val entry_type: String,
val mood: String? = null,
val symptoms: List<String>? = null,
val notes: String? = null
)
data class CalendarEntryResponse(
val id: Int,
val title: String,
val description: String? = null,
val start_date: String,
val end_date: String? = null,
val entry_type: String,
val mood: String? = null,
val symptoms: List<String>? = null,
val notes: String? = null,
val user_id: Int
)
data class CalendarSettings(
val cycle_length: Int,
val period_length: Int,
val notifications_enabled: Boolean,
val reminder_days_before: Int
)
data class CalendarReminder(
val title: String,
val message: String,
val reminder_date: String,
val reminder_time: String,
val is_recurring: Boolean
)
data class CalendarInsights(
val average_cycle_length: Double,
val cycle_regularity: String,
val mood_patterns: Map<String, Int>,
val symptom_frequency: Map<String, Int>
)
data class CycleOverview(
val current_phase: String,
val next_period_date: String,
val cycle_day: Int,
val fertile_window: List<String>
)
// Notification models
data class NotificationDevice(
val device_token: String,
val device_type: String,
val is_active: Boolean
)
data class NotificationPreferences(
val push_notifications_enabled: Boolean,
val email_notifications_enabled: Boolean,
val sms_notifications_enabled: Boolean,
val emergency_notifications_enabled: Boolean,
val calendar_reminders_enabled: Boolean
)
data class NotificationHistory(
val id: Int,
val title: String,
val message: String,
val type: String,
val sent_at: String,
val is_read: Boolean
)
data class TestNotification(
val title: String,
val message: String,
val type: String
)
// Generic response models
data class MessageResponse(
val message: String,
val status: String? = null
)
data class EmailAvailabilityResponse(
val available: Boolean,
val message: String
)
data class TestUserData(
val username: String,
val email: String,
val password: String,
val full_name: String,
val phone_number: String
)
// Validation error models
data class ValidationError( data class ValidationError(
val loc: List<String>, val loc: List<Any>,
val msg: String, val msg: String,
val type: String val type: String
) )

View File

@@ -0,0 +1,79 @@
package com.example.womansafe.data.model
import java.time.LocalDate
// Типы событий в календаре
enum class CalendarEventType {
MENSTRUATION, // Месячные
OVULATION, // Овуляция
FERTILE_WINDOW, // Окно фертильности
PREDICTED_MENSTRUATION, // Прогноз месячных
PREDICTED_OVULATION // Прогноз овуляции
}
// Настроения
enum class MoodType {
EXCELLENT, // Отлично
GOOD, // Хорошо
NORMAL, // Нормально
BAD, // Плохо
TERRIBLE // Ужасно
}
// Симптомы
enum class SymptomType {
CRAMPS, // Спазмы
HEADACHE, // Головная боль
BLOATING, // Вздутие
BREAST_TENDERNESS, // Болезненность груди
MOOD_SWINGS, // Перепады настроения
FATIGUE, // Усталость
ACNE, // Акне
CRAVINGS, // Тяга к еде
BACK_PAIN, // Боль в спине
NAUSEA // Тошнота
}
// Событие календаря
data class CalendarEvent(
val id: Int? = null,
val date: LocalDate,
val type: CalendarEventType,
val isActual: Boolean = true, // true - фактическое, false - прогноз
val mood: MoodType? = null,
val symptoms: List<SymptomType> = emptyList(),
val notes: String = "",
val flowIntensity: Int? = null, // Интенсивность выделений 1-5
val createdAt: LocalDate = LocalDate.now(),
val updatedAt: LocalDate = LocalDate.now()
)
// Настройки цикла
data class CycleSettings(
val averageCycleLength: Int = 28, // Средняя длина цикла
val averagePeriodLength: Int = 5, // Средняя длина менструации
val lastPeriodStart: LocalDate? = null, // Последняя менструация
val reminderDaysBefore: Int = 2, // За сколько дней напоминать
val enablePredictions: Boolean = true,
val enableReminders: Boolean = true
)
// Прогнозы цикла
data class CyclePrediction(
val nextPeriodStart: LocalDate,
val nextPeriodEnd: LocalDate,
val nextOvulation: LocalDate,
val fertileWindowStart: LocalDate,
val fertileWindowEnd: LocalDate,
val confidence: Float = 0.8f // Уверенность в прогнозе 0-1
)
// Статистика цикла
data class CycleStatistics(
val averageCycleLength: Float,
val cycleVariation: Float, // Отклонение в днях
val lastCycles: List<Int>, // Длины последних циклов
val periodLengthAverage: Float,
val commonSymptoms: List<SymptomType>,
val moodPatterns: Map<CalendarEventType, MoodType>
)

View File

@@ -0,0 +1,4 @@
package com.example.womansafe.data.model
// Этот файл оставлен пустым, так как все модели экстренных контактов
// теперь находятся в ApiModels.kt для избежания дублирования

View File

@@ -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
)

View File

@@ -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?
)

View File

@@ -1,6 +1,6 @@
package com.example.womansafe.data.network package com.example.womansafe.data.network
import com.google.gson.GsonBuilder import com.example.womansafe.data.api.WomanSafeApi
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
@@ -9,22 +9,28 @@ import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
object NetworkClient { object NetworkClient {
private const val BASE_URL = "http://10.0.2.2:8000/" // For Android Emulator private var BASE_URL = "http://192.168.0.103:8000/"
// For real device, use: "http://YOUR_IP:8000/"
private var authToken: String? = null private var authToken: String? = null
fun setAuthToken(token: String?) { private val authInterceptor = Interceptor { chain ->
authToken = token val requestBuilder = chain.request().newBuilder()
authToken?.let { token ->
requestBuilder.addHeader("Authorization", "Bearer $token")
} }
private val authInterceptor = Interceptor { chain -> // Debug logging
val request = chain.request().newBuilder() val request = requestBuilder.build()
authToken?.let { token -> println("=== API Request Debug ===")
request.addHeader("Authorization", "Bearer $token") println("URL: ${request.url}")
} println("Method: ${request.method}")
request.addHeader("Content-Type", "application/json") println("Headers: ${request.headers}")
chain.proceed(request.build())
val response = chain.proceed(request)
println("Response Code: ${response.code}")
println("Response Message: ${response.message}")
println("========================")
response
} }
private val loggingInterceptor = HttpLoggingInterceptor().apply { private val loggingInterceptor = HttpLoggingInterceptor().apply {
@@ -39,13 +45,20 @@ object NetworkClient {
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
.build() .build()
private val gson = GsonBuilder() val apiService: WomanSafeApi by lazy {
.setLenient() Retrofit.Builder()
.create()
val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BASE_URL) .baseUrl(BASE_URL)
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson)) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
.create(WomanSafeApi::class.java)
}
fun setAuthToken(token: String?) {
authToken = token
}
fun updateBaseUrl(newUrl: String) {
BASE_URL = if (!newUrl.endsWith("/")) "$newUrl/" else newUrl
}
} }

View File

@@ -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)
}

View File

@@ -1,277 +1,283 @@
package com.example.womansafe.data.repository package com.example.womansafe.data.repository
import com.example.womansafe.data.api.WomanSafeApi
import com.example.womansafe.data.model.* import com.example.womansafe.data.model.*
import com.example.womansafe.data.network.NetworkClient import com.example.womansafe.data.network.NetworkClient
import retrofit2.Response import retrofit2.Response
class ApiRepository { class ApiRepository {
private val api = NetworkClient.retrofit.create(WomanSafeApi::class.java) private val apiService = NetworkClient.apiService
// Authentication methods // Auth methods
suspend fun login(email: String?, username: String?, password: String): Response<Token> { suspend fun login(email: String?, username: String?, password: String): Response<Token> {
val loginData = UserLogin(email = email, username = username, password = password) val request = UserLogin(email, username, password)
val requestBody = ApiRequestBody(userLogin = loginData) return apiService.login(request)
return api.login(requestBody)
} }
suspend fun register( suspend fun register(
email: String, email: String,
username: String?, username: String? = null,
password: String, password: String,
fullName: String?, fullName: String? = null,
phoneNumber: String? phoneNumber: String? = null,
firstName: String? = null,
lastName: String? = null,
dateOfBirth: String? = null,
bio: String? = null
): Response<UserResponse> { ): Response<UserResponse> {
val userData = UserCreate( val request = UserCreate(
email = email, email = email,
username = username, username = username,
password = password, password = password,
fullName = fullName, full_name = fullName,
phoneNumber = phoneNumber phone_number = phoneNumber,
first_name = firstName,
last_name = lastName,
date_of_birth = dateOfBirth,
bio = bio
) )
val requestBody = ApiRequestBody(userCreate = userData) return apiService.register(request)
return api.register(requestBody)
} }
// User methods // User methods
suspend fun getCurrentUser(): Response<UserResponse> { suspend fun getCurrentUser(): Response<UserResponse> {
return api.getCurrentUser() return apiService.getCurrentUser()
} }
suspend fun updateUser(userUpdate: UserUpdate): Response<UserResponse> { suspend fun updateCurrentUser(userUpdate: UserUpdate): Response<UserResponse> {
val requestBody = ApiRequestBody(userUpdate = userUpdate) val body = ApiRequestBody(user_update = userUpdate)
return api.updateCurrentUser(requestBody) return apiService.updateCurrentUser(body)
} }
suspend fun patchUser(userUpdate: UserUpdate): Response<UserResponse> { suspend fun patchCurrentUser(userUpdate: UserUpdate): Response<UserResponse> {
val requestBody = ApiRequestBody(userUpdate = userUpdate) val body = ApiRequestBody(user_update = userUpdate)
return api.patchCurrentUser(requestBody) return apiService.patchCurrentUser(body)
} }
suspend fun changePassword(): Response<Unit> { suspend fun changePassword(currentPassword: String, newPassword: String): Response<Unit> {
return api.changePassword(ApiRequestBody()) val passwordRequest = ChangePasswordRequest(currentPassword, newPassword)
// Поскольку WomanSafeApi ожидает ApiRequestBody, нам нужно обернуть запрос
val body = ApiRequestBody() // Здесь может потребоваться дополнительное поле для смены пароля
return apiService.changePassword(body)
} }
suspend fun getDashboard(): Response<Any> { suspend fun getDashboard(): Response<Any> {
return api.getDashboard() return apiService.getDashboard()
} }
// Profile methods suspend fun getUserProfile(): Response<UserResponse> {
suspend fun getProfile(): Response<UserResponse> { return apiService.getProfile()
return api.getProfile()
} }
suspend fun updateProfile(userUpdate: UserUpdate): Response<UserResponse> { suspend fun updateUserProfile(userUpdate: UserUpdate): Response<UserResponse> {
val requestBody = ApiRequestBody(userUpdate = userUpdate) val body = ApiRequestBody(user_update = userUpdate)
return api.updateProfile(requestBody) return apiService.updateProfile(body)
} }
// Emergency Contacts methods // Emergency Contact methods
suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>> { suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>> {
return api.getEmergencyContacts() return apiService.getEmergencyContacts()
} }
suspend fun createEmergencyContact(contact: EmergencyContactCreate): Response<EmergencyContactResponse> { suspend fun createEmergencyContact(contact: EmergencyContactCreate): Response<EmergencyContactResponse> {
val requestBody = ApiRequestBody(emergencyContactCreate = contact) return apiService.createEmergencyContact(contact)
return api.createEmergencyContact(requestBody)
} }
suspend fun getEmergencyContact(contactId: String): Response<EmergencyContactResponse> { suspend fun getEmergencyContact(contactId: Int): Response<EmergencyContactResponse> {
return api.getEmergencyContact(contactId) return apiService.getEmergencyContact(contactId.toString())
} }
suspend fun updateEmergencyContact(contactId: String, contact: EmergencyContactUpdate): Response<EmergencyContactResponse> { suspend fun updateEmergencyContact(contactId: Int, contact: EmergencyContactUpdate): Response<EmergencyContactResponse> {
val requestBody = ApiRequestBody(emergencyContactUpdate = contact) val body = ApiRequestBody(emergency_contact_update = contact)
return api.updateEmergencyContact(contactId, requestBody) return apiService.updateEmergencyContact(contactId.toString(), body)
} }
suspend fun deleteEmergencyContact(contactId: String): Response<Unit> { suspend fun deleteEmergencyContact(contactId: Int): Response<Unit> {
return api.deleteEmergencyContact(contactId) return apiService.deleteEmergencyContact(contactId.toString())
} }
// Emergency methods // Emergency methods - возвращают Any согласно WomanSafeApi
suspend fun getEmergencyReports(): Response<Any> { suspend fun getEmergencyReports(): Response<Any> {
return api.getEmergencyReports() return apiService.getEmergencyReports()
} }
suspend fun createEmergencyReport(): Response<Any> { suspend fun createEmergencyReport(): Response<Any> {
return api.createEmergencyReport() return apiService.createEmergencyReport()
} }
suspend fun getNearbyEmergencyReports(): Response<Any> { suspend fun getNearbyEmergencyReports(): Response<Any> {
return api.getNearbyEmergencyReports() return apiService.getNearbyEmergencyReports()
} }
suspend fun getEmergencyReport(reportId: String): Response<Any> { suspend fun getEmergencyReport(reportId: Int): Response<Any> {
return api.getEmergencyReport(reportId) return apiService.getEmergencyReport(reportId.toString())
} }
suspend fun updateEmergencyReport(reportId: String): Response<Any> { suspend fun updateEmergencyReport(reportId: Int): Response<Any> {
return api.updateEmergencyReport(reportId) return apiService.updateEmergencyReport(reportId.toString())
} }
suspend fun deleteEmergencyReport(reportId: String): Response<Any> { suspend fun deleteEmergencyReport(reportId: Int): Response<Any> {
return api.deleteEmergencyReport(reportId) return apiService.deleteEmergencyReport(reportId.toString())
} }
// Emergency Alerts methods
suspend fun getEmergencyAlerts(): Response<Any> { suspend fun getEmergencyAlerts(): Response<List<EmergencyAlertResponse>> {
return api.getEmergencyAlerts() return apiService.getEmergencyAlerts()
} }
suspend fun createEmergencyAlert(): Response<Any> { suspend fun createEmergencyAlert(request: EmergencyAlertCreate): Response<EmergencyAlertResponse> {
return api.createEmergencyAlert() return apiService.createEmergencyAlert(request)
} }
suspend fun getMyEmergencyAlerts(): Response<Any> { suspend fun getMyEmergencyAlerts(): Response<List<EmergencyAlertResponse>> {
return api.getMyEmergencyAlerts() return apiService.getMyEmergencyAlerts()
} }
suspend fun getNearbyEmergencyAlerts(): Response<Any> { suspend fun getNearbyEmergencyAlerts(): Response<Any> {
return api.getNearbyEmergencyAlerts() return apiService.getNearbyEmergencyAlerts()
} }
suspend fun getEmergencyAlert(alertId: String): Response<Any> { suspend fun getEmergencyAlert(alertId: Int): Response<Any> {
return api.getEmergencyAlert(alertId) return apiService.getEmergencyAlert(alertId.toString())
} }
suspend fun updateEmergencyAlert(alertId: String): Response<Any> { suspend fun updateEmergencyAlert(alertId: Int): Response<Any> {
return api.updateEmergencyAlert(alertId) return apiService.updateEmergencyAlert(alertId.toString())
} }
suspend fun deleteEmergencyAlert(alertId: String): Response<Any> { suspend fun cancelEmergencyAlert(alertId: Int): Response<Any> {
return api.deleteEmergencyAlert(alertId) return apiService.cancelEmergencyAlert(alertId.toString())
} }
suspend fun cancelEmergencyAlert(alertId: String): Response<Any> { suspend fun deleteEmergencyAlert(alertId: Int): Response<Any> {
return api.cancelEmergencyAlert(alertId) return apiService.deleteEmergencyAlert(alertId.toString())
} }
// Location methods // Location methods
suspend fun updateLocation(): Response<Any> { suspend fun updateLocation(): Response<Any> {
return api.updateLocation() return apiService.updateLocation()
} }
suspend fun getLastLocation(): Response<Any> { suspend fun getLastLocation(): Response<Any> {
return api.getLastLocation() return apiService.getLastLocation()
} }
suspend fun getLocationHistory(): Response<Any> { suspend fun getLocationHistory(): Response<Any> {
return api.getLocationHistory() return apiService.getLocationHistory()
} }
suspend fun getNearbyUsers(): Response<Any> { suspend fun getNearbyUsers(): Response<Any> {
return api.getNearbyUsers() return apiService.getNearbyUsers()
} }
suspend fun getSafePlaces(): Response<Any> { suspend fun getSafePlaces(): Response<Any> {
return api.getSafePlaces() return apiService.getSafePlaces()
} }
suspend fun createSafePlace(): Response<Any> { suspend fun createSafePlace(): Response<Any> {
return api.createSafePlace() return apiService.createSafePlace()
} }
suspend fun getSafePlace(placeId: String): Response<Any> { suspend fun getSafePlace(placeId: Int): Response<Any> {
return api.getSafePlace(placeId) return apiService.getSafePlace(placeId.toString())
} }
suspend fun updateSafePlace(placeId: String): Response<Any> { suspend fun updateSafePlace(placeId: Int): Response<Any> {
return api.updateSafePlace(placeId) return apiService.updateSafePlace(placeId.toString())
} }
suspend fun deleteSafePlace(placeId: String): Response<Any> { suspend fun deleteSafePlace(placeId: Int): Response<Any> {
return api.deleteSafePlace(placeId) return apiService.deleteSafePlace(placeId.toString())
} }
// Calendar methods // Calendar methods
suspend fun getCalendarEntries(): Response<Any> { suspend fun getCalendarEntries(): Response<Any> {
return api.getCalendarEntries() return apiService.getCalendarEntries()
} }
suspend fun createCalendarEntry(): Response<Any> { suspend fun createCalendarEntry(): Response<Any> {
return api.createCalendarEntry() return apiService.createCalendarEntry()
} }
suspend fun getCalendarEntry(entryId: String): Response<Any> { suspend fun getCalendarEntry(entryId: Int): Response<Any> {
return api.getCalendarEntry(entryId) return apiService.getCalendarEntry(entryId.toString())
} }
suspend fun updateCalendarEntry(entryId: String): Response<Any> { suspend fun updateCalendarEntry(entryId: Int): Response<Any> {
return api.updateCalendarEntry(entryId) return apiService.updateCalendarEntry(entryId.toString())
} }
suspend fun deleteCalendarEntry(entryId: String): Response<Any> { suspend fun deleteCalendarEntry(entryId: Int): Response<Any> {
return api.deleteCalendarEntry(entryId) return apiService.deleteCalendarEntry(entryId.toString())
} }
suspend fun getCycleOverview(): Response<Any> { suspend fun getCycleOverview(): Response<Any> {
return api.getCycleOverview() return apiService.getCycleOverview()
} }
suspend fun getCalendarInsights(): Response<Any> { suspend fun getCalendarInsights(): Response<Any> {
return api.getCalendarInsights() return apiService.getCalendarInsights()
} }
suspend fun getCalendarReminders(): Response<Any> { suspend fun getCalendarReminders(): Response<Any> {
return api.getCalendarReminders() return apiService.getCalendarReminders()
} }
suspend fun createCalendarReminder(): Response<Any> { suspend fun createCalendarReminder(): Response<Any> {
return api.createCalendarReminder() return apiService.createCalendarReminder()
} }
suspend fun getCalendarSettings(): Response<Any> { suspend fun getCalendarSettings(): Response<Any> {
return api.getCalendarSettings() return apiService.getCalendarSettings()
} }
suspend fun updateCalendarSettings(): Response<Any> { suspend fun updateCalendarSettings(): Response<Any> {
return api.updateCalendarSettings() return apiService.updateCalendarSettings()
} }
// Notification methods // Notification methods
suspend fun getNotificationDevices(): Response<Any> { suspend fun getNotificationDevices(): Response<Any> {
return api.getNotificationDevices() return apiService.getNotificationDevices()
} }
suspend fun createNotificationDevice(): Response<Any> { suspend fun registerNotificationDevice(): Response<Any> {
return api.createNotificationDevice() return apiService.createNotificationDevice()
} }
suspend fun getNotificationDevice(deviceId: String): Response<Any> { suspend fun getNotificationDevice(deviceId: String): Response<Any> {
return api.getNotificationDevice(deviceId) return apiService.getNotificationDevice(deviceId)
} }
suspend fun deleteNotificationDevice(deviceId: String): Response<Any> { suspend fun unregisterNotificationDevice(deviceId: String): Response<Any> {
return api.deleteNotificationDevice(deviceId) return apiService.deleteNotificationDevice(deviceId)
} }
suspend fun getNotificationPreferences(): Response<Any> { suspend fun getNotificationPreferences(): Response<Any> {
return api.getNotificationPreferences() return apiService.getNotificationPreferences()
} }
suspend fun updateNotificationPreferences(): Response<Any> { suspend fun updateNotificationPreferences(): Response<Any> {
return api.updateNotificationPreferences() return apiService.updateNotificationPreferences()
} }
suspend fun testNotification(): Response<Any> { suspend fun sendTestNotification(): Response<Any> {
return api.testNotification() return apiService.testNotification()
} }
suspend fun getNotificationHistory(): Response<Any> { suspend fun getNotificationHistory(): Response<Any> {
return api.getNotificationHistory() return apiService.getNotificationHistory()
} }
// Health check methods // Health and status methods
suspend fun getHealth(): Response<Any> { suspend fun getHealth(): Response<Any> {
return api.getHealth() return apiService.getHealth()
} }
suspend fun getServicesStatus(): Response<Any> { suspend fun getServicesStatus(): Response<Any> {
return api.getServicesStatus() return apiService.getServicesStatus()
} }
suspend fun getRoot(): Response<Any> { suspend fun getRoot(): Response<Any> {
return api.getRoot() return apiService.getRoot()
} }
} }

View File

@@ -221,10 +221,25 @@ fun UserTab(
Text("ID: ${currentUser.id}") Text("ID: ${currentUser.id}")
Text("UUID: ${currentUser.uuid}") Text("UUID: ${currentUser.uuid}")
Text("Email: ${currentUser.email}") Text("Email: ${currentUser.email}")
currentUser.fullName?.let { Text("Имя: $it") } currentUser.full_name?.let { Text("Имя: $it") }
currentUser.phoneNumber?.let { Text("Телефон: $it") } currentUser.phone_number?.let { Text("Телефон: $it") }
Text("Email подтвержден: ${if (currentUser.emailVerified) "Да" else "Нет"}") currentUser.username?.let { Text("Имя пользователя: $it") }
Text("Активен: ${if (currentUser.isActive) "Да" else "Нет"}") currentUser.first_name?.let { Text("Имя: $it") }
currentUser.last_name?.let { Text("Фамилия: $it") }
currentUser.bio?.let { Text("О себе: $it") }
currentUser.date_of_birth?.let { Text("Дата рождения: $it") }
Text("Email подтвержден: ${if (currentUser.email_verified) "Да" else "Нет"}")
Text("Телефон подтвержден: ${if (currentUser.phone_verified) "Да" else "Нет"}")
Text("Активен: ${if (currentUser.is_active) "Да" else "Нет"}")
Text("Геолокация включена: ${if (currentUser.location_sharing_enabled) "Да" else "Нет"}")
Text("Экстренные уведомления: ${if (currentUser.emergency_notifications_enabled) "Да" else "Нет"}")
Text("Push-уведомления: ${if (currentUser.push_notifications_enabled) "Да" else "Нет"}")
currentUser.emergency_contact_1_name?.let {
Text("Экстренный контакт 1: $it (${currentUser.emergency_contact_1_phone ?: "Не указан"})")
}
currentUser.emergency_contact_2_name?.let {
Text("Экстренный контакт 2: $it (${currentUser.emergency_contact_2_phone ?: "Не указан"})")
}
} }
} }
} }
@@ -354,7 +369,7 @@ fun ContactsTab(
Column( Column(
modifier = Modifier.padding(12.dp) modifier = Modifier.padding(12.dp)
) { ) {
Text("${contact.name} - ${contact.phoneNumber}") Text("${contact.name} - ${contact.phone_number}")
contact.relationship?.let { Text("Отношение: $it", fontSize = 12.sp) } contact.relationship?.let { Text("Отношение: $it", fontSize = 12.sp) }
contact.notes?.let { Text("Заметки: $it", fontSize = 12.sp) } contact.notes?.let { Text("Заметки: $it", fontSize = 12.sp) }
Text("ID: ${contact.id}", fontSize = 10.sp, color = Color.Gray) Text("ID: ${contact.id}", fontSize = 10.sp, color = Color.Gray)

View File

@@ -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
}
}
)
}
}
}

View File

@@ -0,0 +1,640 @@
package com.example.womansafe.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.womansafe.data.model.UserResponse
import com.example.womansafe.ui.viewmodel.AuthViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AuthScreen(viewModel: AuthViewModel) {
val uiState = viewModel.uiState
var isLogin by remember { mutableStateOf(true) }
if (uiState.isLoggedIn) {
// Показываем индикатор загрузки профиля или сам профиль
if (!uiState.profileLoaded && uiState.user == null) {
ProfileLoadingScreen()
} else {
UserProfileScreen(
user = uiState.user,
onLogout = { viewModel.logout() },
error = uiState.error,
onClearError = { viewModel.clearError() }
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (isLogin) {
LoginForm(
onLogin = { usernameOrEmail, password ->
viewModel.login(usernameOrEmail, password)
},
onSwitchToRegister = { isLogin = false },
isLoading = uiState.isLoading,
error = uiState.error,
onClearError = { viewModel.clearError() }
)
} else {
RegisterForm(
onRegister = { username, email, password, fullName, phoneNumber ->
viewModel.register(username, email, password, fullName, phoneNumber)
},
onSwitchToLogin = { isLogin = true },
isLoading = uiState.isLoading,
error = uiState.error,
onClearError = { viewModel.clearError() }
)
}
}
}
}
@Composable
fun LoginForm(
onLogin: (String, String) -> Unit,
onSwitchToRegister: () -> Unit,
isLoading: Boolean,
error: String?,
onClearError: () -> Unit
) {
var usernameOrEmail by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Вход в приложение",
style = MaterialTheme.typography.headlineMedium
)
OutlinedTextField(
value = usernameOrEmail,
onValueChange = {
usernameOrEmail = it
if (error != null) onClearError()
},
label = { Text("Email или имя пользователя") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
OutlinedTextField(
value = password,
onValueChange = {
password = it
if (error != null) onClearError()
},
label = { Text("Пароль") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
if (error != null) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = error,
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Button(
onClick = { onLogin(usernameOrEmail, password) },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading && usernameOrEmail.isNotBlank() && password.isNotBlank()
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Вход...")
} else {
Text("Войти")
}
}
TextButton(
onClick = onSwitchToRegister,
enabled = !isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text("Нет аккаунта? Зарегистрироваться")
}
}
}
}
@Composable
fun RegisterForm(
onRegister: (String, String, String, String?, String?) -> Unit,
onSwitchToLogin: () -> Unit,
isLoading: Boolean,
error: String?,
onClearError: () -> Unit
) {
var username by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var fullName by remember { mutableStateOf("") }
var phoneNumber by remember { mutableStateOf("") }
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Регистрация",
style = MaterialTheme.typography.headlineMedium
)
OutlinedTextField(
value = email,
onValueChange = {
email = it
if (error != null) onClearError()
},
label = { Text("Email *") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
OutlinedTextField(
value = username,
onValueChange = {
username = it
if (error != null) onClearError()
},
label = { Text("Имя пользователя") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
OutlinedTextField(
value = fullName,
onValueChange = {
fullName = it
if (error != null) onClearError()
},
label = { Text("Полное имя") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
OutlinedTextField(
value = phoneNumber,
onValueChange = {
phoneNumber = it
if (error != null) onClearError()
},
label = { Text("Номер телефона") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
OutlinedTextField(
value = password,
onValueChange = {
password = it
if (error != null) onClearError()
},
label = { Text("Пароль *") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
if (error != null) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = error,
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Button(
onClick = {
onRegister(
username.ifBlank { null } ?: "",
email,
password,
fullName.ifBlank { null },
phoneNumber.ifBlank { null }
)
},
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading && email.isNotBlank() && password.isNotBlank()
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Регистрация...")
} else {
Text("Зарегистрироваться")
}
}
TextButton(
onClick = onSwitchToLogin,
enabled = !isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text("Уже есть аккаунт? Войти")
}
}
}
}
@Composable
fun ProfileLoadingScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Загрузка профиля...",
style = MaterialTheme.typography.bodyLarge
)
}
}
@Composable
fun UserProfileScreen(
user: UserResponse?,
onLogout: () -> Unit,
error: String? = null,
onClearError: (() -> Unit)? = null
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
// Заголовок профиля с реальными данными
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = user?.full_name ?: user?.username ?: "Профиль",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
user?.email?.let { email ->
Text(
text = email,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
}
IconButton(
onClick = onLogout,
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Transparent
)
) {
Icon(
imageVector = Icons.Filled.ExitToApp,
contentDescription = "Выйти",
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
// Отображение ошибки, если есть
if (error != null && onClearError != null) {
item {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onClearError) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Закрыть",
tint = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
}
item {
// Основная информация пользователя из API
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Личная информация",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
user?.let {
ProfileInfoRow("ID пользователя", it.id.toString())
ProfileInfoRow("UUID", it.uuid)
ProfileInfoRow("Email", it.email)
it.username?.let { username ->
ProfileInfoRow("Имя пользователя", username)
}
it.full_name?.let { name ->
ProfileInfoRow("Полное имя", name)
}
it.first_name?.let { firstName ->
ProfileInfoRow("Имя", firstName)
}
it.last_name?.let { lastName ->
ProfileInfoRow("Фамилия", lastName)
}
it.phone_number?.let { phone ->
ProfileInfoRow("Номер телефона", phone)
}
it.date_of_birth?.let { date ->
ProfileInfoRow("Дата рождения", date)
}
it.bio?.let { bio ->
ProfileInfoRow("О себе", bio)
}
}
}
}
}
item {
// Статус аккаунта из API
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Статус аккаунта",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
user?.let {
ProfileStatusRow(
"Аккаунт активен",
it.is_active,
if (it.is_active) "Активный" else "Неактивный"
)
ProfileStatusRow(
"Email подтверждён",
it.email_verified,
if (it.email_verified) "Подтверждён" else "Требует подтверждения"
)
ProfileStatusRow(
"Телефон подтверждён",
it.phone_verified,
if (it.phone_verified) "Подтверждён" else "Требует подтверждения"
)
}
}
}
}
item {
// Настройки уведомлений из API
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Настройки уведомлений",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
user?.let {
ProfileStatusRow(
"Геолокация",
it.location_sharing_enabled,
if (it.location_sharing_enabled) "Включена" else "Отключена"
)
ProfileStatusRow(
"Экстренные уведомления",
it.emergency_notifications_enabled,
if (it.emergency_notifications_enabled) "Включены" else "Отключены"
)
ProfileStatusRow(
"Push-уведомления",
it.push_notifications_enabled,
if (it.push_notifications_enabled) "Включены" else "Отключены"
)
}
}
}
}
// Экстренные контакты из API (если есть в профиле пользователя)
user?.let { userData ->
if (userData.emergency_contact_1_name != null || userData.emergency_contact_2_name != null) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Экстренные контакты",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
userData.emergency_contact_1_name?.let { contact1 ->
ProfileInfoRow("Контакт 1", contact1)
userData.emergency_contact_1_phone?.let { phone1 ->
ProfileInfoRow("Телефон 1", phone1)
}
}
userData.emergency_contact_2_name?.let { contact2 ->
ProfileInfoRow("Контакт 2", contact2)
userData.emergency_contact_2_phone?.let { phone2 ->
ProfileInfoRow("Телефон 2", phone2)
}
}
}
}
}
}
}
item {
// Кнопка выхода
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Default.ExitToApp,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Выйти из аккаунта")
}
}
}
}
@Composable
fun ProfileInfoRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
@Composable
fun ProfileStatusRow(label: String, isEnabled: Boolean, statusText: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isEnabled) Icons.Default.CheckCircle else Icons.Default.Close,
contentDescription = null,
tint = if (isEnabled) Color(0xFF4CAF50) else Color(0xFFF44336),
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = statusText,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (isEnabled) Color(0xFF4CAF50) else Color(0xFFF44336)
)
}
}
}

View File

@@ -0,0 +1,879 @@
package com.example.womansafe.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.womansafe.data.model.*
import com.example.womansafe.ui.viewmodel.CalendarViewModel
import com.example.womansafe.util.DateUtils
import java.text.SimpleDateFormat
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarScreen(
calendarViewModel: CalendarViewModel,
modifier: Modifier = Modifier
) {
val uiState = calendarViewModel.uiState
val scrollState = rememberLazyListState()
// Автоскролл к выбранной дате
LaunchedEffect(uiState.selectedDate) {
val today = LocalDate.now()
val daysDiff = ChronoUnit.DAYS.between(today, uiState.selectedDate).toInt()
if (daysDiff in -30..30) {
scrollState.animateScrollToItem(daysDiff + 30)
}
}
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
// Заголовок с месяцем и навигацией
MonthHeader(
currentMonth = uiState.currentMonth,
onPreviousMonth = { calendarViewModel.changeMonth(-1) },
onNextMonth = { calendarViewModel.changeMonth(1) },
onSettingsClick = { calendarViewModel.showSettingsDialog() }
)
Spacer(modifier = Modifier.height(16.dp))
// Горизонтальный скролл дней
HorizontalDayScroll(
selectedDate = uiState.selectedDate,
events = uiState.events,
predictions = uiState.predictions,
onDateSelect = { calendarViewModel.selectDate(it) },
scrollState = scrollState
)
Spacer(modifier = Modifier.height(24.dp))
// Информация о выбранном дне
SelectedDateInfo(
selectedDate = uiState.selectedDate,
events = uiState.events[uiState.selectedDate] ?: emptyList(),
predictions = uiState.predictions,
onAddEvent = { calendarViewModel.showEventDialog() },
onRemoveEvent = { type -> calendarViewModel.removeEvent(uiState.selectedDate, type) }
)
Spacer(modifier = Modifier.height(16.dp))
// Прогнозы и статистика
if (uiState.predictions != null || uiState.statistics != null) {
PredictionsCard(
predictions = uiState.predictions,
statistics = uiState.statistics
)
}
}
// Диалог добавления события
if (uiState.showEventDialog) {
AddEventDialog(
selectedDate = uiState.selectedDate,
onDismiss = { calendarViewModel.hideEventDialog() },
onAddEvent = { type, mood, symptoms, notes, flowIntensity ->
calendarViewModel.addEvent(
date = uiState.selectedDate,
type = type,
mood = mood,
symptoms = symptoms,
notes = notes,
flowIntensity = flowIntensity
)
calendarViewModel.hideEventDialog()
}
)
}
// Диалог настроек цикла
if (uiState.showSettingsDialog) {
CycleSettingsDialog(
settings = uiState.settings,
onDismiss = { calendarViewModel.hideSettingsDialog() },
onSave = { settings ->
calendarViewModel.updateCycleSettings(settings)
calendarViewModel.hideSettingsDialog()
}
)
}
// Обработка ошибок
uiState.error?.let { error ->
LaunchedEffect(error) {
// TODO: Показать snackbar с ошибкой
}
}
}
@Composable
private fun MonthHeader(
currentMonth: LocalDate,
onPreviousMonth: () -> Unit,
onNextMonth: () -> Unit,
onSettingsClick: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onPreviousMonth) {
Icon(
imageVector = Icons.Filled.KeyboardArrowLeft,
contentDescription = "Предыдущий месяц"
)
}
Text(
text = DateUtils.formatDate(currentMonth, "LLLL yyyy"),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Row {
IconButton(onClick = onSettingsClick) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = "Настройки цикла"
)
}
IconButton(onClick = onNextMonth) {
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = "Следующий месяц"
)
}
}
}
}
}
@Composable
private fun HorizontalDayScroll(
selectedDate: LocalDate,
events: Map<LocalDate, List<CalendarEvent>>,
predictions: CyclePrediction?,
onDateSelect: (LocalDate) -> Unit,
scrollState: LazyListState
) {
val today = DateUtils.getCurrentDate()
// Генерируем дни для показа (от -30 до +60 дней от сегодня)
val days = (-30..60).map { dayOffset ->
today.plusDays(dayOffset.toLong())
}
LazyRow(
state = scrollState,
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp)
) {
items(days) { date ->
DayItem(
date = date,
isSelected = date.isEqual(selectedDate),
isToday = DateUtils.isSameDay(date, today),
events = events[date] ?: emptyList(),
predictions = predictions,
onClick = { onDateSelect(date) }
)
}
}
}
@Composable
private fun DayItem(
date: LocalDate,
isSelected: Boolean,
isToday: Boolean,
events: List<CalendarEvent>,
predictions: CyclePrediction?,
onClick: () -> Unit
) {
val backgroundColor = when {
isSelected -> MaterialTheme.colorScheme.primary
isToday -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surface
}
val textColor = when {
isSelected -> MaterialTheme.colorScheme.onPrimary
isToday -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurface
}
// Получение дня недели и числа месяца
val dayOfWeek = DateUtils.formatDate(date, "EEE")
val dayOfMonth = date.dayOfMonth
// Определяем цвет индикатора событий
val eventIndicatorColor = getEventIndicatorColor(events, predictions, date)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(60.dp)
.clip(RoundedCornerShape(12.dp))
.background(backgroundColor)
.clickable { onClick() }
.padding(8.dp)
) {
// День недели
Text(
text = dayOfWeek,
style = MaterialTheme.typography.bodySmall,
color = textColor.copy(alpha = 0.7f),
fontSize = 10.sp
)
// Число
Text(
text = dayOfMonth.toString(),
style = MaterialTheme.typography.titleMedium,
fontWeight = if (isSelected || isToday) FontWeight.Bold else FontWeight.Normal,
color = textColor
)
// Индикаторы событий
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.height(8.dp)
) {
eventIndicatorColor.forEach { color ->
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(color)
)
}
}
}
}
@Composable
private fun SelectedDateInfo(
selectedDate: LocalDate,
events: List<CalendarEvent>,
predictions: CyclePrediction?,
onAddEvent: () -> Unit,
onRemoveEvent: (CalendarEventType) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = DateUtils.formatDate(selectedDate, "d MMMM yyyy"),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
IconButton(onClick = onAddEvent) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Добавить событие"
)
}
}
Spacer(modifier = Modifier.height(16.dp))
if (events.isEmpty() && !isPredictedDate(selectedDate, predictions)) {
Text(
text = "Нет событий на этот день",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
// Показываем события
events.forEach { event ->
EventItem(
event = event,
onRemove = { onRemoveEvent(event.type) }
)
Spacer(modifier = Modifier.height(8.dp))
}
// Показываем прогнозы для этого дня
predictions?.let { pred ->
when {
DateUtils.isDateInRange(selectedDate, pred.nextPeriodStart, pred.nextPeriodEnd) -> {
PredictionItem(
type = "Прогноз месячных",
color = MaterialTheme.colorScheme.error.copy(alpha = 0.3f),
confidence = pred.confidence
)
}
selectedDate.isEqual(pred.nextOvulation) -> {
PredictionItem(
type = "Прогноз овуляции",
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
confidence = pred.confidence
)
}
DateUtils.isDateInRange(selectedDate, pred.fertileWindowStart, pred.fertileWindowEnd) -> {
PredictionItem(
type = "Период фертильности",
color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f),
confidence = pred.confidence
)
}
}
}
}
}
}
}
@Composable
private fun EventItem(
event: CalendarEvent,
onRemove: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(getEventColor(event.type).copy(alpha = 0.1f))
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = getEventTypeName(event.type),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = getEventColor(event.type)
)
event.mood?.let { mood ->
Text(
text = "Настроение: ${getMoodName(mood)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (event.symptoms.isNotEmpty()) {
Text(
text = "Симптомы: ${event.symptoms.size}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (event.notes.isNotBlank()) {
Text(
text = event.notes,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (event.isActual) {
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Удалить событие",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
}
}
}
}
@Composable
private fun PredictionItem(
type: String,
color: Color,
confidence: Float
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(color)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = "Прогноз",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = type,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = "Вероятность: ${(confidence * 100).toInt()}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun PredictionsCard(
predictions: CyclePrediction?,
statistics: CycleStatistics?
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = "Прогнозы и статистика",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
predictions?.let { pred ->
PredictionRow("Следующие месячные", pred.nextPeriodStart)
PredictionRow("Овуляция", pred.nextOvulation)
}
statistics?.let { stats ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Средняя длина цикла: ${stats.averageCycleLength.toInt()} дней",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Вариация: ±${stats.cycleVariation.toInt()} дней",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
@Composable
private fun PredictionRow(
label: String,
date: LocalDate
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = DateUtils.formatDate(date, "d MMM"),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddEventDialog(
selectedDate: LocalDate,
onDismiss: () -> Unit,
onAddEvent: (CalendarEventType, MoodType?, List<SymptomType>, String, Int?) -> Unit
) {
var selectedEventType by remember { mutableStateOf(CalendarEventType.MENSTRUATION) }
var selectedMood by remember { mutableStateOf<MoodType?>(null) }
var selectedSymptoms by remember { mutableStateOf(setOf<SymptomType>()) }
var notes by remember { mutableStateOf("") }
var flowIntensity by remember { mutableStateOf<Int?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("Добавить событие на ${DateUtils.formatDate(selectedDate, "d MMMM")}")
},
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Тип события
Text(
text = "Тип события",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium
)
Row {
listOf(
CalendarEventType.MENSTRUATION to "Месячные",
CalendarEventType.OVULATION to "Овуляция"
).forEach { (type, name) ->
FilterChip(
onClick = { selectedEventType = type },
label = { Text(name) },
selected = selectedEventType == type,
modifier = Modifier.padding(end = 8.dp)
)
}
}
// Интенсивность (только для месячных)
if (selectedEventType == CalendarEventType.MENSTRUATION) {
Text(
text = "Интенсивность",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium
)
Row {
(1..5).forEach { intensity ->
FilterChip(
onClick = { flowIntensity = intensity },
label = { Text(intensity.toString()) },
selected = flowIntensity == intensity,
modifier = Modifier.padding(end = 4.dp)
)
}
}
}
// Настроение
Text(
text = "Настроение",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium
)
Row {
MoodType.values().forEach { mood ->
FilterChip(
onClick = {
selectedMood = if (selectedMood == mood) null else mood
},
label = { Text(getMoodEmoji(mood)) },
selected = selectedMood == mood,
modifier = Modifier.padding(end = 4.dp)
)
}
}
// Симптомы
Text(
text = "Симптомы",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Создаем строки по 2 симптома вручную, избегая chunked
val symptoms = SymptomType.values().toList()
var i = 0
while (i < symptoms.size) {
Row {
// Первый симптом в строке
FilterChip(
onClick = {
val symptom = symptoms[i]
selectedSymptoms = if (selectedSymptoms.contains(symptom)) {
selectedSymptoms - symptom
} else {
selectedSymptoms + symptom
}
},
label = { Text(getSymptomName(symptoms[i])) },
selected = selectedSymptoms.contains(symptoms[i]),
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
)
// Второй симптом в строке (если есть)
if (i + 1 < symptoms.size) {
FilterChip(
onClick = {
val symptom = symptoms[i + 1]
selectedSymptoms = if (selectedSymptoms.contains(symptom)) {
selectedSymptoms - symptom
} else {
selectedSymptoms + symptom
}
},
label = { Text(getSymptomName(symptoms[i + 1])) },
selected = selectedSymptoms.contains(symptoms[i + 1]),
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
)
} else {
// Пустое место для выравнивания
Spacer(modifier = Modifier.weight(1f))
}
}
i += 2
}
}
// Заметки
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Заметки") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3
)
}
},
confirmButton = {
TextButton(
onClick = {
onAddEvent(
selectedEventType,
selectedMood,
selectedSymptoms.toList(),
notes,
flowIntensity
)
}
) {
Text("Добавить")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Отмена")
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CycleSettingsDialog(
settings: CycleSettings,
onDismiss: () -> Unit,
onSave: (CycleSettings) -> Unit
) {
var cycleLength by remember { mutableStateOf(settings.averageCycleLength.toString()) }
var periodLength by remember { mutableStateOf(settings.averagePeriodLength.toString()) }
var lastPeriodDate by remember { mutableStateOf(settings.lastPeriodStart) }
var reminderDays by remember { mutableStateOf(settings.reminderDaysBefore.toString()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Настройки цикла") },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
value = cycleLength,
onValueChange = { cycleLength = it },
label = { Text("Средняя длина цикла (дни)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
OutlinedTextField(
value = periodLength,
onValueChange = { periodLength = it },
label = { Text("Длина менструации (дни)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
OutlinedTextField(
value = reminderDays,
onValueChange = { reminderDays = it },
label = { Text("Напоминать за (дни)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
// TODO: Добавить выбор даты последних месячных
lastPeriodDate?.let { date ->
Text(
text = "Последние месячные: ${date.format(DateTimeFormatter.ofPattern("d MMMM yyyy"))}",
style = MaterialTheme.typography.bodyMedium
)
}
}
},
confirmButton = {
TextButton(
onClick = {
val newSettings = settings.copy(
averageCycleLength = cycleLength.toIntOrNull() ?: settings.averageCycleLength,
averagePeriodLength = periodLength.toIntOrNull() ?: settings.averagePeriodLength,
reminderDaysBefore = reminderDays.toIntOrNull() ?: settings.reminderDaysBefore,
lastPeriodStart = lastPeriodDate
)
onSave(newSettings)
}
) {
Text("Сохранить")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Отмена")
}
}
)
}
// Вспомогательные функции
private fun getEventIndicatorColor(
events: List<CalendarEvent>,
predictions: CyclePrediction?,
date: LocalDate
): List<Color> {
val colors = mutableListOf<Color>()
events.forEach { event ->
colors.add(getEventColor(event.type))
}
// Добавляем цвета прогнозов
predictions?.let { pred ->
when {
DateUtils.isDateInRange(date, pred.nextPeriodStart, pred.nextPeriodEnd) -> {
if (!events.any { it.type == CalendarEventType.MENSTRUATION }) {
colors.add(Color.Red.copy(alpha = 0.5f))
}
}
date.isEqual(pred.nextOvulation) -> {
if (!events.any { it.type == CalendarEventType.OVULATION }) {
colors.add(Color.Blue.copy(alpha = 0.5f))
}
}
DateUtils.isDateInRange(date, pred.fertileWindowStart, pred.fertileWindowEnd) -> {
if (!events.any { it.type == CalendarEventType.FERTILE_WINDOW }) {
colors.add(Color.Green.copy(alpha = 0.5f))
}
}
}
}
return colors.take(3) // Максимум 3 индикатора
}
private fun getEventColor(type: CalendarEventType): Color {
return when (type) {
CalendarEventType.MENSTRUATION -> Color.Red
CalendarEventType.OVULATION -> Color.Blue
CalendarEventType.FERTILE_WINDOW -> Color.Green
CalendarEventType.PREDICTED_MENSTRUATION -> Color.Red.copy(alpha = 0.5f)
CalendarEventType.PREDICTED_OVULATION -> Color.Blue.copy(alpha = 0.5f)
}
}
private fun getEventTypeName(type: CalendarEventType): String {
return when (type) {
CalendarEventType.MENSTRUATION -> "Месячные"
CalendarEventType.OVULATION -> "Овуляция"
CalendarEventType.FERTILE_WINDOW -> "Фертильность"
CalendarEventType.PREDICTED_MENSTRUATION -> "Прогноз месячных"
CalendarEventType.PREDICTED_OVULATION -> "Прогноз овуляции"
}
}
private fun getMoodName(mood: MoodType): String {
return when (mood) {
MoodType.EXCELLENT -> "Отлично"
MoodType.GOOD -> "Хорошо"
MoodType.NORMAL -> "Нормально"
MoodType.BAD -> "Плохо"
MoodType.TERRIBLE -> "Ужасно"
}
}
private fun getMoodEmoji(mood: MoodType): String {
return when (mood) {
MoodType.EXCELLENT -> "😄"
MoodType.GOOD -> "😊"
MoodType.NORMAL -> "😐"
MoodType.BAD -> "😞"
MoodType.TERRIBLE -> "😫"
}
}
private fun getSymptomName(symptom: SymptomType): String {
return when (symptom) {
SymptomType.CRAMPS -> "Спазмы"
SymptomType.HEADACHE -> "Головная боль"
SymptomType.BLOATING -> "Вздутие"
SymptomType.BREAST_TENDERNESS -> "Болезненность груди"
SymptomType.MOOD_SWINGS -> "Перепады настроения"
SymptomType.FATIGUE -> "Усталость"
SymptomType.ACNE -> "Акне"
SymptomType.CRAVINGS -> "Тяга к еде"
SymptomType.BACK_PAIN -> "Боль в спине"
SymptomType.NAUSEA -> "Тошнота"
}
}
private fun isPredictedDate(date: LocalDate, predictions: CyclePrediction?): Boolean {
predictions ?: return false
return DateUtils.isDateInRange(date, predictions.nextPeriodStart, predictions.nextPeriodEnd) ||
date.isEqual(predictions.nextOvulation) ||
DateUtils.isDateInRange(date, predictions.fertileWindowStart, predictions.fertileWindowEnd)
}

View File

@@ -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("Отмена")
}
}
)
}

View File

@@ -0,0 +1,452 @@
package com.example.womansafe.ui.screens
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.womansafe.data.model.EmergencyContactResponse
import com.example.womansafe.data.model.EmergencyType
import com.example.womansafe.ui.viewmodel.EmergencyViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmergencyScreen(
emergencyViewModel: EmergencyViewModel = viewModel()
) {
val context = LocalContext.current
val uiState by emergencyViewModel.uiState.collectAsState()
// Запуск анимации для экстренной кнопки
val infiniteTransition = rememberInfiniteTransition(label = "emergency_pulse")
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = if (uiState.isEmergencyActive) 1.1f else 1f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
), label = "emergency_scale"
)
// Запрос разрешений на местоположение
val locationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val granted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
emergencyViewModel.onLocationPermissionResult(granted)
}
// Инициализация клиента местоположения
LaunchedEffect(Unit) {
emergencyViewModel.initLocationClient(context)
emergencyViewModel.loadEmergencyContacts()
// Запрашиваем разрешения если их нет
if (!uiState.hasLocationPermission) {
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
}
}
// Диалог выбора типа экстренной ситуации
var showEmergencyTypeDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)
)
)
)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Заголовок
Text(
text = "Экстренная ситуация",
style = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.Bold
),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 24.dp)
)
// Статус местоположения
LocationStatusCard(
hasPermission = uiState.hasLocationPermission,
currentLocation = uiState.currentLocation?.address,
onRequestPermission = {
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
}
)
Spacer(modifier = Modifier.height(32.dp))
// Главная кнопка экстренной ситуации
EmergencyButton(
isActive = uiState.isEmergencyActive,
isLoading = uiState.isLoading,
scale = scale,
onClick = {
if (uiState.isEmergencyActive) {
emergencyViewModel.cancelEmergencyAlert()
} else {
showEmergencyTypeDialog = true
}
}
)
Spacer(modifier = Modifier.height(32.dp))
// Экстренные контакты
EmergencyContactsSection(
contacts = uiState.emergencyContacts,
context = context
)
// Сообщение об ошибке
uiState.errorMessage?.let { error ->
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp),
textAlign = TextAlign.Center
)
}
}
}
// Диалог выбора типа экстренной ситуации
if (showEmergencyTypeDialog) {
EmergencyTypeDialog(
onDismiss = { showEmergencyTypeDialog = false },
onTypeSelected = { type ->
emergencyViewModel.createEmergencyAlert(
context = context,
type = type
)
showEmergencyTypeDialog = false
}
)
}
}
@Composable
private fun LocationStatusCard(
hasPermission: Boolean,
currentLocation: String?,
onRequestPermission: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (hasPermission)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (hasPermission) Icons.Filled.LocationOn else Icons.Filled.Clear,
contentDescription = null,
tint = if (hasPermission)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (hasPermission) "Местоположение определено" else "Нет доступа к местоположению",
style = MaterialTheme.typography.titleSmall,
color = if (hasPermission)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onErrorContainer
)
if (hasPermission && currentLocation != null) {
Text(
text = currentLocation,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
}
if (!hasPermission) {
TextButton(onClick = onRequestPermission) {
Text("Разрешить")
}
}
}
}
}
@Composable
private fun EmergencyButton(
isActive: Boolean,
isLoading: Boolean,
scale: Float,
onClick: () -> Unit
) {
Button(
onClick = onClick,
modifier = Modifier
.size(200.dp)
.scale(scale),
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
containerColor = if (isActive)
MaterialTheme.colorScheme.error
else
Color.Red
),
enabled = !isLoading
) {
if (isLoading) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.onError,
modifier = Modifier.size(48.dp)
)
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = if (isActive) Icons.Filled.Close else Icons.Filled.Warning,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = Color.White
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (isActive) "ОТМЕНИТЬ" else "SOS",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
@Composable
private fun EmergencyContactsSection(
contacts: List<EmergencyContactResponse>,
context: Context
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 12.dp)
) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Экстренные контакты",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
if (contacts.isEmpty()) {
Text(
text = "Добавьте экстренные контакты в настройках",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
} else {
LazyColumn {
items(contacts) { contact ->
EmergencyContactItem(
contact = contact,
onCallClick = {
val intent = Intent(Intent.ACTION_CALL).apply {
data = Uri.parse("tel:${contact.phone_number}")
}
context.startActivity(intent)
}
)
if (contact != contacts.last()) {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
}
}
}
@Composable
private fun EmergencyContactItem(
contact: EmergencyContactResponse,
onCallClick: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = contact.name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Text(
text = contact.phone_number,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
contact.relationship?.let { relationship ->
Text(
text = relationship,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
FilledTonalButton(
onClick = onCallClick,
modifier = Modifier.padding(start = 8.dp)
) {
Icon(
imageVector = Icons.Filled.Phone,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Вызов")
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EmergencyTypeDialog(
onDismiss: () -> Unit,
onTypeSelected: (EmergencyType) -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "Выберите тип экстренной ситуации",
style = MaterialTheme.typography.titleLarge
)
},
text = {
LazyColumn {
items(EmergencyType.values()) { type ->
val (title, icon) = when (type) {
EmergencyType.HARASSMENT -> "Домогательства" to Icons.Filled.Warning
EmergencyType.ASSAULT -> "Нападение" to Icons.Filled.Warning
EmergencyType.STALKING -> "Преследование" to Icons.Filled.Search
EmergencyType.DOMESTIC_VIOLENCE -> "Домашнее насилие" to Icons.Filled.Home
EmergencyType.UNSAFE_AREA -> "Небезопасная зона" to Icons.Filled.LocationOn
EmergencyType.MEDICAL -> "Медицинская помощь" to Icons.Filled.Favorite
EmergencyType.OTHER -> "Другое" to Icons.Filled.MoreVert
}
Card(
onClick = { onTypeSelected(type) },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Отмена")
}
}
)
}

View File

@@ -0,0 +1,561 @@
package com.example.womansafe.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.womansafe.ui.viewmodel.AuthViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
authViewModel: AuthViewModel,
modifier: Modifier = Modifier
) {
val uiState = authViewModel.uiState
var showLocationDialog by remember { mutableStateOf(false) }
var showEmergencyDialog by remember { mutableStateOf(false) }
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Spacer(modifier = Modifier.height(16.dp))
// Приветствие пользователя
WelcomeCard(userName = uiState.user?.username ?: "Пользователь")
}
item {
// Быстрые действия
QuickActionsSection(
onShareLocation = { showLocationDialog = true },
onCallHelp = { /* TODO: Call emergency services */ },
onSendSignal = { showEmergencyDialog = true }
)
}
item {
// Статус безопасности
SafetyStatusCard(uiState)
}
item {
// Экстренные контакты
EmergencyContactsCard(
contacts = uiState.emergencyContacts ?: emptyList()
)
}
item {
// Последняя активность
RecentActivityCard()
}
item {
// Календарь - краткий обзор
CalendarOverviewCard()
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
}
// Диалог отправки местоположения
if (showLocationDialog) {
LocationSharingDialog(
onDismiss = { showLocationDialog = false },
onConfirm = { contacts ->
// TODO: Отправить местоположение выбранным контактам
showLocationDialog = false
}
)
}
// Диалог экстренного сигнала
if (showEmergencyDialog) {
EmergencySignalDialog(
onDismiss = { showEmergencyDialog = false },
onConfirm = { message ->
// TODO: Отправить экстренный сигнал
showEmergencyDialog = false
}
)
}
}
@Composable
private fun WelcomeCard(userName: String) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = "Привет, $userName!",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Ваша безопасность - наш приоритет",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun QuickActionsSection(
onShareLocation: () -> Unit,
onCallHelp: () -> Unit,
onSendSignal: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = "Быстрые действия",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
QuickActionButton(
icon = Icons.Filled.LocationOn,
text = "Поделиться местоположением",
onClick = onShareLocation
)
QuickActionButton(
icon = Icons.Filled.Phone,
text = "Вызвать помощь",
onClick = onCallHelp
)
QuickActionButton(
icon = Icons.Filled.Notifications,
text = "Отправить сигнал",
onClick = onSendSignal
)
}
}
}
}
@Composable
private fun QuickActionButton(
icon: ImageVector,
text: String,
onClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(80.dp)
) {
FilledIconButton(
onClick = onClick,
modifier = Modifier.size(56.dp)
) {
Icon(
imageVector = icon,
contentDescription = text,
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
@Composable
private fun SafetyStatusCard(uiState: Any) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.padding(20.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = "Статус безопасности",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(40.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "Статус: Безопасно",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Все системы работают нормально",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
@Composable
private fun EmergencyContactsCard(contacts: List<Any>) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Экстренные контакты",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
TextButton(onClick = { /* TODO: Navigate to contacts */ }) {
Text("Управление")
}
}
Spacer(modifier = Modifier.height(12.dp))
if (contacts.isEmpty()) {
Text(
text = "Добавьте экстренные контакты для быстрого доступа",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = "Настроено контактов: ${contacts.size}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
@Composable
private fun RecentActivityCard() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = "Последняя активность",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(12.dp))
// Placeholder для активности
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.DateRange,
contentDescription = "История",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Нет недавней активности",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun CalendarOverviewCard() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = "Календарь - краткий обзор",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(12.dp))
// Placeholder для календаря
Text(
text = "Нет предстоящих событий",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun LocationSharingDialog(
onDismiss: () -> Unit,
onConfirm: (List<String>) -> Unit
) {
var selectedContacts by remember { mutableStateOf(setOf<String>()) }
var customMessage by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Поделиться местоположением") },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Ваше текущее местоположение будет отправлено выбранным контактам",
style = MaterialTheme.typography.bodyMedium
)
// Список контактов (заглушка)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(12.dp)
) {
Text(
text = "Экстренные контакты:",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
listOf("Мама", "Служба безопасности", "Врач").forEach { contact ->
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selectedContacts.contains(contact),
onCheckedChange = { checked ->
selectedContacts = if (checked) {
selectedContacts + contact
} else {
selectedContacts - contact
}
}
)
Text(
text = contact,
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
OutlinedTextField(
value = customMessage,
onValueChange = { customMessage = it },
label = { Text("Дополнительное сообщение (необязательно)") },
modifier = Modifier.fillMaxWidth(),
maxLines = 2
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(selectedContacts.toList()) },
enabled = selectedContacts.isNotEmpty()
) {
Text("Отправить")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Отмена")
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EmergencySignalDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
var emergencyType by remember { mutableStateOf("Общая тревога") }
var customMessage by remember { mutableStateOf("") }
var includeLocation by remember { mutableStateOf(true) }
var includePhoto by remember { mutableStateOf(false) }
val emergencyTypes = listOf("Общая тревога", "Медицинская помощь", "Преследование", "ДТП", "Другое")
var expanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Экстренный сигнал") },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Сигнал будет отправлен всем вашим экстренным контактам",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
// Тип экстренной ситуации
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = emergencyType,
onValueChange = { },
readOnly = true,
label = { Text("Тип ситуации") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
emergencyTypes.forEach { type ->
DropdownMenuItem(
text = { Text(type) },
onClick = {
emergencyType = type
expanded = false
}
)
}
}
}
OutlinedTextField(
value = customMessage,
onValueChange = { customMessage = it },
label = { Text("Дополнительная информация") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
placeholder = { Text("Опишите ситуацию...") }
)
// Дополнительные опции
Column {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = includeLocation,
onCheckedChange = { includeLocation = it }
)
Text(
text = "Включить местоположение",
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = includePhoto,
onCheckedChange = { includePhoto = it }
)
Text(
text = "Сделать фото с камеры",
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
},
confirmButton = {
Button(
onClick = {
val message = buildString {
append("ЭКСТРЕННЫЙ СИГНАЛ: $emergencyType")
if (customMessage.isNotBlank()) {
append("\nДетали: $customMessage")
}
if (includeLocation) {
append("\nМестоположение: будет приложено")
}
if (includePhoto) {
append("\nФото: будет приложено")
}
}
onConfirm(message)
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("ОТПРАВИТЬ SOS")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Отмена")
}
}
)
}

View File

@@ -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() }
)
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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("Отмена")
}
}
)
}

View File

@@ -6,10 +6,12 @@ import com.example.womansafe.data.model.*
import com.example.womansafe.data.network.NetworkClient import com.example.womansafe.data.network.NetworkClient
import com.example.womansafe.data.repository.ApiRepository import com.example.womansafe.data.repository.ApiRepository
import com.google.gson.Gson import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ApiTestState( data class ApiTestState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -17,10 +19,16 @@ data class ApiTestState(
val authToken: String? = null, val authToken: String? = null,
val isAuthenticated: Boolean = false, val isAuthenticated: Boolean = false,
val emergencyContacts: List<EmergencyContactResponse> = emptyList(), val emergencyContacts: List<EmergencyContactResponse> = emptyList(),
val emergencyReports: List<EmergencyReportResponse> = emptyList(),
val emergencyAlerts: List<EmergencyAlertResponse> = emptyList(),
val calendarEntries: List<CalendarEntryResponse> = emptyList(),
val locationHistory: List<LocationResponse> = emptyList(),
val safePlaces: List<SafePlaceResponse> = emptyList(),
val notificationHistory: List<NotificationHistory> = emptyList(),
val lastApiResponse: String = "", val lastApiResponse: String = "",
val lastApiError: String = "", val lastApiError: String = "",
val selectedEndpoint: String = "", val selectedEndpoint: String = "",
val baseUrl: String = "http://10.0.2.2:8000/" val baseUrl: String = "http://192.168.0.103:8000/"
) )
class ApiTestViewModel : ViewModel() { class ApiTestViewModel : ViewModel() {
@@ -55,6 +63,8 @@ class ApiTestViewModel : ViewModel() {
lastApiResponse = "Login successful! Token: ${it.accessToken.take(20)}...", lastApiResponse = "Login successful! Token: ${it.accessToken.take(20)}...",
isLoading = false isLoading = false
) )
// Запрос профиля сразу после авторизации
getCurrentUser()
} }
} else { } else {
val errorBody = response.errorBody()?.string() ?: "Unknown error" val errorBody = response.errorBody()?.string() ?: "Unknown error"
@@ -87,9 +97,10 @@ class ApiTestViewModel : ViewModel() {
val response = repository.register(email, username, password, fullName, phoneNumber) val response = repository.register(email, username, password, fullName, phoneNumber)
if (response.isSuccessful) { if (response.isSuccessful) {
val user = response.body() val user = response.body()
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
_state.value = _state.value.copy( _state.value = _state.value.copy(
currentUser = user, currentUser = user,
lastApiResponse = gson.toJson(user), lastApiResponse = userJson,
isLoading = false isLoading = false
) )
} else { } else {
@@ -123,9 +134,10 @@ class ApiTestViewModel : ViewModel() {
val response = repository.getCurrentUser() val response = repository.getCurrentUser()
if (response.isSuccessful) { if (response.isSuccessful) {
val user = response.body() val user = response.body()
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
_state.value = _state.value.copy( _state.value = _state.value.copy(
currentUser = user, currentUser = user,
lastApiResponse = gson.toJson(user), lastApiResponse = userJson,
isLoading = false isLoading = false
) )
} else { } else {
@@ -159,8 +171,9 @@ class ApiTestViewModel : ViewModel() {
val response = repository.getDashboard() val response = repository.getDashboard()
if (response.isSuccessful) { if (response.isSuccessful) {
val dashboard = response.body() val dashboard = response.body()
val dashboardJson = withContext(Dispatchers.Default) { gson.toJson(dashboard) }
_state.value = _state.value.copy( _state.value = _state.value.copy(
lastApiResponse = gson.toJson(dashboard), lastApiResponse = dashboardJson,
isLoading = false isLoading = false
) )
} else { } else {
@@ -194,9 +207,10 @@ class ApiTestViewModel : ViewModel() {
val response = repository.getEmergencyContacts() val response = repository.getEmergencyContacts()
if (response.isSuccessful) { if (response.isSuccessful) {
val contacts = response.body() ?: emptyList() val contacts = response.body() ?: emptyList()
val contactsJson = withContext(Dispatchers.Default) { gson.toJson(contacts) }
_state.value = _state.value.copy( _state.value = _state.value.copy(
emergencyContacts = contacts, emergencyContacts = contacts,
lastApiResponse = gson.toJson(contacts), lastApiResponse = contactsJson,
isLoading = false isLoading = false
) )
} else { } else {
@@ -231,8 +245,9 @@ class ApiTestViewModel : ViewModel() {
val response = repository.createEmergencyContact(contact) val response = repository.createEmergencyContact(contact)
if (response.isSuccessful) { if (response.isSuccessful) {
val createdContact = response.body() val createdContact = response.body()
val contactJson = withContext(Dispatchers.Default) { gson.toJson(createdContact) }
_state.value = _state.value.copy( _state.value = _state.value.copy(
lastApiResponse = gson.toJson(createdContact), lastApiResponse = contactJson,
isLoading = false isLoading = false
) )
// Refresh the contacts list // Refresh the contacts list
@@ -255,6 +270,368 @@ class ApiTestViewModel : ViewModel() {
} }
} }
fun updateUser(
firstName: String? = null,
lastName: String? = null,
phone: String? = null,
dateOfBirth: String? = null,
bio: String? = null,
avatarUrl: String? = null,
emergencyContact1Name: String? = null,
emergencyContact1Phone: String? = null,
emergencyContact2Name: String? = null,
emergencyContact2Phone: String? = null,
locationSharingEnabled: Boolean? = null,
emergencyNotificationsEnabled: Boolean? = null,
pushNotificationsEnabled: Boolean? = null
) {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "PUT /api/v1/users/me",
lastApiError = "",
lastApiResponse = ""
)
try {
val userUpdate = UserUpdate(
first_name = firstName,
last_name = lastName,
phone = phone,
date_of_birth = dateOfBirth,
bio = bio,
avatar_url = avatarUrl,
emergency_contact_1_name = emergencyContact1Name,
emergency_contact_1_phone = emergencyContact1Phone,
emergency_contact_2_name = emergencyContact2Name,
emergency_contact_2_phone = emergencyContact2Phone,
location_sharing_enabled = locationSharingEnabled,
emergency_notifications_enabled = emergencyNotificationsEnabled,
push_notifications_enabled = pushNotificationsEnabled
)
val response = repository.updateCurrentUser(userUpdate)
if (response.isSuccessful) {
val user = response.body()
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
_state.value = _state.value.copy(
currentUser = user,
lastApiResponse = userJson,
isLoading = false
)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
_state.value = _state.value.copy(
lastApiError = "Error ${response.code()}: $errorBody",
lastApiResponse = "",
isLoading = false
)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
lastApiError = "Network error: ${e.message}",
lastApiResponse = "",
isLoading = false
)
}
}
}
fun updateEmergencyContact(contactId: Int, name: String?, phoneNumber: String?, relationship: String?, notes: String?) {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "PATCH /api/v1/users/me/emergency-contacts/$contactId",
lastApiError = "",
lastApiResponse = ""
)
try {
val contactUpdate = EmergencyContactUpdate(name, phoneNumber, relationship, notes)
val response = repository.updateEmergencyContact(contactId, contactUpdate)
if (response.isSuccessful) {
val contact = response.body()
val contactJson = withContext(Dispatchers.Default) { gson.toJson(contact) }
_state.value = _state.value.copy(
lastApiResponse = contactJson,
isLoading = false
)
// Refresh contacts
getEmergencyContacts()
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
_state.value = _state.value.copy(
lastApiError = "Error ${response.code()}: $errorBody",
lastApiResponse = "",
isLoading = false
)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
lastApiError = "Network error: ${e.message}",
lastApiResponse = "",
isLoading = false
)
}
}
}
fun deleteEmergencyContact(contactId: Int) {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "DELETE /api/v1/users/me/emergency-contacts/$contactId",
lastApiError = "",
lastApiResponse = ""
)
try {
val response = repository.deleteEmergencyContact(contactId)
if (response.isSuccessful) {
val message = response.body()
val messageJson = withContext(Dispatchers.Default) { gson.toJson(message) }
_state.value = _state.value.copy(
lastApiResponse = messageJson,
isLoading = false
)
// Refresh contacts
getEmergencyContacts()
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
_state.value = _state.value.copy(
lastApiError = "Error ${response.code()}: $errorBody",
lastApiResponse = "",
isLoading = false
)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
lastApiError = "Network error: ${e.message}",
lastApiResponse = "",
isLoading = false
)
}
}
}
fun getEmergencyReports() {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "GET /api/v1/emergency/reports",
lastApiError = "",
lastApiResponse = ""
)
try {
val response = repository.getEmergencyReports()
if (response.isSuccessful) {
val reports = response.body()
val reportsJson = withContext(Dispatchers.Default) { gson.toJson(reports) }
_state.value = _state.value.copy(
lastApiResponse = reportsJson,
isLoading = false
)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
_state.value = _state.value.copy(
lastApiError = "Error ${response.code()}: $errorBody",
lastApiResponse = "",
isLoading = false
)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
lastApiError = "Network error: ${e.message}",
lastApiResponse = "",
isLoading = false
)
}
}
}
fun getMyEmergencyAlerts() {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "GET /api/v1/emergency/alerts/my",
lastApiError = "",
lastApiResponse = ""
)
try {
val response = repository.getMyEmergencyAlerts()
if (response.isSuccessful) {
val alerts = response.body()
val alertsJson = withContext(Dispatchers.Default) { gson.toJson(alerts) }
_state.value = _state.value.copy(
lastApiResponse = alertsJson,
isLoading = false
)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
_state.value = _state.value.copy(
lastApiError = "Error ${response.code()}: $errorBody",
lastApiResponse = "",
isLoading = false
)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
lastApiError = "Network error: ${e.message}",
lastApiResponse = "",
isLoading = false
)
}
}
}
fun getLocationHistory() {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "GET /api/v1/locations/history",
lastApiError = "",
lastApiResponse = ""
)
try {
val response = repository.getLocationHistory()
if (response.isSuccessful) {
val history = response.body()
val historyJson = withContext(Dispatchers.Default) { gson.toJson(history) }
_state.value = _state.value.copy(
lastApiResponse = historyJson,
isLoading = false
)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
_state.value = _state.value.copy(
lastApiError = "Error ${response.code()}: $errorBody",
lastApiResponse = "",
isLoading = false
)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
lastApiError = "Network error: ${e.message}",
lastApiResponse = "",
isLoading = false
)
}
}
}
fun getSafePlaces() {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "GET /api/v1/locations/safe-places",
lastApiError = "",
lastApiResponse = ""
)
try {
val response = repository.getSafePlaces()
if (response.isSuccessful) {
val places = response.body()
val placesJson = withContext(Dispatchers.Default) { gson.toJson(places) }
_state.value = _state.value.copy(
lastApiResponse = placesJson,
isLoading = false
)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
_state.value = _state.value.copy(
lastApiError = "Error ${response.code()}: $errorBody",
lastApiResponse = "",
isLoading = false
)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
lastApiError = "Network error: ${e.message}",
lastApiResponse = "",
isLoading = false
)
}
}
}
fun getCalendarEntries() {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "GET /api/v1/calendar/entries",
lastApiError = "",
lastApiResponse = ""
)
try {
val response = repository.getCalendarEntries()
if (response.isSuccessful) {
val entries = response.body()
val entriesJson = withContext(Dispatchers.Default) { gson.toJson(entries) }
_state.value = _state.value.copy(
lastApiResponse = entriesJson,
isLoading = false
)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
_state.value = _state.value.copy(
lastApiError = "Error ${response.code()}: $errorBody",
lastApiResponse = "",
isLoading = false
)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
lastApiError = "Network error: ${e.message}",
lastApiResponse = "",
isLoading = false
)
}
}
}
fun getNotificationHistory() {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "GET /api/v1/notifications/history",
lastApiError = "",
lastApiResponse = ""
)
try {
val response = repository.getNotificationHistory()
if (response.isSuccessful) {
val history = response.body()
val historyJson = withContext(Dispatchers.Default) { gson.toJson(history) }
_state.value = _state.value.copy(
lastApiResponse = historyJson,
isLoading = false
)
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
_state.value = _state.value.copy(
lastApiError = "Error ${response.code()}: $errorBody",
lastApiResponse = "",
isLoading = false
)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
lastApiError = "Network error: ${e.message}",
lastApiResponse = "",
isLoading = false
)
}
}
}
fun testGenericEndpoint(endpoint: String, method: String) { fun testGenericEndpoint(endpoint: String, method: String) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy( _state.value = _state.value.copy(
@@ -272,10 +649,20 @@ class ApiTestViewModel : ViewModel() {
"/api/v1/users/dashboard" -> repository.getDashboard() "/api/v1/users/dashboard" -> repository.getDashboard()
"/api/v1/emergency/reports" -> repository.getEmergencyReports() "/api/v1/emergency/reports" -> repository.getEmergencyReports()
"/api/v1/emergency/alerts" -> repository.getEmergencyAlerts() "/api/v1/emergency/alerts" -> repository.getEmergencyAlerts()
"/api/v1/emergency/alerts/my" -> repository.getMyEmergencyAlerts()
"/api/v1/emergency/alerts/nearby" -> repository.getNearbyEmergencyAlerts()
"/api/v1/locations/last" -> repository.getLastLocation() "/api/v1/locations/last" -> repository.getLastLocation()
"/api/v1/locations/history" -> repository.getLocationHistory() "/api/v1/locations/history" -> repository.getLocationHistory()
"/api/v1/locations/safe-places" -> repository.getSafePlaces()
"/api/v1/locations/users/nearby" -> repository.getNearbyUsers()
"/api/v1/calendar/entries" -> repository.getCalendarEntries() "/api/v1/calendar/entries" -> repository.getCalendarEntries()
"/api/v1/calendar/cycle-overview" -> repository.getCycleOverview()
"/api/v1/calendar/insights" -> repository.getCalendarInsights()
"/api/v1/calendar/reminders" -> repository.getCalendarReminders()
"/api/v1/calendar/settings" -> repository.getCalendarSettings()
"/api/v1/notifications/preferences" -> repository.getNotificationPreferences() "/api/v1/notifications/preferences" -> repository.getNotificationPreferences()
"/api/v1/notifications/devices" -> repository.getNotificationDevices()
"/api/v1/notifications/history" -> repository.getNotificationHistory()
else -> { else -> {
_state.value = _state.value.copy( _state.value = _state.value.copy(
lastApiError = "Endpoint not implemented in this test app", lastApiError = "Endpoint not implemented in this test app",
@@ -287,8 +674,9 @@ class ApiTestViewModel : ViewModel() {
if (response.isSuccessful) { if (response.isSuccessful) {
val body = response.body() val body = response.body()
val bodyJson = withContext(Dispatchers.Default) { gson.toJson(body) }
_state.value = _state.value.copy( _state.value = _state.value.copy(
lastApiResponse = gson.toJson(body), lastApiResponse = bodyJson,
isLoading = false isLoading = false
) )
} else { } else {

View File

@@ -0,0 +1,203 @@
package com.example.womansafe.ui.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.womansafe.data.model.*
import com.example.womansafe.data.repository.ApiRepository
import com.example.womansafe.data.network.NetworkClient
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
class AuthViewModel : ViewModel() {
private val repository = ApiRepository()
var uiState by mutableStateOf(AuthUiState())
private set
fun login(usernameOrEmail: String, password: String) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, error = null)
try {
// Определяем, что введено - email или username
val isEmail = usernameOrEmail.contains("@")
val response = repository.login(
email = if (isEmail) usernameOrEmail else null,
username = if (!isEmail) usernameOrEmail else null,
password = password
)
if (response.isSuccessful) {
val token = response.body()
token?.let {
NetworkClient.setAuthToken(it.accessToken)
uiState = uiState.copy(
isLoading = false,
isLoggedIn = true,
token = it.accessToken,
tokenType = it.tokenType
)
// Получаем профиль пользователя сразу после успешного входа
getCurrentUser()
}
} else {
uiState = uiState.copy(
isLoading = false,
error = "Ошибка авторизации: ${response.code()} - ${response.message()}"
)
}
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
error = "Ошибка сети: ${e.message}"
)
}
}
}
fun register(username: String, email: String, password: String, fullName: String?, phoneNumber: String?) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, error = null)
try {
val response = repository.register(
email = email,
username = username,
password = password,
fullName = fullName,
phoneNumber = phoneNumber
)
if (response.isSuccessful) {
val userResponse = response.body()
userResponse?.let {
uiState = uiState.copy(
isLoading = false,
isLoggedIn = true,
user = it,
registrationSuccess = true
)
}
} else {
uiState = uiState.copy(
isLoading = false,
error = "Ошибка регистрации: ${response.code()} - ${response.message()}"
)
}
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
error = "Ошибка сети: ${e.message}"
)
}
}
}
private fun getCurrentUser() {
viewModelScope.launch {
try {
println("=== Начинаем загрузку данных пользователя ===")
// Загружаем основную информацию пользователя
println("Отправляем запрос getCurrentUser...")
val userResponse = repository.getCurrentUser()
println("getCurrentUser ответ: ${userResponse.code()}, успешно: ${userResponse.isSuccessful}")
if (userResponse.isSuccessful) {
val user = userResponse.body()
println("Пользователь получен: ${user?.username}")
// Параллельно загружаем экстренные контакты и дашборд
val emergencyContactsDeferred = async {
try {
println("Отправляем запрос getEmergencyContacts...")
val response = repository.getEmergencyContacts()
println("getEmergencyContacts ответ: ${response.code()}")
response
} catch (e: Exception) {
println("Ошибка getEmergencyContacts: ${e.message}")
null
}
}
val dashboardDeferred = async {
try {
println("Отправляем запрос getDashboard...")
val response = repository.getDashboard()
println("getDashboard ответ: ${response.code()}")
response
} catch (e: Exception) {
println("Ошибка getDashboard: ${e.message}")
null
}
}
val profileDeferred = async {
try {
println("Отправляем запрос getUserProfile...")
val response = repository.getUserProfile()
println("getUserProfile ответ: ${response.code()}")
response
} catch (e: Exception) {
println("Ошибка getUserProfile: ${e.message}")
null
}
}
// Ждём результаты дополнительных запросов
val emergencyContacts = emergencyContactsDeferred.await()
val dashboard = dashboardDeferred.await()
val profile = profileDeferred.await()
println("Завершены все запросы, обновляем UI state...")
uiState = uiState.copy(
user = user,
emergencyContacts = emergencyContacts?.takeIf { it.isSuccessful }?.body() ?: emptyList(),
dashboard = dashboard?.takeIf { it.isSuccessful }?.body(),
profileExtended = profile?.takeIf { it.isSuccessful }?.body(),
profileLoaded = true,
isLoading = false
)
} else {
println("Ошибка getCurrentUser: ${userResponse.code()} - ${userResponse.message()}")
uiState = uiState.copy(
profileLoaded = true,
isLoading = false,
error = "Не удалось загрузить профиль: ${userResponse.code()}"
)
}
} catch (e: Exception) {
println("Исключение в getCurrentUser: ${e.message}")
e.printStackTrace()
uiState = uiState.copy(
profileLoaded = true,
isLoading = false,
error = "Ошибка загрузки профиля: ${e.message}"
)
}
}
}
fun logout() {
NetworkClient.setAuthToken(null)
uiState = AuthUiState()
}
fun clearError() {
uiState = uiState.copy(error = null)
}
}
data class AuthUiState(
val isLoading: Boolean = false,
val isLoggedIn: Boolean = false,
val user: UserResponse? = null,
val emergencyContacts: List<EmergencyContactResponse>? = null,
val dashboard: Any? = null,
val profileExtended: UserResponse? = null,
val token: String? = null,
val tokenType: String? = null,
val registrationSuccess: Boolean = false,
val profileLoaded: Boolean = false,
val error: String? = null
)

View File

@@ -0,0 +1,391 @@
package com.example.womansafe.ui.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.womansafe.data.model.*
import com.example.womansafe.data.repository.ApiRepository
import kotlinx.coroutines.*
import java.time.LocalDate
import java.time.temporal.ChronoUnit
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToInt
data class CalendarUiState(
val isLoading: Boolean = false,
val currentMonth: LocalDate = LocalDate.now(),
val selectedDate: LocalDate = LocalDate.now(),
val events: Map<LocalDate, List<CalendarEvent>> = emptyMap(),
val predictions: CyclePrediction? = null,
val settings: CycleSettings = CycleSettings(),
val statistics: CycleStatistics? = null,
val showEventDialog: Boolean = false,
val showSettingsDialog: Boolean = false,
val error: String? = null,
val lastRefreshed: Long = 0 // Время последнего успешного обновления данных
)
class CalendarViewModel : ViewModel() {
private val repository = ApiRepository()
var uiState by mutableStateOf(CalendarUiState())
private set
private var loadJob: Job? = null
private var retryCount = 0
private val maxRetryCount = 5
private val cacheValidityDuration = 10 * 60 * 1000 // 10 минут в миллисекундах
private var debounceJob: Job? = null
init {
loadCalendarData()
loadCycleSettings()
}
fun loadCalendarData() {
// Если данные были обновлены недавно и это не первая загрузка, не делаем запрос
if (uiState.events.isNotEmpty() &&
System.currentTimeMillis() - uiState.lastRefreshed < cacheValidityDuration) {
return
}
// Отменяем предыдущий запрос, если он еще выполняется
loadJob?.cancel()
loadJob = viewModelScope.launch {
uiState = uiState.copy(isLoading = true, error = null)
try {
// Загружаем события календаря
val response = repository.getCalendarEntries()
if (response.isSuccessful) {
// Сбрасываем счетчик повторных попыток при успехе
retryCount = 0
// TODO: Преобразовать ответ API в события календаря
generatePredictions()
calculateStatistics()
// Обновляем время последнего успешного запроса
uiState = uiState.copy(
isLoading = false,
lastRefreshed = System.currentTimeMillis()
)
} else if (response.code() == 429) {
// Обработка Too Many Requests с экспоненциальным откатом
handleRateLimitExceeded()
} else {
uiState = uiState.copy(
isLoading = false,
error = "Ошибка загрузки данных календаря: ${response.code()}"
)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
uiState = uiState.copy(
isLoading = false,
error = "Ошибка сети: ${e.message}"
)
}
}
}
private suspend fun handleRateLimitExceeded() {
if (retryCount < maxRetryCount) {
retryCount++
// Экспоненциальная отсрочка: 2^попытка * 1000 мс (1с, 2с, 4с, 8с, 16с)
val delayTime = (2.0.pow(retryCount.toDouble()) * 1000).toLong()
uiState = uiState.copy(
error = "Слишком много запросов. Повторная попытка через ${delayTime/1000} сек..."
)
delay(delayTime)
loadCalendarData() // Повторная попытка после задержки
} else {
uiState = uiState.copy(
isLoading = false,
error = "Превышен лимит запросов. Попробуйте позже."
)
}
}
fun selectDate(date: LocalDate) {
uiState = uiState.copy(selectedDate = date)
}
fun changeMonth(direction: Int) {
val newMonth = uiState.currentMonth.plusMonths(direction.toLong())
uiState = uiState.copy(currentMonth = newMonth)
}
fun addEvent(
date: LocalDate,
type: CalendarEventType,
mood: MoodType? = null,
symptoms: List<SymptomType> = emptyList(),
notes: String = "",
flowIntensity: Int? = null
) {
viewModelScope.launch {
val event = CalendarEvent(
date = date,
type = type,
mood = mood,
symptoms = symptoms,
notes = notes,
flowIntensity = flowIntensity
)
// Добавляем событие локально
val currentEvents = uiState.events.toMutableMap()
val dateEvents = currentEvents[date]?.toMutableList() ?: mutableListOf()
dateEvents.add(event)
currentEvents[date] = dateEvents
uiState = uiState.copy(events = currentEvents)
// Если это начало менструации, обновляем настройки и прогнозы
if (type == CalendarEventType.MENSTRUATION) {
updateLastPeriodDate(date)
}
// Пересчитываем прогнозы и статистику
generatePredictions()
calculateStatistics()
// Сохраняем на сервер с дебаунсингом
debounceSaveEvent(event)
}
}
// Дебаунсинг для сохранения событий
private fun debounceSaveEvent(event: CalendarEvent) {
debounceJob?.cancel()
debounceJob = viewModelScope.launch {
delay(300) // Дебаунс 300 мс
saveEventToServer(event)
}
}
fun removeEvent(date: LocalDate, eventType: CalendarEventType) {
val currentEvents = uiState.events.toMutableMap()
val dateEvents = currentEvents[date]?.toMutableList()
dateEvents?.removeAll { it.type == eventType }
if (dateEvents.isNullOrEmpty()) {
currentEvents.remove(date)
} else {
currentEvents[date] = dateEvents
}
uiState = uiState.copy(events = currentEvents)
// Пересчитываем прогнозы
generatePredictions()
calculateStatistics()
}
fun showEventDialog() {
uiState = uiState.copy(showEventDialog = true)
}
fun hideEventDialog() {
uiState = uiState.copy(showEventDialog = false)
}
fun showSettingsDialog() {
uiState = uiState.copy(showSettingsDialog = true)
}
fun hideSettingsDialog() {
uiState = uiState.copy(showSettingsDialog = false)
}
fun updateCycleSettings(settings: CycleSettings) {
uiState = uiState.copy(settings = settings)
generatePredictions()
// TODO: Сохранить настройки на сервер с дебаунсингом
debounceSaveSettings(settings)
}
private fun debounceSaveSettings(settings: CycleSettings) {
debounceJob?.cancel()
debounceJob = viewModelScope.launch {
delay(300) // Дебаунс 300 мс
saveCycleSettings(settings)
}
}
private fun updateLastPeriodDate(date: LocalDate) {
val updatedSettings = uiState.settings.copy(lastPeriodStart = date)
uiState = uiState.copy(settings = updatedSettings)
}
private fun loadCycleSettings() {
// TODO: Загрузить настройки с сервера
// Пока используем настройки по умолчанию
}
private fun generatePredictions() {
val settings = uiState.settings
val lastPeriodStart = settings.lastPeriodStart ?: return
// Прогнозируем следующую менструацию
val nextPeriodStart = lastPeriodStart.plusDays(settings.averageCycleLength.toLong())
val nextPeriodEnd = nextPeriodStart.plusDays(settings.averagePeriodLength.toLong() - 1)
// Прогнозируем овуляцию (примерно за 14 дней до следующих месячных)
val nextOvulation = nextPeriodStart.minusDays(14)
// Окно фертильности (5 дней до овуляции + день овуляции)
val fertileWindowStart = nextOvulation.minusDays(4)
val fertileWindowEnd = nextOvulation.plusDays(1)
val prediction = CyclePrediction(
nextPeriodStart = nextPeriodStart,
nextPeriodEnd = nextPeriodEnd,
nextOvulation = nextOvulation,
fertileWindowStart = fertileWindowStart,
fertileWindowEnd = fertileWindowEnd
)
uiState = uiState.copy(predictions = prediction)
// Добавляем прогнозные события в календарь
addPredictedEvents(prediction)
}
private fun addPredictedEvents(prediction: CyclePrediction) {
val currentEvents = uiState.events.toMutableMap()
// Удаляем старые прогнозы
currentEvents.forEach { (date, events) ->
currentEvents[date] = events.filter { it.isActual }
}
// Добавляем новые прогнозы
val predictedEvents = mutableListOf<Pair<LocalDate, CalendarEvent>>()
// Прогноз месячных
var date = prediction.nextPeriodStart
while (!date.isAfter(prediction.nextPeriodEnd)) {
predictedEvents.add(
date to CalendarEvent(
date = date,
type = CalendarEventType.PREDICTED_MENSTRUATION,
isActual = false
)
)
date = date.plusDays(1)
}
// Прогноз овуляции
predictedEvents.add(
prediction.nextOvulation to CalendarEvent(
date = prediction.nextOvulation,
type = CalendarEventType.PREDICTED_OVULATION,
isActual = false
)
)
// Добавляем прогнозы в календарь
predictedEvents.forEach { (eventDate, event) ->
val dateEvents = currentEvents[eventDate]?.toMutableList() ?: mutableListOf()
dateEvents.add(event)
currentEvents[eventDate] = dateEvents
}
uiState = uiState.copy(events = currentEvents)
}
private fun calculateStatistics() {
val menstruationEvents = uiState.events.values.flatten()
.filter { it.type == CalendarEventType.MENSTRUATION && it.isActual }
.sortedBy { it.date }
if (menstruationEvents.size < 2) return
// Вычисляем длины циклов
val cycleLengths = mutableListOf<Int>()
for (i in 1 until menstruationEvents.size) {
val cycleLength = ChronoUnit.DAYS.between(
menstruationEvents[i-1].date,
menstruationEvents[i].date
).toInt()
cycleLengths.add(cycleLength)
}
if (cycleLengths.isEmpty()) return
val averageLength = cycleLengths.average().toFloat()
val variation = cycleLengths.map { abs(it - averageLength) }.average().toFloat()
// Собираем частые симптомы
val allSymptoms = uiState.events.values.flatten()
.flatMap { it.symptoms }
val symptomFrequency = allSymptoms.groupingBy { it }.eachCount()
val commonSymptoms = symptomFrequency.toList()
.sortedByDescending { it.second }
.take(5)
.map { it.first }
val statistics = CycleStatistics(
averageCycleLength = averageLength,
cycleVariation = variation,
lastCycles = cycleLengths.takeLast(6),
periodLengthAverage = uiState.settings.averagePeriodLength.toFloat(),
commonSymptoms = commonSymptoms,
moodPatterns = emptyMap() // TODO: Вычислить паттерны настроения
)
uiState = uiState.copy(statistics = statistics)
// Обновляем настройки на основе статистики
if (cycleLengths.size >= 3) {
val newSettings = uiState.settings.copy(
averageCycleLength = averageLength.roundToInt()
)
uiState = uiState.copy(settings = newSettings)
}
}
private suspend fun saveEventToServer(event: CalendarEvent) {
try {
// TODO: Реализовать сохранение на сервер через API
} catch (e: Exception) {
uiState = uiState.copy(error = "Ошибка сохранения: ${e.message}")
}
}
private suspend fun saveCycleSettings(settings: CycleSettings) {
try {
// TODO: Реализовать сохранение настроек на сервер через API
} catch (e: Exception) {
uiState = uiState.copy(error = "Ошибка сохранения настроек: ${e.message}")
}
}
// Очистка ошибки
fun clearError() {
uiState = uiState.copy(error = null)
}
// Принудительное обновление данных
fun forceRefresh() {
uiState = uiState.copy(lastRefreshed = 0)
retryCount = 0 // Сбрасываем счетчик попыток
loadCalendarData()
}
override fun onCleared() {
super.onCleared()
loadJob?.cancel()
debounceJob?.cancel()
}
}

View File

@@ -0,0 +1,122 @@
package com.example.womansafe.ui.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.womansafe.data.model.EmergencyContactCreate
import com.example.womansafe.data.model.EmergencyContactResponse
import com.example.womansafe.data.model.EmergencyContactUpdate
import com.example.womansafe.data.repository.ApiRepository
import kotlinx.coroutines.launch
class EmergencyContactsViewModel : ViewModel() {
private val repository = ApiRepository()
var uiState by mutableStateOf(EmergencyContactsUiState())
private set
fun loadContacts() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, error = null)
try {
val response = repository.getEmergencyContacts()
if (response.isSuccessful) {
uiState = uiState.copy(
contacts = response.body() ?: emptyList(),
isLoading = false
)
} else {
uiState = uiState.copy(
isLoading = false,
error = "Ошибка загрузки контактов: ${response.code()}"
)
}
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
error = "Ошибка сети: ${e.message}"
)
}
}
}
fun addContact(contact: EmergencyContactCreate) {
viewModelScope.launch {
try {
val response = repository.createEmergencyContact(contact)
if (response.isSuccessful) {
loadContacts() // Перезагружаем список
} else {
uiState = uiState.copy(
error = "Ошибка добавления контакта: ${response.code()}"
)
}
} catch (e: Exception) {
uiState = uiState.copy(
error = "Ошибка добавления контакта: ${e.message}"
)
}
}
}
fun editContact(contact: EmergencyContactResponse) {
viewModelScope.launch {
try {
val update = EmergencyContactUpdate(
name = contact.name,
phone_number = contact.phone_number,
relationship = contact.relationship
)
val response = repository.updateEmergencyContact(contact.id, update)
if (response.isSuccessful) {
loadContacts() // Перезагружаем список
} else {
uiState = uiState.copy(
error = "Ошибка редактирования контакта: ${response.code()}"
)
}
} catch (e: Exception) {
uiState = uiState.copy(
error = "Ошибка редактирования контакта: ${e.message}"
)
}
}
}
fun deleteContact(contactId: Int) {
viewModelScope.launch {
try {
val response = repository.deleteEmergencyContact(contactId)
if (response.isSuccessful) {
loadContacts() // Перезагружаем список
} else {
uiState = uiState.copy(
error = "Ошибка удаления контакта: ${response.code()}"
)
}
} catch (e: Exception) {
uiState = uiState.copy(
error = "Ошибка удаления контакта: ${e.message}"
)
}
}
}
fun callContact(phoneNumber: String) {
// В реальном приложении здесь будет интент для звонка
// Пока просто логируем
println("Calling: $phoneNumber")
}
fun clearError() {
uiState = uiState.copy(error = null)
}
}
data class EmergencyContactsUiState(
val contacts: List<EmergencyContactResponse> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)

View File

@@ -0,0 +1,227 @@
package com.example.womansafe.ui.viewmodel
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Address
import android.location.Geocoder
import android.location.Location
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.womansafe.data.api.WomanSafeApi
import com.example.womansafe.data.model.*
import com.example.womansafe.data.network.RetrofitClient
import com.google.android.gms.location.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
data class EmergencyUiState(
val isLoading: Boolean = false,
val currentLocation: UserLocation? = null,
val emergencyContacts: List<EmergencyContactResponse> = emptyList(),
val isEmergencyActive: Boolean = false,
val errorMessage: String? = null,
val hasLocationPermission: Boolean = false,
val locationPermissionRequested: Boolean = false
)
class EmergencyViewModel : ViewModel() {
private val api: WomanSafeApi = RetrofitClient.api
private val _uiState = MutableStateFlow(EmergencyUiState())
val uiState: StateFlow<EmergencyUiState> = _uiState.asStateFlow()
private lateinit var fusedLocationClient: FusedLocationProviderClient
fun initLocationClient(context: Context) {
fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
checkLocationPermission(context)
}
private fun checkLocationPermission(context: Context) {
val hasPermission = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
_uiState.value = _uiState.value.copy(hasLocationPermission = hasPermission)
}
fun onLocationPermissionResult(granted: Boolean) {
_uiState.value = _uiState.value.copy(
hasLocationPermission = granted,
locationPermissionRequested = true
)
}
suspend fun getCurrentLocation(context: Context): UserLocation? {
return suspendCancellableCoroutine { continuation ->
try {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
continuation.resume(null)
return@suspendCancellableCoroutine
}
fusedLocationClient.lastLocation
.addOnSuccessListener { location: Location? ->
if (location != null) {
val address = getAddressFromLocation(context, location.latitude, location.longitude)
val userLocation = UserLocation(
latitude = location.latitude,
longitude = location.longitude,
accuracy = location.accuracy,
address = address
)
_uiState.value = _uiState.value.copy(currentLocation = userLocation)
continuation.resume(userLocation)
} else {
continuation.resume(null)
}
}
.addOnFailureListener { exception ->
continuation.resumeWithException(exception)
}
} catch (e: Exception) {
continuation.resumeWithException(e)
}
}
}
private fun getAddressFromLocation(context: Context, latitude: Double, longitude: Double): String? {
return try {
val geocoder = Geocoder(context, Locale.getDefault())
val addresses: List<Address> = geocoder.getFromLocation(latitude, longitude, 1) ?: emptyList()
if (addresses.isNotEmpty()) {
val address = addresses[0]
"${address.getAddressLine(0)}"
} else {
null
}
} catch (e: Exception) {
null
}
}
fun loadEmergencyContacts() {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val response = api.getEmergencyContacts()
if (response.isSuccessful) {
_uiState.value = _uiState.value.copy(
emergencyContacts = response.body() ?: emptyList(),
isLoading = false
)
} else {
val errorMsg = when (response.code()) {
401 -> "Необходима авторизация. Пожалуйста, войдите снова."
403 -> "У вас нет доступа к этому ресурсу."
404 -> "Список контактов не найден."
500, 502, 503 -> "Ошибка сервера. Пожалуйста, попробуйте позже."
else -> "Не удалось загрузить контакты: код ${response.code()}"
}
_uiState.value = _uiState.value.copy(
errorMessage = errorMsg,
isLoading = false
)
}
} catch (e: Exception) {
val errorMsg = when {
e.message?.contains("Unable to resolve host") == true -> "Нет соединения с сервером. Проверьте подключение к интернету."
e.message?.contains("timeout") == true -> "Время ожидания истекло. Проверьте подключение к интернету."
else -> "Ошибка загрузки контактов: ${e.message ?: "неизвестная ошибка"}"
}
_uiState.value = _uiState.value.copy(
errorMessage = errorMsg,
isLoading = false
)
}
}
}
fun createEmergencyAlert(
context: Context,
type: EmergencyType,
description: String? = null,
isAnonymous: Boolean = false
) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
// Получаем текущее местоположение
val location = getCurrentLocation(context)
if (location == null) {
_uiState.value = _uiState.value.copy(
errorMessage = "Не удалось получить местоположение",
isLoading = false
)
return@launch
}
// Создаем запрос используя обновленную модель из ApiModels.kt
val request = EmergencyAlertCreate(
type = type.name.lowercase(),
description = description,
latitude = location.latitude,
longitude = location.longitude,
address = location.address,
is_anonymous = isAnonymous
)
// Отправляем на сервер
val response = api.createEmergencyAlert(request)
if (response.isSuccessful) {
_uiState.value = _uiState.value.copy(
isEmergencyActive = true,
isLoading = false,
errorMessage = null
)
} else {
_uiState.value = _uiState.value.copy(
errorMessage = "Не удалось создать экстренное событие: ${response.code()}",
isLoading = false
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
errorMessage = "Ошибка создания экстренного события: ${e.message}",
isLoading = false
)
}
}
}
fun cancelEmergencyAlert() {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
// Здесь можно добавить API вызов для отмены экстренного события
_uiState.value = _uiState.value.copy(
isEmergencyActive = false,
isLoading = false
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
errorMessage = "Ошибка отмены экстренного события: ${e.message}",
isLoading = false
)
}
}
}
fun clearError() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
}

View File

@@ -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}"
)
}
}
}
}

View File

@@ -0,0 +1,130 @@
package com.example.womansafe.util
import android.os.Build
import java.text.ParseException
import java.text.SimpleDateFormat
import java.time.LocalDate
import java.time.Period
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.*
import java.util.concurrent.TimeUnit
/**
* Утилитарный класс для работы с датами на устройствах с разными версиями API
*/
object DateUtils {
/**
* Форматирует LocalDate в строку с учетом API устройства
*/
fun formatDate(date: LocalDate?, pattern: String): String {
date ?: return ""
// Для устройств с API 26+ используем Java 8 Time API
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val formatter = DateTimeFormatter.ofPattern(pattern)
date.format(formatter)
} else {
// Для более старых устройств используем легаси API
val calendar = Calendar.getInstance()
calendar.set(date.year, date.monthValue - 1, date.dayOfMonth)
val format = SimpleDateFormat(pattern, Locale.getDefault())
format.format(calendar.time)
}
}
/**
* Проверяет, являются ли две даты одним и тем же днем
*/
fun isSameDay(date1: LocalDate, date2: LocalDate): Boolean {
return date1.year == date2.year &&
date1.monthValue == date2.monthValue &&
date1.dayOfMonth == date2.dayOfMonth
}
/**
* Парсит строку в LocalDate с учетом API устройства
* @param dateStr строка с датой
* @param pattern шаблон формата даты
* @return LocalDate или null, если парсинг не удался
*/
fun parseDate(dateStr: String?, pattern: String): LocalDate? {
dateStr ?: return null
if (dateStr.isBlank()) return null
try {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val formatter = DateTimeFormatter.ofPattern(pattern)
LocalDate.parse(dateStr, formatter)
} else {
val format = SimpleDateFormat(pattern, Locale.getDefault())
val date = format.parse(dateStr) ?: return null
val calendar = Calendar.getInstance()
calendar.time = date
LocalDate.of(
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH) + 1,
calendar.get(Calendar.DAY_OF_MONTH)
)
}
} catch (e: DateTimeParseException) {
return null
} catch (e: ParseException) {
return null
} catch (e: Exception) {
return null
}
}
/**
* Получает текущую дату
* @return текущая дата как LocalDate
*/
fun getCurrentDate(): LocalDate {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
LocalDate.now()
} else {
val calendar = Calendar.getInstance()
LocalDate.of(
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH) + 1,
calendar.get(Calendar.DAY_OF_MONTH)
)
}
}
/**
* Вычисляет разницу в днях между двумя датами
* @param startDate начальная дата
* @param endDate конечная дата
* @return количество дней между датами (может быть отрицательным)
*/
fun daysBetween(startDate: LocalDate, endDate: LocalDate): Int {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Period.between(startDate, endDate).days + Period.between(startDate, endDate).months * 30 + Period.between(startDate, endDate).years * 365
} else {
val startCalendar = Calendar.getInstance()
startCalendar.set(startDate.year, startDate.monthValue - 1, startDate.dayOfMonth)
val endCalendar = Calendar.getInstance()
endCalendar.set(endDate.year, endDate.monthValue - 1, endDate.dayOfMonth)
val diffInMillis = endCalendar.timeInMillis - startCalendar.timeInMillis
TimeUnit.MILLISECONDS.toDays(diffInMillis).toInt()
}
}
/**
* Проверяет, находится ли дата между двумя другими датами (включительно)
* @param date проверяемая дата
* @param startDate начальная дата диапазона
* @param endDate конечная дата диапазона
* @return true, если дата находится в диапазоне (включительно)
*/
fun isDateInRange(date: LocalDate, startDate: LocalDate, endDate: LocalDate): Boolean {
return (date.isEqual(startDate) || date.isAfter(startDate)) &&
(date.isEqual(endDate) || date.isBefore(endDate))
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">192.168.0.112</domain>
<domain includeSubdomains="false">192.168.0.103</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>