Compare commits
3 Commits
37cf587ce6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e9ec5c187 | |||
| 6f969dbd1a | |||
| 86b5df6c10 |
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@@ -4,6 +4,14 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-10-07T06:51:31.183962394Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=LGMG600S9b4da66b" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
id("com.google.devtools.ksp") version "1.9.20-1.0.14"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.womansafe"
|
||||
compileSdk = 36
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.womansafe"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
// Включаем десугаринг для поддержки Java 8 API на старых устройствах
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -28,35 +36,44 @@ android {
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation(platform("androidx.compose:compose-bom:2023.08.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
// Material Icons
|
||||
implementation("androidx.compose.material:material-icons-core")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
|
||||
// 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")
|
||||
// Keyboard options for text fields
|
||||
implementation("androidx.compose.foundation:foundation")
|
||||
|
||||
// JSON
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
// Security
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
@@ -64,17 +81,39 @@ dependencies {
|
||||
// ViewModel
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
// Networking
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// DataStore for preferences
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
// Location Services
|
||||
implementation("com.google.android.gms:play-services-location:21.0.1")
|
||||
implementation("com.google.android.gms:play-services-maps:18.2.0")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
// Permissions
|
||||
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
|
||||
|
||||
// Coil для загрузки изображений
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
|
||||
// Room Database
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
annotationProcessor("androidx.room:room-compiler:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
|
||||
// LiveData
|
||||
implementation("androidx.compose.runtime:runtime-livedata")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
|
||||
// Десугаринг для поддержки Java 8 API (включая java.time) на Android API 24 и ниже
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
|
||||
}
|
||||
@@ -2,6 +2,17 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Internet permission for API calls -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Location permissions for emergency functionality -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Permission to make phone calls for emergency contacts -->
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -10,7 +21,10 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WomanSafe">
|
||||
android:theme="@style/Theme.WomanSafe"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -4,44 +4,50 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.example.womansafe.data.network.NetworkClient
|
||||
import com.example.womansafe.ui.screens.AuthScreen
|
||||
import com.example.womansafe.ui.screens.MainScreen
|
||||
import com.example.womansafe.ui.theme.WomanSafeTheme
|
||||
import com.example.womansafe.ui.viewmodel.AuthViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val authViewModel: AuthViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Инициализируем NetworkClient для работы с сохраненным токеном
|
||||
NetworkClient.initialize(applicationContext)
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
WomanSafeTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
Greeting(
|
||||
name = "Android",
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
// Проверяем сохраненный токен и пытаемся выполнить автоматический вход
|
||||
LaunchedEffect(Unit) {
|
||||
NetworkClient.getAuthToken()?.let { token ->
|
||||
// Если токен существует, пытаемся выполнить автоматический вход
|
||||
authViewModel.autoLogin(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = "Hello $name!",
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun GreetingPreview() {
|
||||
WomanSafeTheme {
|
||||
Greeting("Android")
|
||||
// Показываем либо экран авторизации, либо главный экран
|
||||
if (authViewModel.uiState.isLoggedIn) {
|
||||
MainScreen(authViewModel = authViewModel)
|
||||
} else {
|
||||
AuthScreen(viewModel = authViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.example.womansafe.data.api
|
||||
|
||||
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||
import com.example.womansafe.data.model.calendar.CycleData
|
||||
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
/**
|
||||
* API интерфейс для взаимодействия с серверной частью календаря
|
||||
*/
|
||||
interface CalendarApi {
|
||||
|
||||
@GET("api/v1/calendar/cycle-data")
|
||||
suspend fun getCycleData(): Response<CycleData>
|
||||
|
||||
@POST("api/v1/calendar/cycle-data")
|
||||
suspend fun updateCycleData(@Body cycleData: CycleData): Response<CycleData>
|
||||
|
||||
@GET("api/v1/calendar/entries")
|
||||
suspend fun getCalendarEntries(
|
||||
@Query("start_date") startDate: String? = null,
|
||||
@Query("end_date") endDate: String? = null
|
||||
): Response<List<CalendarEntry>>
|
||||
|
||||
@GET("api/v1/calendar/entries/{entry_id}")
|
||||
suspend fun getCalendarEntry(
|
||||
@Path("entry_id") entryId: Long
|
||||
): Response<CalendarEntry>
|
||||
|
||||
@POST("api/v1/calendar/entries")
|
||||
suspend fun addCalendarEntry(
|
||||
@Body entry: CalendarEntry
|
||||
): Response<CalendarEntry>
|
||||
|
||||
@PUT("api/v1/calendar/entries/{entry_id}")
|
||||
suspend fun updateCalendarEntry(
|
||||
@Path("entry_id") entryId: Long,
|
||||
@Body entry: CalendarEntry
|
||||
): Response<CalendarEntry>
|
||||
|
||||
@DELETE("api/v1/calendar/entries/{entry_id}")
|
||||
suspend fun deleteCalendarEntry(
|
||||
@Path("entry_id") entryId: Long
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/v1/calendar/insights")
|
||||
suspend fun getHealthInsights(): Response<List<HealthInsight>>
|
||||
|
||||
@POST("api/v1/calendar/insights/{insight_id}/dismiss")
|
||||
suspend fun dismissInsight(
|
||||
@Path("insight_id") insightId: Long
|
||||
): Response<Unit>
|
||||
}
|
||||
@@ -5,17 +5,16 @@ import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
interface WomanSafeApi {
|
||||
|
||||
// Authentication endpoints
|
||||
@POST("api/v1/auth/login")
|
||||
suspend fun login(@Body request: ApiRequestBody): Response<Token>
|
||||
suspend fun login(@Body request: UserLogin): Response<Token>
|
||||
|
||||
@POST("api/v1/auth/register")
|
||||
suspend fun register(@Body request: ApiRequestBody): Response<UserResponse>
|
||||
suspend fun register(@Body request: UserCreate): Response<UserResponse>
|
||||
|
||||
// User endpoints
|
||||
@GET("api/v1/users/me")
|
||||
suspend fun getCurrentUser(@Body request: ApiRequestBody = ApiRequestBody()): Response<UserResponse>
|
||||
suspend fun getCurrentUser(): Response<UserResponse>
|
||||
|
||||
@PUT("api/v1/users/me")
|
||||
suspend fun updateCurrentUser(@Body request: ApiRequestBody): Response<UserResponse>
|
||||
@@ -27,41 +26,32 @@ interface WomanSafeApi {
|
||||
suspend fun changePassword(@Body request: ApiRequestBody): Response<Unit>
|
||||
|
||||
@GET("api/v1/users/dashboard")
|
||||
suspend fun getDashboard(@Body request: ApiRequestBody = ApiRequestBody()): Response<Any>
|
||||
suspend fun getDashboard(): Response<Any>
|
||||
|
||||
// Profile endpoints
|
||||
@GET("api/v1/profile")
|
||||
suspend fun getProfile(@Body request: ApiRequestBody = ApiRequestBody()): Response<UserResponse>
|
||||
suspend fun getProfile(): Response<UserResponse>
|
||||
|
||||
@PUT("api/v1/profile")
|
||||
suspend fun updateProfile(@Body request: ApiRequestBody): Response<UserResponse>
|
||||
|
||||
// Emergency Contacts endpoints
|
||||
@GET("api/v1/users/me/emergency-contacts")
|
||||
suspend fun getEmergencyContacts(@Body request: ApiRequestBody = ApiRequestBody()): Response<List<EmergencyContactResponse>>
|
||||
suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>>
|
||||
|
||||
@POST("api/v1/users/me/emergency-contacts")
|
||||
suspend fun createEmergencyContact(@Body request: ApiRequestBody): Response<EmergencyContactResponse>
|
||||
suspend fun createEmergencyContact(@Body request: EmergencyContactCreate): Response<EmergencyContactResponse>
|
||||
|
||||
@GET("api/v1/users/me/emergency-contacts/{contact_id}")
|
||||
suspend fun getEmergencyContact(
|
||||
@Path("contact_id") contactId: String,
|
||||
@Body request: ApiRequestBody = ApiRequestBody()
|
||||
): Response<EmergencyContactResponse>
|
||||
suspend fun getEmergencyContact(@Path("contact_id") contactId: String): Response<EmergencyContactResponse>
|
||||
|
||||
@PATCH("api/v1/users/me/emergency-contacts/{contact_id}")
|
||||
suspend fun updateEmergencyContact(
|
||||
@Path("contact_id") contactId: String,
|
||||
@Body request: ApiRequestBody
|
||||
): Response<EmergencyContactResponse>
|
||||
suspend fun updateEmergencyContact(@Path("contact_id") contactId: String, @Body request: ApiRequestBody): Response<EmergencyContactResponse>
|
||||
|
||||
@DELETE("api/v1/users/me/emergency-contacts/{contact_id}")
|
||||
suspend fun deleteEmergencyContact(
|
||||
@Path("contact_id") contactId: String,
|
||||
@Body request: ApiRequestBody = ApiRequestBody()
|
||||
): Response<Unit>
|
||||
suspend fun deleteEmergencyContact(@Path("contact_id") contactId: String): Response<Unit>
|
||||
|
||||
// Emergency endpoints
|
||||
// Emergency Reports endpoints
|
||||
@GET("api/v1/emergency/reports")
|
||||
suspend fun getEmergencyReports(): Response<Any>
|
||||
|
||||
@@ -82,13 +72,13 @@ interface WomanSafeApi {
|
||||
|
||||
// Emergency Alerts endpoints
|
||||
@GET("api/v1/emergency/alerts")
|
||||
suspend fun getEmergencyAlerts(): Response<Any>
|
||||
suspend fun getEmergencyAlerts(): Response<List<EmergencyAlertResponse>>
|
||||
|
||||
@POST("api/v1/emergency/alerts")
|
||||
suspend fun createEmergencyAlert(): Response<Any>
|
||||
suspend fun createEmergencyAlert(@Body request: EmergencyAlertCreate): Response<EmergencyAlertResponse>
|
||||
|
||||
@GET("api/v1/emergency/alerts/my")
|
||||
suspend fun getMyEmergencyAlerts(): Response<Any>
|
||||
suspend fun getMyEmergencyAlerts(): Response<List<EmergencyAlertResponse>>
|
||||
|
||||
@GET("api/v1/emergency/alerts/nearby")
|
||||
suspend fun getNearbyEmergencyAlerts(): Response<Any>
|
||||
@@ -133,39 +123,41 @@ interface WomanSafeApi {
|
||||
@DELETE("api/v1/locations/safe-places/{place_id}")
|
||||
suspend fun deleteSafePlace(@Path("place_id") placeId: String): Response<Any>
|
||||
|
||||
// Calendar endpoints
|
||||
// Календарь и отслеживание цикла
|
||||
@GET("api/v1/calendar/cycle-data")
|
||||
suspend fun getCycleData(): Response<CycleData>
|
||||
|
||||
@GET("api/v1/calendar/entries")
|
||||
suspend fun getCalendarEntries(): Response<Any>
|
||||
suspend fun getCalendarEntries(
|
||||
@Query("start_date") startDate: String? = null,
|
||||
@Query("end_date") endDate: String? = null,
|
||||
@Query("entry_type") entryType: String? = null,
|
||||
@Query("limit") limit: Int? = null
|
||||
): Response<List<CalendarEvent>>
|
||||
|
||||
@POST("api/v1/calendar/entries")
|
||||
suspend fun createCalendarEntry(): Response<Any>
|
||||
|
||||
@GET("api/v1/calendar/entries/{entry_id}")
|
||||
suspend fun getCalendarEntry(@Path("entry_id") entryId: String): Response<Any>
|
||||
suspend fun createCalendarEntry(@Body entry: CalendarEntryRequest): Response<CalendarEvent>
|
||||
|
||||
@PUT("api/v1/calendar/entries/{entry_id}")
|
||||
suspend fun updateCalendarEntry(@Path("entry_id") entryId: String): Response<Any>
|
||||
suspend fun updateCalendarEntry(
|
||||
@Path("entry_id") entryId: String,
|
||||
@Body entry: CalendarEntryRequest
|
||||
): Response<CalendarEvent>
|
||||
|
||||
@DELETE("api/v1/calendar/entries/{entry_id}")
|
||||
suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response<Any>
|
||||
suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response<Unit>
|
||||
|
||||
@GET("api/v1/calendar/cycle-overview")
|
||||
suspend fun getCycleOverview(): Response<Any>
|
||||
@GET("api/v1/calendar/statistics")
|
||||
suspend fun getCycleStatistics(): Response<CycleStatistics>
|
||||
|
||||
@GET("api/v1/calendar/predictions")
|
||||
suspend fun getCyclePredictions(): Response<CyclePrediction>
|
||||
|
||||
@GET("api/v1/calendar/insights")
|
||||
suspend fun getCalendarInsights(): Response<Any>
|
||||
suspend fun getHealthInsights(): Response<List<HealthInsight>>
|
||||
|
||||
@GET("api/v1/calendar/reminders")
|
||||
suspend fun getCalendarReminders(): Response<Any>
|
||||
|
||||
@POST("api/v1/calendar/reminders")
|
||||
suspend fun createCalendarReminder(): Response<Any>
|
||||
|
||||
@GET("api/v1/calendar/settings")
|
||||
suspend fun getCalendarSettings(): Response<Any>
|
||||
|
||||
@PUT("api/v1/calendar/settings")
|
||||
suspend fun updateCalendarSettings(): Response<Any>
|
||||
@PATCH("api/v1/calendar/insights/{insight_id}/dismiss")
|
||||
suspend fun dismissInsight(@Path("insight_id") insightId: String): Response<HealthInsight>
|
||||
|
||||
// Notification endpoints
|
||||
@GET("api/v1/notifications/devices")
|
||||
@@ -199,6 +191,6 @@ interface WomanSafeApi {
|
||||
@GET("api/v1/services-status")
|
||||
suspend fun getServicesStatus(): Response<Any>
|
||||
|
||||
@GET("")
|
||||
@GET("/")
|
||||
suspend fun getRoot(): Response<Any>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.example.womansafe.data.local
|
||||
|
||||
import androidx.room.*
|
||||
import com.example.womansafe.data.model.calendar.CycleData
|
||||
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* DAO для работы с данными менструального календаря
|
||||
*/
|
||||
@Dao
|
||||
interface CalendarDao {
|
||||
// CycleData operations
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertCycleData(cycleData: CycleData)
|
||||
|
||||
@Query("SELECT * FROM cycle_data WHERE userId = :userId")
|
||||
suspend fun getCycleDataByUserId(userId: String): CycleData?
|
||||
|
||||
@Query("SELECT * FROM cycle_data WHERE userId = :userId")
|
||||
fun getCycleDataFlowByUserId(userId: String): Flow<CycleData?>
|
||||
|
||||
@Delete
|
||||
suspend fun deleteCycleData(cycleData: CycleData)
|
||||
|
||||
// CalendarEntry operations
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertCalendarEntry(entry: CalendarEntry): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertCalendarEntries(entries: List<CalendarEntry>)
|
||||
|
||||
@Update
|
||||
suspend fun updateCalendarEntry(entry: CalendarEntry)
|
||||
|
||||
@Query("SELECT * FROM calendar_entries WHERE userId = :userId ORDER BY entryDate DESC")
|
||||
fun getAllCalendarEntriesFlow(userId: String): Flow<List<CalendarEntry>>
|
||||
|
||||
@Query("SELECT * FROM calendar_entries WHERE userId = :userId AND entryDate = :date")
|
||||
suspend fun getCalendarEntryByDate(userId: String, date: LocalDate): CalendarEntry?
|
||||
|
||||
@Query("SELECT * FROM calendar_entries WHERE userId = :userId AND entryDate BETWEEN :startDate AND :endDate ORDER BY entryDate")
|
||||
suspend fun getCalendarEntriesBetweenDates(userId: String, startDate: LocalDate, endDate: LocalDate): List<CalendarEntry>
|
||||
|
||||
@Query("SELECT * FROM calendar_entries WHERE userId = :userId AND entryDate BETWEEN :startDate AND :endDate ORDER BY entryDate")
|
||||
fun getCalendarEntriesBetweenDatesFlow(userId: String, startDate: LocalDate, endDate: LocalDate): Flow<List<CalendarEntry>>
|
||||
|
||||
@Query("SELECT * FROM calendar_entries WHERE id = :entryId")
|
||||
suspend fun getCalendarEntryById(entryId: Long): CalendarEntry?
|
||||
|
||||
@Delete
|
||||
suspend fun deleteCalendarEntry(entry: CalendarEntry)
|
||||
|
||||
@Query("DELETE FROM calendar_entries WHERE userId = :userId AND entryDate = :date")
|
||||
suspend fun deleteCalendarEntryByDate(userId: String, date: LocalDate)
|
||||
|
||||
@Query("SELECT * FROM calendar_entries ORDER BY entryDate DESC")
|
||||
suspend fun getAllCalendarEntries(): List<CalendarEntry>
|
||||
|
||||
// HealthInsight operations
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertHealthInsight(insight: HealthInsight): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertHealthInsights(insights: List<HealthInsight>)
|
||||
|
||||
@Update
|
||||
suspend fun updateHealthInsight(insight: HealthInsight)
|
||||
|
||||
@Query("SELECT * FROM health_insights WHERE userId = :userId AND isDismissed = 0 ORDER BY createdAt DESC")
|
||||
fun getActiveHealthInsightsFlow(userId: String): Flow<List<HealthInsight>>
|
||||
|
||||
@Query("SELECT * FROM health_insights WHERE userId = :userId ORDER BY createdAt DESC")
|
||||
fun getAllHealthInsightsFlow(userId: String): Flow<List<HealthInsight>>
|
||||
|
||||
@Query("UPDATE health_insights SET isDismissed = 1 WHERE id = :insightId")
|
||||
suspend fun dismissHealthInsight(insightId: Long)
|
||||
|
||||
@Query("DELETE FROM health_insights WHERE userId = :userId AND createdAt < :timestamp")
|
||||
suspend fun deleteOldInsights(userId: String, timestamp: Long)
|
||||
|
||||
@Query("SELECT * FROM cycle_data ORDER BY lastUpdated DESC LIMIT 1")
|
||||
suspend fun getLatestCycleData(): CycleData?
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.example.womansafe.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||
import com.example.womansafe.data.model.calendar.CycleData
|
||||
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||
|
||||
/**
|
||||
* База данных Room для хранения всех данных менструального календаря
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
CycleData::class,
|
||||
CalendarEntry::class,
|
||||
HealthInsight::class
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(CalendarTypeConverters::class)
|
||||
abstract class CalendarDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun calendarDao(): CalendarDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: CalendarDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): CalendarDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
CalendarDatabase::class.java,
|
||||
"calendar_database"
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.example.womansafe.data.local
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.example.womansafe.data.model.calendar.*
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Конвертеры типов для Room базы данных для работы с нестандартными типами
|
||||
*/
|
||||
class CalendarTypeConverters {
|
||||
private val gson = Gson()
|
||||
|
||||
@TypeConverter
|
||||
fun fromLocalDate(value: LocalDate?): String? {
|
||||
return value?.toString()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toLocalDate(value: String?): LocalDate? {
|
||||
return value?.let { LocalDate.parse(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromSymptomList(value: List<Symptom>?): String? {
|
||||
return value?.let { gson.toJson(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toSymptomList(value: String?): List<Symptom>? {
|
||||
if (value == null) return null
|
||||
val listType = object : TypeToken<List<Symptom>>() {}.type
|
||||
return gson.fromJson(value, listType)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringList(value: List<String>?): String? {
|
||||
return value?.let { gson.toJson(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toStringList(value: String?): List<String>? {
|
||||
if (value == null) return null
|
||||
val listType = object : TypeToken<List<String>>() {}.type
|
||||
return gson.fromJson(value, listType)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromEntryType(value: EntryType): String {
|
||||
return value.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toEntryType(value: String): EntryType {
|
||||
return try {
|
||||
EntryType.valueOf(value)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
EntryType.NOTE // Дефолтное значение
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromFlowIntensity(value: FlowIntensity?): String? {
|
||||
return value?.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toFlowIntensity(value: String?): FlowIntensity? {
|
||||
if (value == null) return null
|
||||
return try {
|
||||
FlowIntensity.valueOf(value)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromMood(value: Mood?): String? {
|
||||
return value?.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toMood(value: String?): Mood? {
|
||||
if (value == null) return null
|
||||
return try {
|
||||
Mood.valueOf(value)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromInsightType(value: InsightType): String {
|
||||
return value.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toInsightType(value: String): InsightType {
|
||||
return InsightType.valueOf(value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromConfidenceLevel(value: ConfidenceLevel): String {
|
||||
return value.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toConfidenceLevel(value: String): ConfidenceLevel {
|
||||
return ConfidenceLevel.valueOf(value)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +1,24 @@
|
||||
package com.example.womansafe.data.model
|
||||
|
||||
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
// Authentication models
|
||||
// Request body wrapper for API Gateway proxy endpoints
|
||||
data class ApiRequestBody(
|
||||
val user_create: UserCreate? = null,
|
||||
val user_login: UserLogin? = null,
|
||||
val user_update: UserUpdate? = null,
|
||||
val emergency_contact_create: EmergencyContactCreate? = null,
|
||||
val emergency_contact_update: EmergencyContactUpdate? = null
|
||||
)
|
||||
|
||||
// Auth models
|
||||
data class UserLogin(
|
||||
val email: String? = null,
|
||||
val username: String? = null,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class UserCreate(
|
||||
val email: String,
|
||||
val username: String? = null,
|
||||
val phone: String? = null,
|
||||
@SerializedName("phone_number")
|
||||
val phoneNumber: String? = null,
|
||||
@SerializedName("first_name")
|
||||
val firstName: String? = "",
|
||||
@SerializedName("last_name")
|
||||
val lastName: String? = "",
|
||||
@SerializedName("full_name")
|
||||
val fullName: String? = null,
|
||||
@SerializedName("date_of_birth")
|
||||
val dateOfBirth: String? = null,
|
||||
val bio: String? = null,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class UserUpdate(
|
||||
@SerializedName("first_name")
|
||||
val firstName: String? = null,
|
||||
@SerializedName("last_name")
|
||||
val lastName: String? = null,
|
||||
val phone: String? = null,
|
||||
@SerializedName("date_of_birth")
|
||||
val dateOfBirth: String? = null,
|
||||
val bio: String? = null,
|
||||
@SerializedName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
@SerializedName("emergency_contact_1_name")
|
||||
val emergencyContact1Name: String? = null,
|
||||
@SerializedName("emergency_contact_1_phone")
|
||||
val emergencyContact1Phone: String? = null,
|
||||
@SerializedName("emergency_contact_2_name")
|
||||
val emergencyContact2Name: String? = null,
|
||||
@SerializedName("emergency_contact_2_phone")
|
||||
val emergencyContact2Phone: String? = null,
|
||||
@SerializedName("location_sharing_enabled")
|
||||
val locationSharingEnabled: Boolean? = null,
|
||||
@SerializedName("emergency_notifications_enabled")
|
||||
val emergencyNotificationsEnabled: Boolean? = null,
|
||||
@SerializedName("push_notifications_enabled")
|
||||
val pushNotificationsEnabled: Boolean? = null
|
||||
)
|
||||
|
||||
data class UserResponse(
|
||||
val email: String,
|
||||
val username: String? = null,
|
||||
val phone: String? = null,
|
||||
@SerializedName("phone_number")
|
||||
val phoneNumber: String? = null,
|
||||
@SerializedName("first_name")
|
||||
val firstName: String? = "",
|
||||
@SerializedName("last_name")
|
||||
val lastName: String? = "",
|
||||
@SerializedName("full_name")
|
||||
val fullName: String? = null,
|
||||
@SerializedName("date_of_birth")
|
||||
val dateOfBirth: String? = null,
|
||||
val bio: String? = null,
|
||||
val id: Int,
|
||||
val uuid: String,
|
||||
@SerializedName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
@SerializedName("emergency_contact_1_name")
|
||||
val emergencyContact1Name: String? = null,
|
||||
@SerializedName("emergency_contact_1_phone")
|
||||
val emergencyContact1Phone: String? = null,
|
||||
@SerializedName("emergency_contact_2_name")
|
||||
val emergencyContact2Name: String? = null,
|
||||
@SerializedName("emergency_contact_2_phone")
|
||||
val emergencyContact2Phone: String? = null,
|
||||
@SerializedName("location_sharing_enabled")
|
||||
val locationSharingEnabled: Boolean,
|
||||
@SerializedName("emergency_notifications_enabled")
|
||||
val emergencyNotificationsEnabled: Boolean,
|
||||
@SerializedName("push_notifications_enabled")
|
||||
val pushNotificationsEnabled: Boolean,
|
||||
@SerializedName("email_verified")
|
||||
val emailVerified: Boolean,
|
||||
@SerializedName("phone_verified")
|
||||
val phoneVerified: Boolean,
|
||||
@SerializedName("is_active")
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class Token(
|
||||
@SerializedName("access_token")
|
||||
val accessToken: String,
|
||||
@@ -102,52 +26,324 @@ data class Token(
|
||||
val tokenType: String
|
||||
)
|
||||
|
||||
// User models
|
||||
data class UserCreate(
|
||||
val email: String,
|
||||
val username: String? = null,
|
||||
val phone: String? = null,
|
||||
val phone_number: String? = null,
|
||||
val first_name: String? = "",
|
||||
val last_name: String? = "",
|
||||
val full_name: String? = null,
|
||||
val date_of_birth: String? = null,
|
||||
val bio: String? = null,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class UserUpdate(
|
||||
val first_name: String? = null,
|
||||
val last_name: String? = null,
|
||||
val phone: String? = null,
|
||||
val date_of_birth: String? = null,
|
||||
val bio: String? = null,
|
||||
val avatar_url: String? = null,
|
||||
val emergency_contact_1_name: String? = null,
|
||||
val emergency_contact_1_phone: String? = null,
|
||||
val emergency_contact_2_name: String? = null,
|
||||
val emergency_contact_2_phone: String? = null,
|
||||
val location_sharing_enabled: Boolean? = null,
|
||||
val emergency_notifications_enabled: Boolean? = null,
|
||||
val push_notifications_enabled: Boolean? = null,
|
||||
val email_notifications_enabled: Boolean? = null
|
||||
)
|
||||
|
||||
data class UserResponse(
|
||||
val id: Int,
|
||||
val uuid: String,
|
||||
val email: String,
|
||||
val username: String? = null,
|
||||
val phone: String? = null,
|
||||
val phone_number: String? = null,
|
||||
val first_name: String? = "",
|
||||
val last_name: String? = "",
|
||||
val full_name: String? = null,
|
||||
val date_of_birth: String? = null,
|
||||
val bio: String? = null,
|
||||
val avatar_url: String? = null,
|
||||
val emergency_contact_1_name: String? = null,
|
||||
val emergency_contact_1_phone: String? = null,
|
||||
val emergency_contact_2_name: String? = null,
|
||||
val emergency_contact_2_phone: String? = null,
|
||||
val location_sharing_enabled: Boolean,
|
||||
val emergency_notifications_enabled: Boolean,
|
||||
val push_notifications_enabled: Boolean,
|
||||
val email_notifications_enabled: Boolean? = false,
|
||||
val email_verified: Boolean,
|
||||
val phone_verified: Boolean,
|
||||
val is_active: Boolean
|
||||
)
|
||||
|
||||
// Emergency Contact models
|
||||
data class EmergencyContactCreate(
|
||||
val name: String,
|
||||
@SerializedName("phone_number")
|
||||
val phoneNumber: String,
|
||||
val phone_number: String,
|
||||
val relationship: String? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class EmergencyContactUpdate(
|
||||
val name: String? = null,
|
||||
@SerializedName("phone_number")
|
||||
val phoneNumber: String? = null,
|
||||
val phone_number: String? = null,
|
||||
val relationship: String? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class EmergencyContactResponse(
|
||||
val name: String,
|
||||
@SerializedName("phone_number")
|
||||
val phoneNumber: String,
|
||||
val relationship: String? = null,
|
||||
val notes: String? = null,
|
||||
val id: Int,
|
||||
val uuid: String,
|
||||
@SerializedName("user_id")
|
||||
val userId: Int
|
||||
val name: String,
|
||||
val phone_number: String,
|
||||
val relationship: String? = null,
|
||||
val notes: String? = null,
|
||||
val user_id: Int
|
||||
)
|
||||
|
||||
// API Request body wrapper
|
||||
data class ApiRequestBody(
|
||||
@SerializedName("user_create")
|
||||
val userCreate: UserCreate? = null,
|
||||
@SerializedName("user_login")
|
||||
val userLogin: UserLogin? = null,
|
||||
@SerializedName("user_update")
|
||||
val userUpdate: UserUpdate? = null,
|
||||
@SerializedName("emergency_contact_create")
|
||||
val emergencyContactCreate: EmergencyContactCreate? = null,
|
||||
@SerializedName("emergency_contact_update")
|
||||
val emergencyContactUpdate: EmergencyContactUpdate? = null
|
||||
// Request body for different endpoints
|
||||
data class RequestBody(
|
||||
val user_create: UserCreate? = null,
|
||||
val user_login: UserLogin? = null,
|
||||
val user_update: UserUpdate? = null,
|
||||
val emergency_contact_create: EmergencyContactCreate? = null,
|
||||
val emergency_contact_update: EmergencyContactUpdate? = null
|
||||
)
|
||||
|
||||
// Error models
|
||||
// Password change model
|
||||
data class ChangePasswordRequest(
|
||||
val current_password: String,
|
||||
val new_password: String
|
||||
)
|
||||
|
||||
// Dashboard and other response models
|
||||
data class DashboardResponse(
|
||||
val user: UserResponse? = null,
|
||||
val emergency_contacts: List<EmergencyContactResponse>? = null,
|
||||
val recent_activities: List<ActivityResponse>? = null,
|
||||
val safety_status: String? = null
|
||||
)
|
||||
|
||||
data class ActivityResponse(
|
||||
val id: Int,
|
||||
val type: String,
|
||||
val description: String,
|
||||
val timestamp: String,
|
||||
val location: String? = null
|
||||
)
|
||||
|
||||
// Health check models
|
||||
data class HealthResponse(
|
||||
val status: String,
|
||||
val timestamp: String,
|
||||
val version: String? = null
|
||||
)
|
||||
|
||||
data class ServicesStatusResponse(
|
||||
val database: String,
|
||||
val redis: String? = null,
|
||||
val api: String,
|
||||
val timestamp: String
|
||||
)
|
||||
|
||||
// Emergency models
|
||||
data class EmergencyReportCreate(
|
||||
val type: String,
|
||||
val description: String,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val address: String? = null
|
||||
)
|
||||
|
||||
data class EmergencyReportUpdate(
|
||||
val description: String? = null,
|
||||
val status: String? = null
|
||||
)
|
||||
|
||||
data class EmergencyReportResponse(
|
||||
val id: Int,
|
||||
val type: String,
|
||||
val description: String,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val address: String? = null,
|
||||
val timestamp: String,
|
||||
val status: String,
|
||||
val user_id: Int
|
||||
)
|
||||
|
||||
data class EmergencyAlertCreate(
|
||||
val type: String,
|
||||
val description: String? = null,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val address: String? = null,
|
||||
val is_anonymous: Boolean = false
|
||||
)
|
||||
|
||||
data class EmergencyAlertResponse(
|
||||
val id: Int,
|
||||
val uuid: String,
|
||||
val user_id: Int,
|
||||
val type: String,
|
||||
val description: String?,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val address: String?,
|
||||
val is_anonymous: Boolean,
|
||||
val status: String,
|
||||
val created_at: String,
|
||||
val updated_at: String?,
|
||||
val is_active: Boolean
|
||||
)
|
||||
|
||||
// Location models
|
||||
data class LocationUpdate(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracy: Double? = null,
|
||||
val address: String? = null
|
||||
)
|
||||
|
||||
data class LocationResponse(
|
||||
val id: Int,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val timestamp: String,
|
||||
val accuracy: Double? = null,
|
||||
val address: String? = null,
|
||||
val user_id: Int
|
||||
)
|
||||
|
||||
data class SafePlace(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val category: String,
|
||||
val phone_number: String? = null
|
||||
)
|
||||
|
||||
data class SafePlaceResponse(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val category: String,
|
||||
val phone_number: String? = null,
|
||||
val user_id: Int
|
||||
)
|
||||
|
||||
data class NearbyUser(
|
||||
val id: Int,
|
||||
val username: String? = null,
|
||||
val distance: Double,
|
||||
val last_seen: String
|
||||
)
|
||||
|
||||
// Calendar models
|
||||
data class LegacyCalendarEntryResponse( // Переименовано, чтобы избежать конфликта с CalendarModels.kt
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val start_date: String,
|
||||
val end_date: String? = null,
|
||||
val entry_type: String,
|
||||
val mood: String? = null,
|
||||
val symptoms: List<String>? = null,
|
||||
val notes: String? = null,
|
||||
val user_id: Int
|
||||
)
|
||||
|
||||
data class CalendarSettings(
|
||||
val cycle_length: Int,
|
||||
val period_length: Int,
|
||||
val notifications_enabled: Boolean,
|
||||
val reminder_days_before: Int
|
||||
)
|
||||
|
||||
data class CalendarReminder(
|
||||
val title: String,
|
||||
val message: String,
|
||||
val reminder_date: String,
|
||||
val reminder_time: String,
|
||||
val is_recurring: Boolean
|
||||
)
|
||||
|
||||
data class CalendarInsights(
|
||||
val average_cycle_length: Double,
|
||||
val cycle_regularity: String,
|
||||
val mood_patterns: Map<String, Int>,
|
||||
val symptom_frequency: Map<String, Int>
|
||||
)
|
||||
|
||||
data class CycleOverview(
|
||||
val current_phase: String,
|
||||
val next_period_date: String,
|
||||
val cycle_day: Int,
|
||||
val fertile_window: List<String>
|
||||
)
|
||||
|
||||
// Notification models
|
||||
data class NotificationDevice(
|
||||
val device_token: String,
|
||||
val device_type: String,
|
||||
val is_active: Boolean
|
||||
)
|
||||
|
||||
data class NotificationPreferences(
|
||||
val push_notifications_enabled: Boolean,
|
||||
val email_notifications_enabled: Boolean,
|
||||
val sms_notifications_enabled: Boolean,
|
||||
val emergency_notifications_enabled: Boolean,
|
||||
val calendar_reminders_enabled: Boolean
|
||||
)
|
||||
|
||||
data class NotificationHistory(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val message: String,
|
||||
val type: String,
|
||||
val sent_at: String,
|
||||
val is_read: Boolean
|
||||
)
|
||||
|
||||
data class TestNotification(
|
||||
val title: String,
|
||||
val message: String,
|
||||
val type: String
|
||||
)
|
||||
|
||||
// Generic response models
|
||||
data class MessageResponse(
|
||||
val message: String,
|
||||
val status: String? = null
|
||||
)
|
||||
|
||||
data class EmailAvailabilityResponse(
|
||||
val available: Boolean,
|
||||
val message: String
|
||||
)
|
||||
|
||||
data class TestUserData(
|
||||
val username: String,
|
||||
val email: String,
|
||||
val password: String,
|
||||
val full_name: String,
|
||||
val phone_number: String
|
||||
)
|
||||
|
||||
// Validation error models
|
||||
data class ValidationError(
|
||||
val loc: List<String>,
|
||||
val loc: List<Any>,
|
||||
val msg: String,
|
||||
val type: String
|
||||
)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
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: String? = 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(),
|
||||
val isPredicted: Boolean = false
|
||||
)
|
||||
|
||||
// API модели для обмена с сервером
|
||||
data class CalendarEventApiResponse(
|
||||
val id: Int,
|
||||
val uuid: String,
|
||||
val entry_date: String,
|
||||
val entry_type: String,
|
||||
val flow_intensity: String? = null,
|
||||
val mood: String? = null,
|
||||
val symptoms: String = "",
|
||||
val notes: String? = null,
|
||||
val is_predicted: Boolean = false,
|
||||
val created_at: String? = null,
|
||||
val updated_at: String? = null,
|
||||
val is_active: Boolean = true,
|
||||
val user_id: Int
|
||||
)
|
||||
|
||||
// Настройки цикла
|
||||
data class CycleSettings(
|
||||
val averageCycleLength: Int = 28,
|
||||
val averagePeriodLength: Int = 5,
|
||||
val lastPeriodStart: LocalDate? = null,
|
||||
val irregularCycles: Boolean = false,
|
||||
val trackSymptoms: Boolean = true,
|
||||
val trackMood: Boolean = true,
|
||||
val showPredictions: Boolean = true,
|
||||
val reminderDaysBefore: Int = 3 // За сколько дней напоминать о приближающемся цикле
|
||||
)
|
||||
|
||||
// Модели для API
|
||||
|
||||
// Запрос на создание события в календаре
|
||||
data class CalendarEntryCreate(
|
||||
val date: String,
|
||||
val type: String,
|
||||
val mood: String? = null,
|
||||
val symptoms: List<String> = emptyList(),
|
||||
val notes: String? = null,
|
||||
val flow_intensity: Int? = null
|
||||
)
|
||||
|
||||
// Запрос на обновление события в календаре
|
||||
data class CalendarEntryUpdate(
|
||||
val date: String? = null,
|
||||
val type: String? = null,
|
||||
val mood: String? = null,
|
||||
val symptoms: List<String>? = null,
|
||||
val notes: String? = null,
|
||||
val flow_intensity: Int? = null
|
||||
)
|
||||
|
||||
// API ответ для отдельной записи календаря
|
||||
data class CalendarEntryResponse(
|
||||
val id: String,
|
||||
val date: String,
|
||||
val type: String,
|
||||
val mood: String? = null,
|
||||
val symptoms: List<String> = emptyList(),
|
||||
val notes: String? = null,
|
||||
val flow_intensity: Int? = null,
|
||||
val is_predicted: Boolean = false,
|
||||
val created_at: String? = null,
|
||||
val updated_at: String? = null
|
||||
)
|
||||
|
||||
// API ответ для информации о цикле
|
||||
data class CycleInfoResponse(
|
||||
val average_cycle_length: Int,
|
||||
val average_period_length: Int,
|
||||
val last_period_start: String?,
|
||||
val next_period_predicted: String?,
|
||||
val next_ovulation_predicted: String?,
|
||||
val fertile_window_start: String?,
|
||||
val fertile_window_end: String?
|
||||
)
|
||||
|
||||
// Полный API ответ для записей календаря
|
||||
data class CalendarEntriesResponse(
|
||||
val entries: List<CalendarEntryResponse>,
|
||||
val cycle_info: CycleInfoResponse
|
||||
)
|
||||
|
||||
// Extension функции для преобразования API моделей в доменные
|
||||
fun CalendarEventApiResponse.toDomainModel(): CalendarEvent {
|
||||
println("=== Преобразование API модели в доменную ===")
|
||||
println("API данные: entry_date=${this.entry_date}, entry_type=${this.entry_type}, symptoms=${this.symptoms}")
|
||||
|
||||
val calendarEvent = CalendarEvent(
|
||||
id = this.uuid,
|
||||
date = LocalDate.parse(this.entry_date),
|
||||
type = mapApiTypeToCalendarEventType(this.entry_type),
|
||||
isActual = !this.is_predicted,
|
||||
mood = this.mood?.let { mapApiMoodToMoodType(it) },
|
||||
symptoms = parseApiSymptoms(this.symptoms),
|
||||
notes = this.notes ?: "",
|
||||
flowIntensity = mapApiFlowIntensityToInt(this.flow_intensity),
|
||||
isPredicted = this.is_predicted,
|
||||
createdAt = this.created_at?.let {
|
||||
try {
|
||||
LocalDate.parse(it.substring(0, 10))
|
||||
} catch (e: Exception) {
|
||||
LocalDate.now()
|
||||
}
|
||||
} ?: LocalDate.now(),
|
||||
updatedAt = this.updated_at?.let {
|
||||
try {
|
||||
LocalDate.parse(it.substring(0, 10))
|
||||
} catch (e: Exception) {
|
||||
LocalDate.now()
|
||||
}
|
||||
} ?: LocalDate.now()
|
||||
)
|
||||
|
||||
println("Доменная модель: id=${calendarEvent.id}, date=${calendarEvent.date}, type=${calendarEvent.type}")
|
||||
println("=============================================")
|
||||
|
||||
return calendarEvent
|
||||
}
|
||||
|
||||
// Вспомогательные функции для маппинга
|
||||
private fun mapApiTypeToCalendarEventType(apiType: String): CalendarEventType {
|
||||
return when (apiType.lowercase()) {
|
||||
"period" -> CalendarEventType.MENSTRUATION
|
||||
"ovulation" -> CalendarEventType.OVULATION
|
||||
"fertile_window" -> CalendarEventType.FERTILE_WINDOW
|
||||
"predicted_period" -> CalendarEventType.PREDICTED_MENSTRUATION
|
||||
"predicted_ovulation" -> CalendarEventType.PREDICTED_OVULATION
|
||||
else -> CalendarEventType.MENSTRUATION // по умолчанию
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapApiMoodToMoodType(apiMood: String): MoodType {
|
||||
return when (apiMood.uppercase()) {
|
||||
"EXCELLENT" -> MoodType.EXCELLENT
|
||||
"GOOD" -> MoodType.GOOD
|
||||
"NORMAL" -> MoodType.NORMAL
|
||||
"BAD" -> MoodType.BAD
|
||||
"TERRIBLE" -> MoodType.TERRIBLE
|
||||
else -> MoodType.NORMAL // по умолчанию
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseApiSymptoms(symptomsString: String): List<SymptomType> {
|
||||
if (symptomsString.isBlank()) return emptyList()
|
||||
|
||||
return symptomsString.split(",")
|
||||
.map { it.trim() }
|
||||
.mapNotNull { symptom ->
|
||||
try {
|
||||
SymptomType.valueOf(symptom.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null // Игнорируем неизвестные симптомы
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapApiFlowIntensityToInt(apiIntensity: String?): Int? {
|
||||
return when (apiIntensity?.lowercase()) {
|
||||
"light" -> 2
|
||||
"medium" -> 3
|
||||
"heavy" -> 5
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.example.womansafe.data.model
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||
import com.example.womansafe.data.model.calendar.EntryType
|
||||
import com.example.womansafe.data.model.calendar.FlowIntensity
|
||||
import com.example.womansafe.data.model.calendar.Symptom
|
||||
import com.example.womansafe.data.model.calendar.Mood
|
||||
|
||||
/**
|
||||
* Модель данных цикла
|
||||
*/
|
||||
data class CycleData(
|
||||
val user_id: Int,
|
||||
val cycle_start_date: LocalDate,
|
||||
val cycle_length: Int,
|
||||
val period_length: Int,
|
||||
val ovulation_date: LocalDate,
|
||||
val fertile_window_start: LocalDate,
|
||||
val fertile_window_end: LocalDate,
|
||||
val next_period_predicted: LocalDate,
|
||||
val cycle_regularity_score: Int, // 1-100
|
||||
val avg_cycle_length: Float,
|
||||
val avg_period_length: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель для аналитики здоровья и инсайтов
|
||||
*/
|
||||
data class HealthInsight(
|
||||
val id: Int,
|
||||
val user_id: Int,
|
||||
val insight_type: InsightType,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val recommendation: String,
|
||||
val confidence_level: ConfidenceLevel,
|
||||
val data_points_used: Int,
|
||||
val is_dismissed: Boolean = false,
|
||||
val created_at: LocalDateTime? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Типы записей в календаре
|
||||
*/
|
||||
|
||||
/**
|
||||
* Интенсивность менструации
|
||||
*/
|
||||
|
||||
/**
|
||||
* Настроение
|
||||
*/
|
||||
|
||||
/**
|
||||
* Симптомы
|
||||
*/
|
||||
|
||||
/**
|
||||
* Типы инсайтов
|
||||
*/
|
||||
enum class InsightType {
|
||||
CYCLE_IRREGULARITY,
|
||||
PERIOD_LENGTH_CHANGE,
|
||||
SYMPTOM_PATTERN,
|
||||
HEALTH_RECOMMENDATION,
|
||||
OVULATION_PREDICTION,
|
||||
LIFESTYLE_IMPACT
|
||||
}
|
||||
|
||||
/**
|
||||
* Уровни достоверности инсайтов
|
||||
*/
|
||||
enum class ConfidenceLevel {
|
||||
LOW,
|
||||
MEDIUM,
|
||||
HIGH
|
||||
}
|
||||
|
||||
/**
|
||||
* Модель для запроса на создание записи в календаре
|
||||
*/
|
||||
data class CalendarEntryRequest(
|
||||
val entry_date: String,
|
||||
val entry_type: String,
|
||||
val flow_intensity: String? = null,
|
||||
val period_symptoms: List<String>? = null,
|
||||
val mood: String? = null,
|
||||
val energy_level: Int? = null,
|
||||
val sleep_hours: Float? = null,
|
||||
val symptoms: List<String>? = null,
|
||||
val medications: List<String>? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель для статистики цикла
|
||||
*/
|
||||
data class CycleStatistics(
|
||||
val average_cycle_length: Float,
|
||||
val cycle_length_variation: Float,
|
||||
val average_period_length: Float,
|
||||
val cycle_regularity: Int,
|
||||
val cycle_history: List<CycleHistoryItem>
|
||||
)
|
||||
|
||||
/**
|
||||
* Элемент истории циклов для отображения графика
|
||||
*/
|
||||
data class CycleHistoryItem(
|
||||
val start_date: LocalDate,
|
||||
val end_date: LocalDate,
|
||||
val cycle_length: Int,
|
||||
val period_length: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель для прогноза цикла
|
||||
*/
|
||||
data class CyclePrediction(
|
||||
val next_period_start: LocalDate,
|
||||
val next_period_end: LocalDate,
|
||||
val next_ovulation: LocalDate,
|
||||
val fertile_window_start: LocalDate,
|
||||
val fertile_window_end: LocalDate,
|
||||
val confidence: Float = 0.85f
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.example.womansafe.data.model
|
||||
|
||||
// Этот файл оставлен пустым, так как все модели экстренных контактов
|
||||
// теперь находятся в ApiModels.kt для избежания дублирования
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.example.womansafe.data.model
|
||||
|
||||
import java.util.Date
|
||||
|
||||
// Типы экстренных событий
|
||||
enum class EmergencyType {
|
||||
HARASSMENT, // Домогательства
|
||||
ASSAULT, // Нападение
|
||||
STALKING, // Преследование
|
||||
DOMESTIC_VIOLENCE, // Домашнее насилие
|
||||
UNSAFE_AREA, // Небезопасная зона
|
||||
MEDICAL, // Медицинская помощь
|
||||
OTHER // Другое
|
||||
}
|
||||
|
||||
// Статус экстренного события
|
||||
enum class EmergencyStatus {
|
||||
ACTIVE, // Активное
|
||||
RESOLVED, // Решено
|
||||
FALSE_ALARM // Ложная тревога
|
||||
}
|
||||
|
||||
// Локальная модель экстренного события
|
||||
data class EmergencyAlert(
|
||||
val id: Int? = null,
|
||||
val uuid: String? = null,
|
||||
val type: EmergencyType,
|
||||
val description: String? = null,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val address: String? = null,
|
||||
val isAnonymous: Boolean = false,
|
||||
val status: EmergencyStatus = EmergencyStatus.ACTIVE,
|
||||
val createdAt: Date? = null,
|
||||
val updatedAt: Date? = null,
|
||||
val isActive: Boolean = true
|
||||
)
|
||||
|
||||
// Местоположение пользователя
|
||||
data class UserLocation(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracy: Float? = null,
|
||||
val address: String? = null,
|
||||
val timestamp: Date = Date()
|
||||
)
|
||||
|
||||
// Emergency models for UI layer
|
||||
|
||||
// Модель экстренного события для отображения в списке
|
||||
data class EmergencyAlertItem(
|
||||
val id: Int,
|
||||
val type: EmergencyType,
|
||||
val description: String?,
|
||||
val address: String?,
|
||||
val status: EmergencyStatus,
|
||||
val createdAt: Date
|
||||
)
|
||||
|
||||
// Модель экстренного события для детального просмотра
|
||||
data class EmergencyAlertDetail(
|
||||
val id: Int,
|
||||
val uuid: String,
|
||||
val type: EmergencyType,
|
||||
val description: String?,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val address: String?,
|
||||
val isAnonymous: Boolean,
|
||||
val status: EmergencyStatus,
|
||||
val createdAt: Date,
|
||||
val updatedAt: Date,
|
||||
val isActive: Boolean
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
data class UserResponse(
|
||||
val id: Int,
|
||||
val uuid: String?,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val phone: String?,
|
||||
val firstName: String?,
|
||||
val lastName: String?,
|
||||
val dateOfBirth: String?,
|
||||
val avatarUrl: String?,
|
||||
val bio: String?,
|
||||
val emergencyContact1Name: String?,
|
||||
val emergencyContact1Phone: String?,
|
||||
val emergencyContact2Name: String?,
|
||||
val emergencyContact2Phone: String?,
|
||||
val locationSharingEnabled: Boolean?,
|
||||
val emergencyNotificationsEnabled: Boolean?,
|
||||
val pushNotificationsEnabled: Boolean?,
|
||||
val emailNotificationsEnabled: Boolean?,
|
||||
val emailVerified: Boolean?,
|
||||
val phoneVerified: Boolean?,
|
||||
val isBlocked: Boolean?,
|
||||
val isActive: Boolean?,
|
||||
val createdAt: String?,
|
||||
val updatedAt: String?
|
||||
)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.LocalDate
|
||||
|
||||
@Entity(tableName = "calendar_entries")
|
||||
data class CalendarEntry(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val userId: String,
|
||||
val entryDate: LocalDate,
|
||||
val entryType: EntryType,
|
||||
val flowIntensity: FlowIntensity? = null,
|
||||
val periodSymptoms: List<Symptom>? = null,
|
||||
val mood: Mood? = null,
|
||||
val energyLevel: Int? = null, // 1-5
|
||||
val sleepHours: Float? = null,
|
||||
val symptoms: List<Symptom>? = null,
|
||||
val medications: List<String>? = null,
|
||||
val notes: String? = null,
|
||||
val isPredicted: Boolean = false,
|
||||
val confidenceScore: Int? = null, // 1-100
|
||||
val syncTimestamp: Long = System.currentTimeMillis(),
|
||||
val entryId: Long
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Запрос на создание/обновление записи календаря
|
||||
*/
|
||||
data class CalendarEntryRequest(
|
||||
val entry_date: String,
|
||||
val entry_type: String,
|
||||
val flow_intensity: String? = null,
|
||||
val mood: String? = null,
|
||||
val symptoms: List<String>? = null,
|
||||
val period_symptoms: List<String>? = null,
|
||||
val medications: List<String>? = null,
|
||||
val notes: String? = null,
|
||||
val energy_level: Int? = null,
|
||||
val sleep_hours: Float? = null
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Модель события календаря, используемая в улучшенном календарном интерфейсе
|
||||
*/
|
||||
@Entity(tableName = "calendar_events")
|
||||
data class CalendarEvent(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val userId: String,
|
||||
val date: LocalDate,
|
||||
val type: String, // period, ovulation, symptoms, medication, note, appointment
|
||||
val flowIntensity: String? = null,
|
||||
val mood: String? = null,
|
||||
val energyLevel: Int? = null,
|
||||
val sleepHours: Float? = null,
|
||||
val symptoms: List<String>? = null,
|
||||
val medications: List<String>? = null,
|
||||
val notes: String? = null,
|
||||
val isDismissed: Boolean = false,
|
||||
val syncTimestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Уровень уверенности в прогнозе или инсайте
|
||||
*/
|
||||
enum class ConfidenceLevel {
|
||||
LOW, // Низкая уверенность
|
||||
MEDIUM, // Средняя уверенность
|
||||
HIGH // Высокая уверенность
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Данные о текущем менструальном цикле
|
||||
*/
|
||||
@Entity(tableName = "cycle_data")
|
||||
data class CycleData(
|
||||
@PrimaryKey
|
||||
val userId: String,
|
||||
val periodStart: LocalDate?,
|
||||
val periodEnd: LocalDate?,
|
||||
val ovulationDate: LocalDate?,
|
||||
val fertileWindowStart: LocalDate?,
|
||||
val fertileWindowEnd: LocalDate?,
|
||||
val cycleLength: Int = 28,
|
||||
val periodLength: Int = 5,
|
||||
val nextPeriodPredicted: LocalDate? = null,
|
||||
val lastUpdated: Long = System.currentTimeMillis(),
|
||||
// Добавленные поля для совместимости с кодом
|
||||
val lastPeriodStartDate: LocalDate? = periodStart,
|
||||
val averageCycleLength: Int = cycleLength,
|
||||
val averagePeriodLength: Int = periodLength,
|
||||
val regularityScore: Int = 0, // 0-100
|
||||
val cycleStartDate: LocalDate? = null
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Модель прогноза менструального цикла
|
||||
*/
|
||||
data class CyclePrediction(
|
||||
val userId: String,
|
||||
val nextPeriodStart: LocalDate,
|
||||
val nextPeriodEnd: LocalDate,
|
||||
val nextOvulation: LocalDate,
|
||||
val fertileWindowStart: LocalDate,
|
||||
val fertileWindowEnd: LocalDate,
|
||||
val confidenceScore: Int, // 1-100
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Модель статистики менструального цикла
|
||||
*/
|
||||
data class CycleStatistics(
|
||||
val userId: String,
|
||||
val averageCycleLength: Int,
|
||||
val averagePeriodLength: Int,
|
||||
val shortestCycle: Int,
|
||||
val longestCycle: Int,
|
||||
val cycleLengthVariation: Int,
|
||||
val regularityScore: Int, // 1-100
|
||||
val dataPointsCount: Int,
|
||||
val lastUpdated: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Типы записей в календаре женского здоровья
|
||||
*/
|
||||
enum class EntryType {
|
||||
PERIOD, // Менструация
|
||||
OVULATION, // Овуляция
|
||||
SYMPTOMS, // Симптомы
|
||||
MEDICATION, // Лекарства
|
||||
NOTE, // Заметка
|
||||
APPOINTMENT // Приём у врача
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Интенсивность менструального кровотечения
|
||||
*/
|
||||
enum class FlowIntensity {
|
||||
SPOTTING, // Мажущие выделения
|
||||
LIGHT, // Легкие
|
||||
MEDIUM, // Средние
|
||||
HEAVY, // Сильные
|
||||
VERY_HEAVY // Очень сильные
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Модель медицинских инсайтов на основе данных календаря
|
||||
*/
|
||||
@Entity(tableName = "health_insights")
|
||||
data class HealthInsight(
|
||||
@PrimaryKey
|
||||
val id: Long,
|
||||
val userId: String,
|
||||
val insightType: InsightType,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val confidenceLevel: ConfidenceLevel,
|
||||
val recommendation: String,
|
||||
val dataPointsUsed: Int,
|
||||
val isDismissed: Boolean = false,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Типы инсайтов о женском здоровье
|
||||
*/
|
||||
enum class InsightType {
|
||||
CYCLE_REGULARITY, // Регулярность цикла
|
||||
PERIOD_LENGTH, // Продолжительность менструации
|
||||
SYMPTOM_PATTERN, // Закономерности в симптомах
|
||||
LIFESTYLE_IMPACT, // Влияние образа жизни
|
||||
MEDICATION_EFFECTIVENESS, // Эффективность лекарств
|
||||
MOOD_CORRELATION, // Корреляции настроения
|
||||
PERIOD_PREDICTION, // Прогноз менструации
|
||||
HEALTH_TIP, // Совет по здоровью
|
||||
MEDICATION_REMINDER, // Напоминание о приеме лекарств
|
||||
EXERCISE_SUGGESTION // Рекомендации по физическим упражнениям
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Расширенный набор настроений для дополненного календаря
|
||||
*/
|
||||
enum class Mood {
|
||||
VERY_HAPPY, // Очень счастливое
|
||||
HAPPY, // Счастливое
|
||||
NEUTRAL, // Нейтральное
|
||||
NORMAL, // Обычное (для совместимости)
|
||||
SAD, // Грустное
|
||||
VERY_SAD, // Очень грустное
|
||||
ANXIOUS, // Тревожное
|
||||
IRRITATED, // Раздраженное (для совместимости)
|
||||
IRRITABLE, // Раздражительное
|
||||
SENSITIVE, // Чувствительное
|
||||
CALM, // Спокойное
|
||||
ENERGETIC, // Энергичное
|
||||
TIRED // Усталое
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Состояние кожи в менструальном календаре
|
||||
*/
|
||||
enum class SkinCondition {
|
||||
NORMAL, // Нормальное состояние кожи
|
||||
IRRITATED, // Раздраженная кожа
|
||||
SENSITIVE, // Чувствительная кожа
|
||||
DRY, // Сухая кожа
|
||||
OILY, // Жирная кожа
|
||||
ACNE, // Высыпания/акне
|
||||
REDNESS // Покраснения
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Расширенный набор симптомов для женского здоровья
|
||||
*/
|
||||
enum class Symptom {
|
||||
CRAMPS, // Спазмы/боли
|
||||
HEADACHE, // Головная боль
|
||||
BACKACHE, // Боли в спине
|
||||
NAUSEA, // Тошнота
|
||||
FATIGUE, // Усталость
|
||||
BLOATING, // Вздутие
|
||||
BREAST_TENDERNESS, // Чувствительность груди
|
||||
ACNE, // Высыпания
|
||||
MOOD_SWINGS, // Перепады настроения
|
||||
CRAVINGS, // Тяга к еде
|
||||
INSOMNIA, // Бессонница
|
||||
DIZZINESS, // Головокружение
|
||||
CONSTIPATION, // Запоры
|
||||
DIARRHEA, // Диарея
|
||||
HOT_FLASHES, // Приливы
|
||||
SPOTTING // Мажущие выделения
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.example.womansafe.data.network
|
||||
|
||||
import com.google.gson.GsonBuilder
|
||||
import android.content.Context
|
||||
import com.example.womansafe.data.api.WomanSafeApi
|
||||
import com.example.womansafe.util.PreferenceManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
@@ -9,22 +11,59 @@ import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object NetworkClient {
|
||||
private const val BASE_URL = "http://10.0.2.2:8000/" // For Android Emulator
|
||||
// For real device, use: "http://YOUR_IP:8000/"
|
||||
|
||||
private var BASE_URL = "http://192.168.0.112:8000/"
|
||||
private var authToken: String? = null
|
||||
private lateinit var preferenceManager: PreferenceManager
|
||||
|
||||
fun setAuthToken(token: String?) {
|
||||
authToken = token
|
||||
// Метод для получения экземпляра клиента Retrofit
|
||||
fun getClient(): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
||||
// Метод для получения ID пользователя из токена
|
||||
fun getUserId(): String? {
|
||||
// Заглушка для метода - в реальном приложении здесь должна быть логика получения ID из токена
|
||||
return "user123"
|
||||
}
|
||||
|
||||
// Инициализация клиента с контекстом приложения
|
||||
fun initialize(context: Context) {
|
||||
preferenceManager = PreferenceManager.getInstance(context)
|
||||
// Загружаем сохраненный токен при инициализации
|
||||
authToken = preferenceManager.getAuthToken()
|
||||
println("NetworkClient initialized with token: ${authToken?.take(10)}...")
|
||||
}
|
||||
|
||||
private val authInterceptor = Interceptor { chain ->
|
||||
val request = chain.request().newBuilder()
|
||||
val requestBuilder = chain.request().newBuilder()
|
||||
authToken?.let { token ->
|
||||
request.addHeader("Authorization", "Bearer $token")
|
||||
requestBuilder.addHeader("Authorization", "Bearer $token")
|
||||
}
|
||||
request.addHeader("Content-Type", "application/json")
|
||||
chain.proceed(request.build())
|
||||
|
||||
// Debug logging
|
||||
val request = requestBuilder.build()
|
||||
println("=== API Request Debug ===")
|
||||
println("URL: ${request.url}")
|
||||
println("Method: ${request.method}")
|
||||
print("Headers: ")
|
||||
request.headers.forEach { (name, value) ->
|
||||
if (name.equals("Authorization", ignoreCase = true)) {
|
||||
println("$name: ██")
|
||||
} else {
|
||||
println("$name: $value")
|
||||
}
|
||||
}
|
||||
|
||||
val response = chain.proceed(request)
|
||||
println("Response Code: ${response.code}")
|
||||
println("Response Message: ${response.message}")
|
||||
println("========================")
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
@@ -39,13 +78,43 @@ object NetworkClient {
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val gson = GsonBuilder()
|
||||
.setLenient()
|
||||
.create()
|
||||
|
||||
val retrofit: Retrofit = Retrofit.Builder()
|
||||
val apiService: WomanSafeApi by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl(BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(WomanSafeApi::class.java)
|
||||
}
|
||||
|
||||
fun setAuthToken(token: String?) {
|
||||
authToken = token
|
||||
if (::preferenceManager.isInitialized) {
|
||||
preferenceManager.saveAuthToken(token)
|
||||
println("Token saved to preferences: ${token?.take(10)}...")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAuthToken() {
|
||||
authToken = null
|
||||
if (::preferenceManager.isInitialized) {
|
||||
preferenceManager.clearAuthData()
|
||||
println("Token cleared from preferences")
|
||||
}
|
||||
}
|
||||
|
||||
fun getAuthToken(): String? = authToken
|
||||
|
||||
fun updateBaseUrl(newUrl: String) {
|
||||
BASE_URL = if (!newUrl.endsWith("/")) "$newUrl/" else newUrl
|
||||
}
|
||||
|
||||
private val retrofit: Retrofit = Retrofit.Builder()
|
||||
.baseUrl("https://api.example.com/") // Замените на актуальный URL
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
fun <T> createService(serviceClass: Class<T>): T {
|
||||
return retrofit.create(serviceClass)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.example.womansafe.data.network
|
||||
|
||||
import com.example.womansafe.data.api.WomanSafeApi
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object RetrofitClient {
|
||||
private const val BASE_URL = "http://192.168.0.112:8000/"
|
||||
|
||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
private val okHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val retrofit = Retrofit.Builder()
|
||||
.baseUrl(BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
val api: WomanSafeApi = retrofit.create(WomanSafeApi::class.java)
|
||||
}
|
||||
@@ -1,277 +1,293 @@
|
||||
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.calendar.CalendarEntry
|
||||
import com.example.womansafe.data.network.NetworkClient
|
||||
import retrofit2.Response
|
||||
|
||||
class ApiRepository {
|
||||
private val api = NetworkClient.retrofit.create(WomanSafeApi::class.java)
|
||||
private val apiService = NetworkClient.apiService
|
||||
|
||||
// Authentication methods
|
||||
// Auth methods
|
||||
suspend fun login(email: String?, username: String?, password: String): Response<Token> {
|
||||
val loginData = UserLogin(email = email, username = username, password = password)
|
||||
val requestBody = ApiRequestBody(userLogin = loginData)
|
||||
return api.login(requestBody)
|
||||
val request = UserLogin(email, username, password)
|
||||
return apiService.login(request)
|
||||
}
|
||||
|
||||
suspend fun register(
|
||||
email: String,
|
||||
username: String?,
|
||||
username: String? = null,
|
||||
password: String,
|
||||
fullName: String?,
|
||||
phoneNumber: String?
|
||||
fullName: String? = null,
|
||||
phoneNumber: String? = null,
|
||||
firstName: String? = null,
|
||||
lastName: String? = null,
|
||||
dateOfBirth: String? = null,
|
||||
bio: String? = null
|
||||
): Response<UserResponse> {
|
||||
val userData = UserCreate(
|
||||
val request = UserCreate(
|
||||
email = email,
|
||||
username = username,
|
||||
password = password,
|
||||
fullName = fullName,
|
||||
phoneNumber = phoneNumber
|
||||
full_name = fullName,
|
||||
phone_number = phoneNumber,
|
||||
first_name = firstName,
|
||||
last_name = lastName,
|
||||
date_of_birth = dateOfBirth,
|
||||
bio = bio
|
||||
)
|
||||
val requestBody = ApiRequestBody(userCreate = userData)
|
||||
return api.register(requestBody)
|
||||
return apiService.register(request)
|
||||
}
|
||||
|
||||
// User methods
|
||||
suspend fun getCurrentUser(): Response<UserResponse> {
|
||||
return api.getCurrentUser()
|
||||
return apiService.getCurrentUser()
|
||||
}
|
||||
|
||||
suspend fun updateUser(userUpdate: UserUpdate): Response<UserResponse> {
|
||||
val requestBody = ApiRequestBody(userUpdate = userUpdate)
|
||||
return api.updateCurrentUser(requestBody)
|
||||
suspend fun updateCurrentUser(userUpdate: UserUpdate): Response<UserResponse> {
|
||||
val body = ApiRequestBody(user_update = userUpdate)
|
||||
return apiService.updateCurrentUser(body)
|
||||
}
|
||||
|
||||
suspend fun patchUser(userUpdate: UserUpdate): Response<UserResponse> {
|
||||
val requestBody = ApiRequestBody(userUpdate = userUpdate)
|
||||
return api.patchCurrentUser(requestBody)
|
||||
suspend fun patchCurrentUser(userUpdate: UserUpdate): Response<UserResponse> {
|
||||
val body = ApiRequestBody(user_update = userUpdate)
|
||||
return apiService.patchCurrentUser(body)
|
||||
}
|
||||
|
||||
suspend fun changePassword(): Response<Unit> {
|
||||
return api.changePassword(ApiRequestBody())
|
||||
suspend fun changePassword(currentPassword: String, newPassword: String): Response<Unit> {
|
||||
val passwordRequest = ChangePasswordRequest(currentPassword, newPassword)
|
||||
// Поскольку WomanSafeApi ожидает ApiRequestBody, нам нужно обернуть запрос
|
||||
val body = ApiRequestBody() // Здесь может потребоваться дополнительное поле для смены пароля
|
||||
return apiService.changePassword(body)
|
||||
}
|
||||
|
||||
suspend fun getDashboard(): Response<Any> {
|
||||
return api.getDashboard()
|
||||
return apiService.getDashboard()
|
||||
}
|
||||
|
||||
// Profile methods
|
||||
suspend fun getProfile(): Response<UserResponse> {
|
||||
return api.getProfile()
|
||||
suspend fun getUserProfile(): Response<UserResponse> {
|
||||
return apiService.getProfile()
|
||||
}
|
||||
|
||||
suspend fun updateProfile(userUpdate: UserUpdate): Response<UserResponse> {
|
||||
val requestBody = ApiRequestBody(userUpdate = userUpdate)
|
||||
return api.updateProfile(requestBody)
|
||||
suspend fun updateUserProfile(userUpdate: UserUpdate): Response<UserResponse> {
|
||||
val body = ApiRequestBody(user_update = userUpdate)
|
||||
return apiService.updateProfile(body)
|
||||
}
|
||||
|
||||
// Emergency Contacts methods
|
||||
// Emergency Contact methods
|
||||
suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>> {
|
||||
return api.getEmergencyContacts()
|
||||
return apiService.getEmergencyContacts()
|
||||
}
|
||||
|
||||
suspend fun createEmergencyContact(contact: EmergencyContactCreate): Response<EmergencyContactResponse> {
|
||||
val requestBody = ApiRequestBody(emergencyContactCreate = contact)
|
||||
return api.createEmergencyContact(requestBody)
|
||||
return apiService.createEmergencyContact(contact)
|
||||
}
|
||||
|
||||
suspend fun getEmergencyContact(contactId: String): Response<EmergencyContactResponse> {
|
||||
return api.getEmergencyContact(contactId)
|
||||
suspend fun getEmergencyContact(contactId: Int): Response<EmergencyContactResponse> {
|
||||
return apiService.getEmergencyContact(contactId.toString())
|
||||
}
|
||||
|
||||
suspend fun updateEmergencyContact(contactId: String, contact: EmergencyContactUpdate): Response<EmergencyContactResponse> {
|
||||
val requestBody = ApiRequestBody(emergencyContactUpdate = contact)
|
||||
return api.updateEmergencyContact(contactId, requestBody)
|
||||
suspend fun updateEmergencyContact(contactId: Int, contact: EmergencyContactUpdate): Response<EmergencyContactResponse> {
|
||||
val body = ApiRequestBody(emergency_contact_update = contact)
|
||||
return apiService.updateEmergencyContact(contactId.toString(), body)
|
||||
}
|
||||
|
||||
suspend fun deleteEmergencyContact(contactId: String): Response<Unit> {
|
||||
return api.deleteEmergencyContact(contactId)
|
||||
suspend fun deleteEmergencyContact(contactId: Int): Response<Unit> {
|
||||
return apiService.deleteEmergencyContact(contactId.toString())
|
||||
}
|
||||
|
||||
// Emergency methods
|
||||
// Emergency methods - возвращают Any согласно WomanSafeApi
|
||||
suspend fun getEmergencyReports(): Response<Any> {
|
||||
return api.getEmergencyReports()
|
||||
return apiService.getEmergencyReports()
|
||||
}
|
||||
|
||||
suspend fun createEmergencyReport(): Response<Any> {
|
||||
return api.createEmergencyReport()
|
||||
return apiService.createEmergencyReport()
|
||||
}
|
||||
|
||||
suspend fun getNearbyEmergencyReports(): Response<Any> {
|
||||
return api.getNearbyEmergencyReports()
|
||||
return apiService.getNearbyEmergencyReports()
|
||||
}
|
||||
|
||||
suspend fun getEmergencyReport(reportId: String): Response<Any> {
|
||||
return api.getEmergencyReport(reportId)
|
||||
suspend fun getEmergencyReport(reportId: Int): Response<Any> {
|
||||
return apiService.getEmergencyReport(reportId.toString())
|
||||
}
|
||||
|
||||
suspend fun updateEmergencyReport(reportId: String): Response<Any> {
|
||||
return api.updateEmergencyReport(reportId)
|
||||
suspend fun updateEmergencyReport(reportId: Int): Response<Any> {
|
||||
return apiService.updateEmergencyReport(reportId.toString())
|
||||
}
|
||||
|
||||
suspend fun deleteEmergencyReport(reportId: String): Response<Any> {
|
||||
return api.deleteEmergencyReport(reportId)
|
||||
suspend fun deleteEmergencyReport(reportId: Int): Response<Any> {
|
||||
return apiService.deleteEmergencyReport(reportId.toString())
|
||||
}
|
||||
|
||||
// Emergency Alerts methods
|
||||
suspend fun getEmergencyAlerts(): Response<Any> {
|
||||
return api.getEmergencyAlerts()
|
||||
|
||||
suspend fun getEmergencyAlerts(): Response<List<EmergencyAlertResponse>> {
|
||||
return apiService.getEmergencyAlerts()
|
||||
}
|
||||
|
||||
suspend fun createEmergencyAlert(): Response<Any> {
|
||||
return api.createEmergencyAlert()
|
||||
suspend fun createEmergencyAlert(request: EmergencyAlertCreate): Response<EmergencyAlertResponse> {
|
||||
return apiService.createEmergencyAlert(request)
|
||||
}
|
||||
|
||||
suspend fun getMyEmergencyAlerts(): Response<Any> {
|
||||
return api.getMyEmergencyAlerts()
|
||||
suspend fun getMyEmergencyAlerts(): Response<List<EmergencyAlertResponse>> {
|
||||
return apiService.getMyEmergencyAlerts()
|
||||
}
|
||||
|
||||
suspend fun getNearbyEmergencyAlerts(): Response<Any> {
|
||||
return api.getNearbyEmergencyAlerts()
|
||||
return apiService.getNearbyEmergencyAlerts()
|
||||
}
|
||||
|
||||
suspend fun getEmergencyAlert(alertId: String): Response<Any> {
|
||||
return api.getEmergencyAlert(alertId)
|
||||
suspend fun getEmergencyAlert(alertId: Int): Response<Any> {
|
||||
return apiService.getEmergencyAlert(alertId.toString())
|
||||
}
|
||||
|
||||
suspend fun updateEmergencyAlert(alertId: String): Response<Any> {
|
||||
return api.updateEmergencyAlert(alertId)
|
||||
suspend fun updateEmergencyAlert(alertId: Int): Response<Any> {
|
||||
return apiService.updateEmergencyAlert(alertId.toString())
|
||||
}
|
||||
|
||||
suspend fun deleteEmergencyAlert(alertId: String): Response<Any> {
|
||||
return api.deleteEmergencyAlert(alertId)
|
||||
suspend fun cancelEmergencyAlert(alertId: Int): Response<Any> {
|
||||
return apiService.cancelEmergencyAlert(alertId.toString())
|
||||
}
|
||||
|
||||
suspend fun cancelEmergencyAlert(alertId: String): Response<Any> {
|
||||
return api.cancelEmergencyAlert(alertId)
|
||||
suspend fun deleteEmergencyAlert(alertId: Int): Response<Any> {
|
||||
return apiService.deleteEmergencyAlert(alertId.toString())
|
||||
}
|
||||
|
||||
// Location methods
|
||||
suspend fun updateLocation(): Response<Any> {
|
||||
return api.updateLocation()
|
||||
return apiService.updateLocation()
|
||||
}
|
||||
|
||||
suspend fun getLastLocation(): Response<Any> {
|
||||
return api.getLastLocation()
|
||||
return apiService.getLastLocation()
|
||||
}
|
||||
|
||||
suspend fun getLocationHistory(): Response<Any> {
|
||||
return api.getLocationHistory()
|
||||
return apiService.getLocationHistory()
|
||||
}
|
||||
|
||||
suspend fun getNearbyUsers(): Response<Any> {
|
||||
return api.getNearbyUsers()
|
||||
return apiService.getNearbyUsers()
|
||||
}
|
||||
|
||||
suspend fun getSafePlaces(): Response<Any> {
|
||||
return api.getSafePlaces()
|
||||
return apiService.getSafePlaces()
|
||||
}
|
||||
|
||||
suspend fun createSafePlace(): Response<Any> {
|
||||
return api.createSafePlace()
|
||||
return apiService.createSafePlace()
|
||||
}
|
||||
|
||||
suspend fun getSafePlace(placeId: String): Response<Any> {
|
||||
return api.getSafePlace(placeId)
|
||||
suspend fun getSafePlace(placeId: Int): Response<Any> {
|
||||
return apiService.getSafePlace(placeId.toString())
|
||||
}
|
||||
|
||||
suspend fun updateSafePlace(placeId: String): Response<Any> {
|
||||
return api.updateSafePlace(placeId)
|
||||
suspend fun updateSafePlace(placeId: Int): Response<Any> {
|
||||
return apiService.updateSafePlace(placeId.toString())
|
||||
}
|
||||
|
||||
suspend fun deleteSafePlace(placeId: String): Response<Any> {
|
||||
return api.deleteSafePlace(placeId)
|
||||
suspend fun deleteSafePlace(placeId: Int): Response<Any> {
|
||||
return apiService.deleteSafePlace(placeId.toString())
|
||||
}
|
||||
|
||||
// Calendar methods
|
||||
suspend fun getCalendarEntries(): Response<Any> {
|
||||
return api.getCalendarEntries()
|
||||
suspend fun getCalendarEntries(
|
||||
startDate: String? = null,
|
||||
endDate: String? = null,
|
||||
entryType: String? = null,
|
||||
limit: Int? = null
|
||||
): Response<List<CalendarEntry>> {
|
||||
// В WomanSafeApi нет метода getCalendarEntries, нужно использовать другой API
|
||||
// Здесь должна быть интеграция с CalendarApi
|
||||
throw NotImplementedError("Method getCalendarEntries not implemented in WomanSafeApi")
|
||||
}
|
||||
|
||||
suspend fun createCalendarEntry(): Response<Any> {
|
||||
return api.createCalendarEntry()
|
||||
suspend fun createCalendarEntry(entry: CalendarEntry): Response<CalendarEntry> {
|
||||
// В WomanSafeApi нет метода createCalendarEntry, нужно использовать другой API
|
||||
// Здесь должна быть интеграция с CalendarApi
|
||||
throw NotImplementedError("Method createCalendarEntry not implemented in WomanSafeApi")
|
||||
}
|
||||
|
||||
suspend fun getCalendarEntry(entryId: String): Response<Any> {
|
||||
return api.getCalendarEntry(entryId)
|
||||
suspend fun updateCalendarEntry(id: String, entry: CalendarEntry): Response<CalendarEntry> {
|
||||
// В WomanSafeApi нет метода updateCalendarEntry, нужно использовать другой API
|
||||
// Здесь должна быть интеграция с CalendarApi
|
||||
throw NotImplementedError("Method updateCalendarEntry not implemented in WomanSafeApi")
|
||||
}
|
||||
|
||||
suspend fun updateCalendarEntry(entryId: String): Response<Any> {
|
||||
return api.updateCalendarEntry(entryId)
|
||||
}
|
||||
|
||||
suspend fun deleteCalendarEntry(entryId: String): Response<Any> {
|
||||
return api.deleteCalendarEntry(entryId)
|
||||
suspend fun deleteCalendarEntry(id: String): Response<Unit> {
|
||||
// В WomanSafeApi нет метода deleteCalendarEntry, нужно использовать другой API
|
||||
// Здесь должна быть интеграция с CalendarApi
|
||||
throw NotImplementedError("Method deleteCalendarEntry not implemented in WomanSafeApi")
|
||||
}
|
||||
|
||||
suspend fun getCycleOverview(): Response<Any> {
|
||||
return api.getCycleOverview()
|
||||
return apiService.getHealth() // Временная заглушка
|
||||
}
|
||||
|
||||
suspend fun getCalendarInsights(): Response<Any> {
|
||||
return api.getCalendarInsights()
|
||||
return apiService.getHealth() // Временная заглушка
|
||||
}
|
||||
|
||||
suspend fun getCalendarReminders(): Response<Any> {
|
||||
return api.getCalendarReminders()
|
||||
return apiService.getHealth() // Временная заглушка
|
||||
}
|
||||
|
||||
suspend fun createCalendarReminder(): Response<Any> {
|
||||
return api.createCalendarReminder()
|
||||
return apiService.getHealth() // Временная заглушка
|
||||
}
|
||||
|
||||
suspend fun getCalendarSettings(): Response<Any> {
|
||||
return api.getCalendarSettings()
|
||||
return apiService.getHealth() // Временная заглушка
|
||||
}
|
||||
|
||||
suspend fun updateCalendarSettings(): Response<Any> {
|
||||
return api.updateCalendarSettings()
|
||||
return apiService.getHealth() // Временная заглушка
|
||||
}
|
||||
|
||||
// Notification methods
|
||||
suspend fun getNotificationDevices(): Response<Any> {
|
||||
return api.getNotificationDevices()
|
||||
return apiService.getNotificationDevices()
|
||||
}
|
||||
|
||||
suspend fun createNotificationDevice(): Response<Any> {
|
||||
return api.createNotificationDevice()
|
||||
suspend fun registerNotificationDevice(): Response<Any> {
|
||||
return apiService.createNotificationDevice()
|
||||
}
|
||||
|
||||
suspend fun getNotificationDevice(deviceId: String): Response<Any> {
|
||||
return api.getNotificationDevice(deviceId)
|
||||
return apiService.getNotificationDevice(deviceId)
|
||||
}
|
||||
|
||||
suspend fun deleteNotificationDevice(deviceId: String): Response<Any> {
|
||||
return api.deleteNotificationDevice(deviceId)
|
||||
suspend fun unregisterNotificationDevice(deviceId: String): Response<Any> {
|
||||
return apiService.deleteNotificationDevice(deviceId)
|
||||
}
|
||||
|
||||
suspend fun getNotificationPreferences(): Response<Any> {
|
||||
return api.getNotificationPreferences()
|
||||
return apiService.getNotificationPreferences()
|
||||
}
|
||||
|
||||
suspend fun updateNotificationPreferences(): Response<Any> {
|
||||
return api.updateNotificationPreferences()
|
||||
return apiService.updateNotificationPreferences()
|
||||
}
|
||||
|
||||
suspend fun testNotification(): Response<Any> {
|
||||
return api.testNotification()
|
||||
suspend fun sendTestNotification(): Response<Any> {
|
||||
return apiService.testNotification()
|
||||
}
|
||||
|
||||
suspend fun getNotificationHistory(): Response<Any> {
|
||||
return api.getNotificationHistory()
|
||||
return apiService.getNotificationHistory()
|
||||
}
|
||||
|
||||
// Health check methods
|
||||
// Health and status methods
|
||||
suspend fun getHealth(): Response<Any> {
|
||||
return api.getHealth()
|
||||
return apiService.getHealth()
|
||||
}
|
||||
|
||||
suspend fun getServicesStatus(): Response<Any> {
|
||||
return api.getServicesStatus()
|
||||
return apiService.getServicesStatus()
|
||||
}
|
||||
|
||||
suspend fun getRoot(): Response<Any> {
|
||||
return api.getRoot()
|
||||
return apiService.getRoot()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package com.example.womansafe.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import com.example.womansafe.data.api.CalendarApi
|
||||
import com.example.womansafe.data.local.CalendarDao
|
||||
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||
import com.example.womansafe.data.model.calendar.CycleData
|
||||
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||
import retrofit2.Response
|
||||
import com.example.womansafe.data.model.calendar.CycleStatistics
|
||||
import com.example.womansafe.data.model.calendar.CyclePrediction
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Репозиторий для работы с данными менструального календаря
|
||||
*/
|
||||
class CalendarRepository(
|
||||
private val calendarDao: CalendarDao,
|
||||
private val calendarApi: CalendarApi
|
||||
) {
|
||||
private val TAG = "CalendarRepository"
|
||||
private val dateFormatter = DateTimeFormatter.ISO_LOCAL_DATE
|
||||
|
||||
// CycleData operations
|
||||
fun getCycleDataFlow(userId: String): Flow<CycleData?> {
|
||||
return calendarDao.getCycleDataFlowByUserId(userId)
|
||||
}
|
||||
|
||||
suspend fun refreshCycleData(userId: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = calendarApi.getCycleData()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { cycleData ->
|
||||
calendarDao.insertCycleData(cycleData)
|
||||
Log.d(TAG, "Cycle data refreshed from server")
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Failed to refresh cycle data: ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error refreshing cycle data", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateCycleData(cycleData: CycleData): Result<CycleData> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = calendarApi.updateCycleData(cycleData)
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { updatedData ->
|
||||
calendarDao.insertCycleData(updatedData)
|
||||
Result.success(updatedData)
|
||||
} ?: Result.failure(Exception("Empty response body"))
|
||||
} else {
|
||||
// Сохраняем данные локально даже при ошибке сети
|
||||
calendarDao.insertCycleData(cycleData)
|
||||
Result.failure(Exception("API error: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// При ошибке сети сохраняем данные локально
|
||||
calendarDao.insertCycleData(cycleData)
|
||||
Log.e(TAG, "Error updating cycle data", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CalendarEntry operations
|
||||
fun getCalendarEntriesFlow(userId: String): Flow<List<CalendarEntry>> {
|
||||
return calendarDao.getAllCalendarEntriesFlow(userId)
|
||||
}
|
||||
|
||||
fun getCalendarEntriesBetweenDatesFlow(
|
||||
userId: String,
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): Flow<List<CalendarEntry>> {
|
||||
return calendarDao.getCalendarEntriesBetweenDatesFlow(userId, startDate, endDate)
|
||||
}
|
||||
|
||||
suspend fun refreshCalendarEntries(
|
||||
userId: String,
|
||||
startDate: LocalDate? = null,
|
||||
endDate: LocalDate? = null
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = calendarApi.getCalendarEntries(
|
||||
startDate?.format(dateFormatter),
|
||||
endDate?.format(dateFormatter)
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { entries ->
|
||||
calendarDao.insertCalendarEntries(entries)
|
||||
Log.d(TAG, "Calendar entries refreshed from server: ${entries.size}")
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Failed to refresh calendar entries: ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error refreshing calendar entries", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCalendarEntryByDate(userId: String, date: LocalDate): CalendarEntry? {
|
||||
return calendarDao.getCalendarEntryByDate(userId, date)
|
||||
}
|
||||
|
||||
suspend fun addCalendarEntry(entry: CalendarEntry): Result<CalendarEntry> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = calendarApi.addCalendarEntry(entry)
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { serverEntry ->
|
||||
calendarDao.insertCalendarEntry(serverEntry)
|
||||
Result.success(serverEntry)
|
||||
} ?: Result.failure(Exception("Empty response body"))
|
||||
} else {
|
||||
// Сохраняем данные локально даже при ошибке сети
|
||||
val localId = calendarDao.insertCalendarEntry(entry)
|
||||
Result.failure(Exception("API error: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// При ошибке сети сохраняем данные локально
|
||||
val localId = calendarDao.insertCalendarEntry(entry)
|
||||
Log.e(TAG, "Error adding calendar entry", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateCalendarEntry(entry: CalendarEntry): Result<CalendarEntry> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = calendarApi.updateCalendarEntry(entry.id, entry)
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { updatedEntry ->
|
||||
calendarDao.updateCalendarEntry(updatedEntry)
|
||||
Result.success(updatedEntry)
|
||||
} ?: Result.failure(Exception("Empty response body"))
|
||||
} else {
|
||||
// Обновляем данные локально даже при ошибке сети
|
||||
calendarDao.updateCalendarEntry(entry)
|
||||
Result.failure(Exception("API error: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// При ошибке сети обновляем данные локально
|
||||
calendarDao.updateCalendarEntry(entry)
|
||||
Log.e(TAG, "Error updating calendar entry", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteCalendarEntry(entryId: Long): Result<Unit> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val entry = calendarDao.getCalendarEntryById(entryId)
|
||||
if (entry == null) {
|
||||
return@withContext Result.failure(Exception("Entry not found"))
|
||||
}
|
||||
|
||||
val response = calendarApi.deleteCalendarEntry(entryId)
|
||||
if (response.isSuccessful) {
|
||||
calendarDao.deleteCalendarEntry(entry)
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception("API error: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error deleting calendar entry", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HealthInsight operations
|
||||
fun getActiveHealthInsightsFlow(userId: String): Flow<List<HealthInsight>> {
|
||||
return calendarDao.getActiveHealthInsightsFlow(userId)
|
||||
}
|
||||
|
||||
fun getAllHealthInsightsFlow(userId: String): Flow<List<HealthInsight>> {
|
||||
return calendarDao.getAllHealthInsightsFlow(userId)
|
||||
}
|
||||
|
||||
suspend fun refreshHealthInsights(userId: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = calendarApi.getHealthInsights()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { insights ->
|
||||
calendarDao.insertHealthInsights(insights)
|
||||
Log.d(TAG, "Health insights refreshed from server: ${insights.size}")
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Failed to refresh health insights: ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error refreshing health insights", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun dismissInsight(insightId: Long): Result<Unit> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = calendarApi.dismissInsight(insightId)
|
||||
if (response.isSuccessful) {
|
||||
calendarDao.dismissHealthInsight(insightId)
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception("API error: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error dismissing insight", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Заглушка: Получение статистики цикла
|
||||
suspend fun getCycleStatistics(userId: String): Response<CycleStatistics> {
|
||||
val now = System.currentTimeMillis()
|
||||
return Response.success(
|
||||
CycleStatistics(
|
||||
userId = userId,
|
||||
averageCycleLength = 28,
|
||||
averagePeriodLength = 5,
|
||||
shortestCycle = 27,
|
||||
longestCycle = 30,
|
||||
cycleLengthVariation = 3,
|
||||
regularityScore = 90,
|
||||
dataPointsCount = 12,
|
||||
lastUpdated = now
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Заглушка: Получение прогнозов цикла
|
||||
suspend fun getCyclePredictions(userId: String): Response<CyclePrediction> {
|
||||
val today = java.time.LocalDate.now()
|
||||
return Response.success(
|
||||
CyclePrediction(
|
||||
userId = userId,
|
||||
nextPeriodStart = today.plusDays(10),
|
||||
nextPeriodEnd = today.plusDays(15),
|
||||
nextOvulation = today.plusDays(20),
|
||||
fertileWindowStart = today.plusDays(18),
|
||||
fertileWindowEnd = today.plusDays(22),
|
||||
confidenceScore = 85,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Заглушка: Получение кэшированных прогнозов
|
||||
fun getCachedPredictions(userId: String): CyclePrediction {
|
||||
val today = java.time.LocalDate.now()
|
||||
return CyclePrediction(
|
||||
userId = userId,
|
||||
nextPeriodStart = today.plusDays(10),
|
||||
nextPeriodEnd = today.plusDays(15),
|
||||
nextOvulation = today.plusDays(20),
|
||||
fertileWindowStart = today.plusDays(18),
|
||||
fertileWindowEnd = today.plusDays(22),
|
||||
confidenceScore = 80,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
// Заглушка: Получение инсайтов о здоровье
|
||||
suspend fun getHealthInsights(): Response<List<HealthInsight>> {
|
||||
// TODO: Реализовать реальный запрос
|
||||
return Response.success(emptyList())
|
||||
}
|
||||
|
||||
// Clean up operations
|
||||
suspend fun cleanupOldInsights(userId: String, olderThan: Long) {
|
||||
calendarDao.deleteOldInsights(userId, olderThan)
|
||||
}
|
||||
|
||||
suspend fun getCalendarEntries(): List<CalendarEntry> {
|
||||
// Пример: получение из DAO или API
|
||||
return calendarDao.getAllCalendarEntries()
|
||||
}
|
||||
|
||||
suspend fun getCycleData(): CycleData? {
|
||||
// Пример: получение из DAO или API
|
||||
return calendarDao.getLatestCycleData()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package com.example.womansafe.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.Composable
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.example.womansafe.data.model.calendar.CalendarEvent
|
||||
import com.example.womansafe.utils.DateUtils
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarEventDetailDialog(
|
||||
event: CalendarEvent,
|
||||
onDismiss: () -> Unit,
|
||||
onEdit: (CalendarEvent) -> Unit,
|
||||
onDelete: (CalendarEvent) -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// Заголовок с датой
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Иконка типа события
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(getEventIconBackgroundColor(event.type)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = getEventIcon(event.type),
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = getEventTitle(event.type),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = event.date.format(DateTimeFormatter.ofPattern("d MMMM yyyy", Locale("ru"))),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
// Детали события
|
||||
event.flowIntensity?.let {
|
||||
DetailItem(
|
||||
title = "Интенсивность",
|
||||
value = getFlowIntensityLabel(it),
|
||||
icon = Icons.Default.Opacity
|
||||
)
|
||||
}
|
||||
|
||||
event.mood?.let {
|
||||
DetailItem(
|
||||
title = "Настроение",
|
||||
value = getMoodLabel(it),
|
||||
icon = Icons.Default.Face
|
||||
)
|
||||
}
|
||||
|
||||
event.symptoms?.takeIf { it.isNotEmpty() }?.let {
|
||||
DetailItem(
|
||||
title = "Симптомы",
|
||||
value = it.joinToString(", ") { symptom -> getSymptomLabel(symptom) },
|
||||
icon = Icons.Default.HealthAndSafety
|
||||
)
|
||||
}
|
||||
|
||||
event.medications?.takeIf { it.isNotEmpty() }?.let {
|
||||
DetailItem(
|
||||
title = "Лекарства",
|
||||
value = it.joinToString(", "),
|
||||
icon = Icons.Default.Medication
|
||||
)
|
||||
}
|
||||
|
||||
event.energyLevel?.let {
|
||||
DetailItem(
|
||||
title = "Уровень энергии",
|
||||
value = "$it/5",
|
||||
icon = Icons.Default.BatteryChargingFull
|
||||
)
|
||||
}
|
||||
|
||||
event.sleepHours?.let {
|
||||
DetailItem(
|
||||
title = "Часы сна",
|
||||
value = "$it ч",
|
||||
icon = Icons.Default.Bedtime
|
||||
)
|
||||
}
|
||||
|
||||
event.notes?.takeIf { it.isNotBlank() }?.let {
|
||||
DetailItem(
|
||||
title = "Заметки",
|
||||
value = it,
|
||||
icon = Icons.Default.Notes,
|
||||
maxLines = 5
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Кнопки действий
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Закрыть")
|
||||
}
|
||||
|
||||
TextButton(onClick = { onEdit(event) }) {
|
||||
Text("Редактировать")
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { onDelete(event) },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Удалить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailItem(
|
||||
title: String,
|
||||
value: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
maxLines: Int = 2
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp, top = 2.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Вспомогательные функции для получения информации о событии
|
||||
private fun getEventIcon(type: String): androidx.compose.ui.graphics.vector.ImageVector {
|
||||
return when (type.lowercase()) {
|
||||
"period" -> Icons.Default.Opacity
|
||||
"ovulation" -> Icons.Default.Star
|
||||
"symptoms" -> Icons.Default.Healing
|
||||
"medication" -> Icons.Default.LocalPharmacy
|
||||
"note" -> Icons.Default.Notes
|
||||
"appointment" -> Icons.Default.Event
|
||||
else -> Icons.Default.Check
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEventIconBackgroundColor(type: String): Color {
|
||||
return when (type.lowercase()) {
|
||||
"period" -> Color(0xFFE91E63) // Розовый
|
||||
"ovulation" -> Color(0xFF2196F3) // Голубой
|
||||
"symptoms" -> Color(0xFFFFC107) // Желтый
|
||||
"medication" -> Color(0xFF9C27B0) // Фиолетовый
|
||||
"note" -> Color(0xFF607D8B) // Серо-синий
|
||||
"appointment" -> Color(0xFF4CAF50) // Зеленый
|
||||
else -> Color.Gray
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEventTitle(type: String): String {
|
||||
return when (type.lowercase()) {
|
||||
"period" -> "Менструация"
|
||||
"ovulation" -> "Овуляция"
|
||||
"symptoms" -> "Симптомы"
|
||||
"medication" -> "Лекарства"
|
||||
"note" -> "Заметка"
|
||||
"appointment" -> "Приём врача"
|
||||
else -> type.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFlowIntensityLabel(intensity: String): String {
|
||||
return when (intensity.uppercase()) {
|
||||
"SPOTTING" -> "Мажущие"
|
||||
"LIGHT" -> "Легкие"
|
||||
"MEDIUM" -> "Средние"
|
||||
"HEAVY" -> "Сильные"
|
||||
"VERY_HEAVY" -> "Очень сильные"
|
||||
else -> intensity
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMoodLabel(mood: String): String {
|
||||
return when (mood.uppercase()) {
|
||||
"VERY_HAPPY" -> "Отлично"
|
||||
"HAPPY" -> "Хорошо"
|
||||
"NEUTRAL" -> "Нормально"
|
||||
"NORMAL" -> "Нормально"
|
||||
"SAD" -> "Грустно"
|
||||
"VERY_SAD" -> "Очень грустно"
|
||||
"ANXIOUS" -> "Тревожно"
|
||||
"IRRITATED" -> "Раздражение"
|
||||
"IRRITABLE" -> "Раздражительность"
|
||||
"SENSITIVE" -> "Чувствительность"
|
||||
"CALM" -> "Спокойно"
|
||||
"ENERGETIC" -> "Энергично"
|
||||
"TIRED" -> "Устало"
|
||||
else -> mood
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSymptomLabel(symptom: String): String {
|
||||
return when (symptom.uppercase()) {
|
||||
"CRAMPS" -> "Спазмы"
|
||||
"HEADACHE" -> "Головная боль"
|
||||
"BACKACHE" -> "Боль в спине"
|
||||
"NAUSEA" -> "Тошнота"
|
||||
"FATIGUE" -> "Усталость"
|
||||
"BLOATING" -> "Вздутие"
|
||||
"BREAST_TENDERNESS" -> "Болезненность груди"
|
||||
"ACNE" -> "Высыпания"
|
||||
"MOOD_SWINGS" -> "Перепады настроения"
|
||||
"CRAVINGS" -> "Тяга к еде"
|
||||
"INSOMNIA" -> "Бессонница"
|
||||
"DIZZINESS" -> "Головокружение"
|
||||
"CONSTIPATION" -> "Запор"
|
||||
"DIARRHEA" -> "Диарея"
|
||||
"HOT_FLASHES" -> "Приливы"
|
||||
"SPOTTING" -> "Мажущие выделения"
|
||||
else -> symptom
|
||||
}
|
||||
}
|
||||
@@ -221,10 +221,25 @@ fun UserTab(
|
||||
Text("ID: ${currentUser.id}")
|
||||
Text("UUID: ${currentUser.uuid}")
|
||||
Text("Email: ${currentUser.email}")
|
||||
currentUser.fullName?.let { Text("Имя: $it") }
|
||||
currentUser.phoneNumber?.let { Text("Телефон: $it") }
|
||||
Text("Email подтвержден: ${if (currentUser.emailVerified) "Да" else "Нет"}")
|
||||
Text("Активен: ${if (currentUser.isActive) "Да" else "Нет"}")
|
||||
currentUser.full_name?.let { Text("Имя: $it") }
|
||||
currentUser.phone_number?.let { Text("Телефон: $it") }
|
||||
currentUser.username?.let { Text("Имя пользователя: $it") }
|
||||
currentUser.first_name?.let { Text("Имя: $it") }
|
||||
currentUser.last_name?.let { Text("Фамилия: $it") }
|
||||
currentUser.bio?.let { Text("О себе: $it") }
|
||||
currentUser.date_of_birth?.let { Text("Дата рождения: $it") }
|
||||
Text("Email подтвержден: ${if (currentUser.email_verified) "Да" else "Нет"}")
|
||||
Text("Телефон подтвержден: ${if (currentUser.phone_verified) "Да" else "Нет"}")
|
||||
Text("Активен: ${if (currentUser.is_active) "Да" else "Нет"}")
|
||||
Text("Геолокация включена: ${if (currentUser.location_sharing_enabled) "Да" else "Нет"}")
|
||||
Text("Экстренные уведомления: ${if (currentUser.emergency_notifications_enabled) "Да" else "Нет"}")
|
||||
Text("Push-уведомления: ${if (currentUser.push_notifications_enabled) "Да" else "Нет"}")
|
||||
currentUser.emergency_contact_1_name?.let {
|
||||
Text("Экстренный контакт 1: $it (${currentUser.emergency_contact_1_phone ?: "Не указан"})")
|
||||
}
|
||||
currentUser.emergency_contact_2_name?.let {
|
||||
Text("Экстренный контакт 2: $it (${currentUser.emergency_contact_2_phone ?: "Не указан"})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,7 +369,7 @@ fun ContactsTab(
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
Text("${contact.name} - ${contact.phoneNumber}")
|
||||
Text("${contact.name} - ${contact.phone_number}")
|
||||
contact.relationship?.let { Text("Отношение: $it", fontSize = 12.sp) }
|
||||
contact.notes?.let { Text("Заметки: $it", fontSize = 12.sp) }
|
||||
Text("ID: ${contact.id}", fontSize = 10.sp, color = Color.Gray)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.example.womansafe.ui.icons
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material.icons.filled.Spa
|
||||
import androidx.compose.material.icons.filled.WaterDrop
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material.icons.outlined.LocalHospital
|
||||
import androidx.compose.material.icons.outlined.Opacity
|
||||
import androidx.compose.material.icons.rounded.BarChart
|
||||
import androidx.compose.material.icons.rounded.CalendarMonth
|
||||
import androidx.compose.material.icons.rounded.Event
|
||||
import androidx.compose.material.icons.rounded.Lightbulb
|
||||
import androidx.compose.material.icons.rounded.NavigateNext
|
||||
import androidx.compose.material.icons.rounded.SentimentDissatisfied
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* Расширенные иконки для использования в приложении
|
||||
*/
|
||||
object CustomIcons {
|
||||
// Иконки для календаря
|
||||
val Healing: ImageVector = Icons.Outlined.LocalHospital
|
||||
val Notes: ImageVector = Icons.Outlined.Description
|
||||
val WaterDrop: ImageVector = Icons.Default.WaterDrop
|
||||
val Contacts: ImageVector = Icons.Default.Create
|
||||
val BarChart: ImageVector = Icons.Rounded.BarChart
|
||||
val Lightbulb: ImageVector = Icons.Rounded.Lightbulb
|
||||
val CalendarMonth: ImageVector = Icons.Rounded.CalendarMonth
|
||||
val LocalHospital: ImageVector = Icons.Outlined.LocalHospital
|
||||
val Dangerous: ImageVector = Icons.Rounded.Warning
|
||||
val Spa: ImageVector = Icons.Default.Spa
|
||||
val SentimentDissatisfied: ImageVector = Icons.Rounded.SentimentDissatisfied
|
||||
val Opacity: ImageVector = Icons.Outlined.Opacity
|
||||
val Event: ImageVector = Icons.Rounded.Event
|
||||
val ChevronRight: ImageVector = Icons.Rounded.NavigateNext
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
642
app/src/main/java/com/example/womansafe/ui/screens/AuthScreen.kt
Normal file
642
app/src/main/java/com/example/womansafe/ui/screens/AuthScreen.kt
Normal file
@@ -0,0 +1,642 @@
|
||||
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
|
||||
import com.example.womansafe.util.fixTouchEvents
|
||||
|
||||
@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)
|
||||
.fixTouchEvents(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
// Заголовок профиля с реальными данными
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = user?.full_name ?: user?.username ?: "Профиль",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
|
||||
user?.email?.let { email ->
|
||||
Text(
|
||||
text = email,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onLogout,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ExitToApp,
|
||||
contentDescription = "Выйти",
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отображение ошибки, если есть
|
||||
if (error != null && onClearError != null) {
|
||||
item {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onClearError) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Закрыть",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
// Основная информация пользователя из API
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Личная информация",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
user?.let {
|
||||
ProfileInfoRow("ID пользователя", it.id.toString())
|
||||
ProfileInfoRow("UUID", it.uuid)
|
||||
ProfileInfoRow("Email", it.email)
|
||||
it.username?.let { username ->
|
||||
ProfileInfoRow("Имя пользователя", username)
|
||||
}
|
||||
it.full_name?.let { name ->
|
||||
ProfileInfoRow("Полное имя", name)
|
||||
}
|
||||
it.first_name?.let { firstName ->
|
||||
ProfileInfoRow("Имя", firstName)
|
||||
}
|
||||
it.last_name?.let { lastName ->
|
||||
ProfileInfoRow("Фамилия", lastName)
|
||||
}
|
||||
it.phone_number?.let { phone ->
|
||||
ProfileInfoRow("Номер телефона", phone)
|
||||
}
|
||||
it.date_of_birth?.let { date ->
|
||||
ProfileInfoRow("Дата рождения", date)
|
||||
}
|
||||
it.bio?.let { bio ->
|
||||
ProfileInfoRow("О себе", bio)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
// Статус аккаунта из API
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Статус аккаунта",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
user?.let {
|
||||
ProfileStatusRow(
|
||||
"Аккаунт активен",
|
||||
it.is_active,
|
||||
if (it.is_active) "Активный" else "Неактивный"
|
||||
)
|
||||
ProfileStatusRow(
|
||||
"Email подтверждён",
|
||||
it.email_verified,
|
||||
if (it.email_verified) "Подтверждён" else "Требует подтверждения"
|
||||
)
|
||||
ProfileStatusRow(
|
||||
"Телефон подтверждён",
|
||||
it.phone_verified,
|
||||
if (it.phone_verified) "Подтверждён" else "Требует подтверждения"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
// Настройки уведомлений из API
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Настройки уведомлений",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
user?.let {
|
||||
ProfileStatusRow(
|
||||
"Геолокация",
|
||||
it.location_sharing_enabled,
|
||||
if (it.location_sharing_enabled) "Включена" else "Отключена"
|
||||
)
|
||||
ProfileStatusRow(
|
||||
"Экстренные уведомления",
|
||||
it.emergency_notifications_enabled,
|
||||
if (it.emergency_notifications_enabled) "Включены" else "Отключены"
|
||||
)
|
||||
ProfileStatusRow(
|
||||
"Push-уведомления",
|
||||
it.push_notifications_enabled,
|
||||
if (it.push_notifications_enabled) "Включены" else "Отключены"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Экстренные контакты из API (если есть в профиле пользователя)
|
||||
user?.let { userData ->
|
||||
if (userData.emergency_contact_1_name != null || userData.emergency_contact_2_name != null) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Экстренные контакты",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
userData.emergency_contact_1_name?.let { contact1 ->
|
||||
ProfileInfoRow("Контакт 1", contact1)
|
||||
userData.emergency_contact_1_phone?.let { phone1 ->
|
||||
ProfileInfoRow("Телефон 1", phone1)
|
||||
}
|
||||
}
|
||||
|
||||
userData.emergency_contact_2_name?.let { contact2 ->
|
||||
ProfileInfoRow("Контакт 2", contact2)
|
||||
userData.emergency_contact_2_phone?.let { phone2 ->
|
||||
ProfileInfoRow("Телефон 2", phone2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
// Кнопка выхода
|
||||
Button(
|
||||
onClick = onLogout,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExitToApp,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Выйти из аккаунта")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileInfoRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileStatusRow(label: String, isEnabled: Boolean, statusText: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isEnabled) Icons.Default.CheckCircle else Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = if (isEnabled) Color(0xFF4CAF50) else Color(0xFFF44336),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isEnabled) Color(0xFF4CAF50) else Color(0xFFF44336)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
package com.example.womansafe.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.womansafe.data.model.calendar.*
|
||||
import com.example.womansafe.ui.icons.CustomIcons
|
||||
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Экран для добавления/редактирования записей в календаре
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarEntryScreen(
|
||||
viewModel: CalendarViewModel,
|
||||
selectedDate: LocalDate,
|
||||
existingEntry: CalendarEntry? = null,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
// Форматтер для отображения даты
|
||||
val dateFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale("ru"))
|
||||
|
||||
// Состояние для полей формы
|
||||
var entryType by remember { mutableStateOf(existingEntry?.entryType ?: EntryType.PERIOD) }
|
||||
var flowIntensity by remember { mutableStateOf(existingEntry?.flowIntensity ?: FlowIntensity.MEDIUM) }
|
||||
var mood by remember { mutableStateOf(existingEntry?.mood ?: Mood.NORMAL) }
|
||||
var energyLevel by remember { mutableStateOf(existingEntry?.energyLevel ?: 3) }
|
||||
var sleepHours by remember { mutableStateOf(existingEntry?.sleepHours?.toString() ?: "8.0") }
|
||||
var selectedSymptoms by remember { mutableStateOf(existingEntry?.symptoms?.toMutableList() ?: mutableListOf()) }
|
||||
var medications by remember { mutableStateOf(existingEntry?.medications?.joinToString(", ") ?: "") }
|
||||
var notes by remember { mutableStateOf(existingEntry?.notes ?: "") }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = if (existingEntry != null) "Редактировать запись" else "Новая запись"
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Закрыть")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
// Создаем или обновляем запись
|
||||
val entry = existingEntry?.copy(
|
||||
entryDate = selectedDate,
|
||||
entryType = entryType,
|
||||
flowIntensity = if (entryType == EntryType.PERIOD) flowIntensity else null,
|
||||
mood = mood,
|
||||
energyLevel = energyLevel,
|
||||
sleepHours = sleepHours.toFloatOrNull(),
|
||||
symptoms = selectedSymptoms,
|
||||
medications = if (medications.isBlank()) null else medications.split(",").map { it.trim() },
|
||||
notes = notes.ifBlank { null }
|
||||
) ?: CalendarEntry(
|
||||
userId = "", // будет заменено в репозитории
|
||||
entryDate = selectedDate,
|
||||
entryType = entryType,
|
||||
flowIntensity = if (entryType == EntryType.PERIOD) flowIntensity else null,
|
||||
mood = mood,
|
||||
energyLevel = energyLevel,
|
||||
sleepHours = sleepHours.toFloatOrNull(),
|
||||
symptoms = if (selectedSymptoms.isEmpty()) null else selectedSymptoms,
|
||||
medications = if (medications.isBlank()) null else medications.split(",").map { it.trim() },
|
||||
notes = notes.ifBlank { null },
|
||||
entryId = existingEntry?.entryId ?: 0L
|
||||
)
|
||||
|
||||
if (existingEntry != null) {
|
||||
viewModel.updateCalendarEntry(entry)
|
||||
} else {
|
||||
viewModel.addCalendarEntry(entry)
|
||||
}
|
||||
|
||||
onClose()
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Check, contentDescription = "Сохранить")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// Заголовок с датой
|
||||
Text(
|
||||
text = selectedDate.format(dateFormatter),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
)
|
||||
|
||||
// Выбор типа записи
|
||||
Text(
|
||||
text = "Тип записи",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
EntryTypeSelector(
|
||||
selectedType = entryType,
|
||||
onTypeSelected = { entryType = it }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Показываем дополнительные поля в зависимости от типа записи
|
||||
when (entryType) {
|
||||
EntryType.PERIOD -> {
|
||||
FlowIntensitySelector(
|
||||
selectedIntensity = flowIntensity,
|
||||
onIntensitySelected = { flowIntensity = it }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
else -> { /* Другие поля будут отображаться всегда */ }
|
||||
}
|
||||
|
||||
// Настроение
|
||||
Text(
|
||||
text = "Настроение",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
MoodSelector(
|
||||
selectedMood = mood,
|
||||
onMoodSelected = { mood = it }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Уровень энергии
|
||||
Text(
|
||||
text = "Уровень энергии: $energyLevel",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = energyLevel.toFloat(),
|
||||
onValueChange = { energyLevel = it.toInt() },
|
||||
valueRange = 1f..5f,
|
||||
steps = 3
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Часы сна
|
||||
OutlinedTextField(
|
||||
value = sleepHours,
|
||||
onValueChange = { sleepHours = it },
|
||||
label = { Text("Часы сна") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Симптомы
|
||||
Text(
|
||||
text = "Симптомы",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
SymptomsSelector(
|
||||
selectedSymptoms = selectedSymptoms,
|
||||
onSymptomsChanged = { selectedSymptoms = it }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Лекарства
|
||||
OutlinedTextField(
|
||||
value = medications,
|
||||
onValueChange = { medications = it },
|
||||
label = { Text("Лекарства (через запятую)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Заметки
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text("Заметки") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp),
|
||||
maxLines = 5
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Кнопка удаления (только для существующих записей)
|
||||
existingEntry?.let {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.deleteCalendarEntry(existingEntry.id)
|
||||
onClose()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Удалить запись")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Селектор типа записи
|
||||
*/
|
||||
@Composable
|
||||
fun EntryTypeSelector(
|
||||
selectedType: EntryType,
|
||||
onTypeSelected: (EntryType) -> Unit
|
||||
) {
|
||||
val entryTypes = listOf(
|
||||
EntryType.PERIOD to "Менструация",
|
||||
EntryType.OVULATION to "Овуляция",
|
||||
EntryType.SYMPTOMS to "Симптомы",
|
||||
EntryType.MEDICATION to "Лекарства",
|
||||
EntryType.NOTE to "Заметка"
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectableGroup()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
entryTypes.forEach { (type, label) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.selectable(
|
||||
selected = type == selectedType,
|
||||
onClick = { onTypeSelected(type) },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
RadioButton(
|
||||
selected = type == selectedType,
|
||||
onClick = null
|
||||
)
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Селектор интенсивности менструации
|
||||
*/
|
||||
@Composable
|
||||
fun FlowIntensitySelector(
|
||||
selectedIntensity: FlowIntensity,
|
||||
onIntensitySelected: (FlowIntensity) -> Unit
|
||||
) {
|
||||
val intensities = listOf(
|
||||
FlowIntensity.LIGHT to "Легкая",
|
||||
FlowIntensity.MEDIUM to "Средняя",
|
||||
FlowIntensity.HEAVY to "Сильная"
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Интенсивность",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectableGroup()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
intensities.forEach { (intensity, label) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.selectable(
|
||||
selected = intensity == selectedIntensity,
|
||||
onClick = { onIntensitySelected(intensity) },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
RadioButton(
|
||||
selected = intensity == selectedIntensity,
|
||||
onClick = null
|
||||
)
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Селектор настроения
|
||||
*/
|
||||
@Composable
|
||||
fun MoodSelector(
|
||||
selectedMood: Mood,
|
||||
onMoodSelected: (Mood) -> Unit
|
||||
) {
|
||||
// Создаем список троек (настроение, метка, иконка) вместо использования вложенных Pair
|
||||
val moods = listOf(
|
||||
Triple(Mood.HAPPY, "Счастливое", Icons.Default.Mood),
|
||||
Triple(Mood.NORMAL, "Обычное", Icons.Default.Face),
|
||||
Triple(Mood.SAD, "Грустное", CustomIcons.SentimentDissatisfied),
|
||||
Triple(Mood.IRRITATED, "Раздражение", CustomIcons.Dangerous),
|
||||
Triple(Mood.ANXIOUS, "Тревожное", Icons.Default.Warning),
|
||||
Triple(Mood.SENSITIVE, "Чувствительное", Icons.Default.Favorite),
|
||||
Triple(Mood.CALM, "Спокойное", CustomIcons.Spa)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyRow {
|
||||
items(moods.size) { index ->
|
||||
val (mood, label, icon) = moods[index]
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.selectable(
|
||||
selected = mood == selectedMood,
|
||||
onClick = { onMoodSelected(mood) },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.padding(8.dp),
|
||||
tint = if (mood == selectedMood)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (mood == selectedMood)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Селектор симптомов
|
||||
*/
|
||||
@Composable
|
||||
fun SymptomsSelector(
|
||||
selectedSymptoms: List<Symptom>,
|
||||
onSymptomsChanged: (MutableList<Symptom>) -> Unit
|
||||
) {
|
||||
val symptoms = listOf(
|
||||
Symptom.CRAMPS to "Спазмы",
|
||||
Symptom.HEADACHE to "Головная боль",
|
||||
Symptom.BLOATING to "Вздутие",
|
||||
Symptom.BACKACHE to "Боль в спине",
|
||||
Symptom.FATIGUE to "Усталость",
|
||||
Symptom.NAUSEA to "Тошнота",
|
||||
Symptom.BREAST_TENDERNESS to "Боль в груди",
|
||||
Symptom.ACNE to "Акне",
|
||||
Symptom.CRAVINGS to "Тяга к еде",
|
||||
Symptom.INSOMNIA to "Бессонница",
|
||||
Symptom.DIZZINESS to "Головокружение",
|
||||
Symptom.DIARRHEA to "Диарея",
|
||||
Symptom.CONSTIPATION to "Запор"
|
||||
)
|
||||
|
||||
Column {
|
||||
symptoms.chunked(2).forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
row.forEach { (symptom, label) ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = selectedSymptoms.contains(symptom),
|
||||
onCheckedChange = { checked ->
|
||||
val newList = selectedSymptoms.toMutableList()
|
||||
if (checked) {
|
||||
newList.add(symptom)
|
||||
} else {
|
||||
newList.remove(symptom)
|
||||
}
|
||||
onSymptomsChanged(newList)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
package com.example.womansafe.ui.screens
|
||||
|
||||
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.ArrowBack
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Insights
|
||||
import androidx.compose.material.icons.filled.Medication
|
||||
import androidx.compose.material.icons.filled.Mood
|
||||
import androidx.compose.material.icons.filled.TipsAndUpdates
|
||||
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||
import androidx.compose.material.icons.outlined.Favorite
|
||||
import androidx.compose.material.icons.outlined.LocalHospital
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.womansafe.data.model.calendar.CycleData
|
||||
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||
import com.example.womansafe.data.model.calendar.InsightType
|
||||
import com.example.womansafe.data.model.calendar.ConfidenceLevel
|
||||
import com.example.womansafe.data.model.calendar.Mood
|
||||
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Экран аналитики менструального цикла и инсайтов
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarInsightsScreen(
|
||||
viewModel: CalendarViewModel = viewModel(),
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
val calendarUiState by viewModel.calendarUiState.observeAsState()
|
||||
val isLoading by viewModel.isLoading.observeAsState(false)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Аналитика и рекомендации") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Назад")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
CycleStatistics(cycleData = calendarUiState?.cycleData)
|
||||
}
|
||||
|
||||
// Разделитель
|
||||
item {
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "Персонализированные рекомендации",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Список инсайтов
|
||||
calendarUiState?.insights?.let { insights ->
|
||||
if (insights.isNotEmpty()) {
|
||||
items(insights) { insight ->
|
||||
InsightItem(
|
||||
insight = insight,
|
||||
onDismiss = { viewModel.dismissInsight(insight.id) }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
EmptyInsightsMessage()
|
||||
}
|
||||
}
|
||||
} ?: item {
|
||||
EmptyInsightsMessage()
|
||||
}
|
||||
|
||||
// Добавляем пространство внизу
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Статистика цикла
|
||||
*/
|
||||
@Composable
|
||||
fun CycleStatistics(cycleData: CycleData?) {
|
||||
val dateFormatter = DateTimeFormatter.ofPattern("d MMMM", Locale("ru"))
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Статистика цикла",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (cycleData != null) {
|
||||
// Основные показатели
|
||||
StatisticRow(
|
||||
label = "Средняя длина цикла:",
|
||||
value = "${cycleData.averageCycleLength} дней"
|
||||
)
|
||||
|
||||
StatisticRow(
|
||||
label = "Средняя продолжительность менструации:",
|
||||
value = "${cycleData.averagePeriodLength} дней"
|
||||
)
|
||||
|
||||
StatisticRow(
|
||||
label = "Регулярность цикла:",
|
||||
value = "${cycleData.regularityScore}/100"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Прогнозы
|
||||
cycleData.nextPeriodPredicted?.let { nextPeriod ->
|
||||
Text(
|
||||
text = "Прогноз следующего цикла",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StatisticRow(
|
||||
label = "Начало следующего цикла:",
|
||||
value = nextPeriod.format(dateFormatter)
|
||||
)
|
||||
|
||||
// Прогноз овуляции
|
||||
cycleData.ovulationDate?.let { ovulationDate ->
|
||||
StatisticRow(
|
||||
label = "Ожидаемая овуляция:",
|
||||
value = ovulationDate.format(dateFormatter)
|
||||
)
|
||||
}
|
||||
|
||||
// Фертильное окно
|
||||
if (cycleData.fertileWindowStart != null && cycleData.fertileWindowEnd != null) {
|
||||
StatisticRow(
|
||||
label = "Фертильное окно:",
|
||||
value = "${cycleData.fertileWindowStart.format(dateFormatter)} - ${cycleData.fertileWindowEnd.format(dateFormatter)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Диаграмма цикла
|
||||
CycleGraph(cycleData = cycleData)
|
||||
|
||||
} else {
|
||||
// Пустое состояние
|
||||
Text(
|
||||
text = "Нет данных о цикле. Добавьте информацию о своем цикле для отображения статистики.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка со статистическим показателем
|
||||
*/
|
||||
@Composable
|
||||
fun StatisticRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
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.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Упрощенная диаграмма цикла
|
||||
*/
|
||||
@Composable
|
||||
fun CycleGraph(cycleData: CycleData) {
|
||||
val totalDays = cycleData.cycleLength
|
||||
val periodDays = cycleData.periodLength
|
||||
val ovulationDay = cycleData.ovulationDate?.let {
|
||||
val startDate = cycleData.periodStart ?: cycleData.lastPeriodStartDate
|
||||
if (startDate != null) {
|
||||
it.toEpochDay() - startDate.toEpochDay()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}?.toInt() ?: (totalDays / 2)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Текущий цикл",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(24.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
// Отображение фазы менструации
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(((periodDays.toFloat() / totalDays) * 100).dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFFE57373)) // Красный для менструации
|
||||
.align(Alignment.CenterStart)
|
||||
)
|
||||
|
||||
// Отображение овуляции
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.offset(x = ((ovulationDay.toFloat() / totalDays) * 100).dp - 12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF64B5F6)) // Синий для овуляции
|
||||
.align(Alignment.CenterStart)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Легенда
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFFE57373))
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Менструация",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 16.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF64B5F6))
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Овуляция",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Элемент списка инсайтов
|
||||
*/
|
||||
@Composable
|
||||
fun InsightItem(
|
||||
insight: HealthInsight,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val (icon, color) = when (insight.insightType) {
|
||||
InsightType.CYCLE_REGULARITY -> Pair(
|
||||
Icons.Outlined.CalendarMonth,
|
||||
MaterialTheme.colorScheme.primary
|
||||
)
|
||||
InsightType.PERIOD_LENGTH -> Pair(
|
||||
Icons.Outlined.Favorite,
|
||||
Color(0xFFE57373)
|
||||
)
|
||||
InsightType.SYMPTOM_PATTERN -> Pair(
|
||||
Icons.Default.Insights,
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
InsightType.MOOD_CORRELATION -> Pair(
|
||||
Icons.Filled.Mood,
|
||||
Color(0xFF81C784)
|
||||
)
|
||||
InsightType.PERIOD_PREDICTION -> Pair(
|
||||
Icons.Default.DateRange,
|
||||
Color(0xFF64B5F6)
|
||||
)
|
||||
InsightType.HEALTH_TIP -> Pair(
|
||||
Icons.Default.TipsAndUpdates,
|
||||
Color(0xFFFFC107)
|
||||
)
|
||||
InsightType.MEDICATION_REMINDER -> Pair(
|
||||
Icons.Default.Medication,
|
||||
Color(0xFF9C27B0)
|
||||
)
|
||||
InsightType.EXERCISE_SUGGESTION -> Pair(
|
||||
Icons.Default.FitnessCenter,
|
||||
Color(0xFF607D8B)
|
||||
)
|
||||
else -> Pair(Icons.Default.Info, MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Заголовок и иконка
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Text(
|
||||
text = insight.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Индикатор достоверности
|
||||
ConfidenceBadge(confidence = insight.confidenceLevel)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Описание инсайта
|
||||
Text(
|
||||
text = insight.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
|
||||
// Рекомендация
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Рекомендация:",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = insight.recommendation,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Информация об источниках данных
|
||||
Text(
|
||||
text = "На основе ${insight.dataPointsUsed} записей",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp)
|
||||
.align(Alignment.End)
|
||||
)
|
||||
|
||||
// Кнопка "скрыть"
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("Скрыть рекомендацию")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Индикатор достоверности инсайта
|
||||
*/
|
||||
@Composable
|
||||
fun ConfidenceBadge(confidence: ConfidenceLevel) {
|
||||
val (color, text) = when (confidence) {
|
||||
ConfidenceLevel.HIGH -> Pair(
|
||||
Color(0xFF4CAF50),
|
||||
"Высокая точность"
|
||||
)
|
||||
ConfidenceLevel.MEDIUM -> Pair(
|
||||
Color(0xFFFFC107),
|
||||
"Средняя точность"
|
||||
)
|
||||
ConfidenceLevel.LOW -> Pair(
|
||||
Color(0xFFFF5722),
|
||||
"Низкая точность"
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color.copy(alpha = 0.2f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сообщение при отсутствии инсайтов
|
||||
*/
|
||||
@Composable
|
||||
fun EmptyInsightsMessage() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Insights,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Пока нет персональных рекомендаций",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Продолжайте вести календарь, и мы сможем предоставить вам полезные советы на основе ваших данных",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
package com.example.womansafe.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
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.runtime.livedata.observeAsState
|
||||
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.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.*
|
||||
import com.example.womansafe.data.model.calendar.CycleData
|
||||
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||
import com.example.womansafe.ui.viewmodel.DayType
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.TextStyle
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Главный экран менструального календаря
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: CalendarViewModel,
|
||||
onAddEntryClick: (LocalDate) -> Unit,
|
||||
onViewInsightsClick: () -> Unit
|
||||
) {
|
||||
// Наблюдение за состоянием
|
||||
val calendarUiState by viewModel.calendarUiState.observeAsState()
|
||||
val selectedDate by viewModel.selectedDate.observeAsState(LocalDate.now())
|
||||
val isLoading by viewModel.isLoading.observeAsState(false)
|
||||
val errorMessage = calendarUiState?.errorMessage
|
||||
|
||||
// Выбранный месяц для отображения
|
||||
var currentMonth by remember { mutableStateOf(YearMonth.from(LocalDate.now())) }
|
||||
|
||||
// Обработка ошибок
|
||||
LaunchedEffect(errorMessage) {
|
||||
errorMessage?.let {
|
||||
// Здесь можно показать сообщение об ошибке (например, Snackbar)
|
||||
// После отображения ошибки очищаем сообщение
|
||||
viewModel.clearErrorMessage()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Заголовок с кнопками навигации по месяцам
|
||||
MonthNavigator(
|
||||
currentMonth = currentMonth,
|
||||
onPreviousMonth = { currentMonth = currentMonth.minusMonths(1) },
|
||||
onNextMonth = { currentMonth = currentMonth.plusMonths(1) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Календарная сетка с днями недели и числами
|
||||
MonthCalendarView(
|
||||
currentMonth = currentMonth,
|
||||
selectedDate = selectedDate,
|
||||
specialDays = calendarUiState?.specialDays ?: emptyMap(),
|
||||
onDateSelected = { date -> viewModel.selectDate(date) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Данные выбранного дня и записи
|
||||
SelectedDayDetails(
|
||||
selectedDate = selectedDate,
|
||||
entries = calendarUiState?.selectedDateEntries ?: emptyList(),
|
||||
cycleData = calendarUiState?.cycleData,
|
||||
onAddEntryClick = { onAddEntryClick(selectedDate) }
|
||||
)
|
||||
|
||||
// Показ инсайтов о здоровье, если они есть
|
||||
calendarUiState?.insights?.takeIf { it.isNotEmpty() }?.let { insights ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
InsightsPreview(
|
||||
insights = insights,
|
||||
onViewAllClick = onViewInsightsClick,
|
||||
onDismiss = { viewModel.dismissInsight(it) }
|
||||
)
|
||||
}
|
||||
|
||||
// Индикатор загрузки
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Навигация по месяцам календаря
|
||||
*/
|
||||
@Composable
|
||||
fun MonthNavigator(
|
||||
currentMonth: YearMonth,
|
||||
onPreviousMonth: () -> Unit,
|
||||
onNextMonth: () -> Unit
|
||||
) {
|
||||
val monthFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", Locale("ru"))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onPreviousMonth) {
|
||||
Icon(Icons.Default.KeyboardArrowLeft, contentDescription = "Предыдущий месяц")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = currentMonth.format(monthFormatter).replaceFirstChar {
|
||||
if (it.isLowerCase()) it.titlecase(Locale("ru")) else it.toString()
|
||||
},
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
IconButton(onClick = onNextMonth) {
|
||||
Icon(Icons.Default.KeyboardArrowRight, contentDescription = "Следующий месяц")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Календарная сетка на месяц
|
||||
*/
|
||||
@Composable
|
||||
fun MonthCalendarView(
|
||||
currentMonth: YearMonth,
|
||||
selectedDate: LocalDate,
|
||||
specialDays: Map<LocalDate, DayType>,
|
||||
onDateSelected: (LocalDate) -> Unit
|
||||
) {
|
||||
Column {
|
||||
// Дни недели (заголовок)
|
||||
DaysOfWeekHeader()
|
||||
|
||||
// Дни месяца
|
||||
val startDate = currentMonth.atDay(1)
|
||||
val endDate = currentMonth.atEndOfMonth()
|
||||
|
||||
// Получаем все даты для отображения (включая дни из предыдущего и следующего месяца для заполнения сетки)
|
||||
val firstDayOfGrid = startDate.minusDays(startDate.dayOfWeek.value.toLong() % 7)
|
||||
val lastDayOfGrid = endDate.plusDays(6 - endDate.dayOfWeek.value.toLong() % 7)
|
||||
|
||||
val daysToShow = mutableListOf<LocalDate>()
|
||||
var currentDate = firstDayOfGrid
|
||||
while (!currentDate.isAfter(lastDayOfGrid)) {
|
||||
daysToShow.add(currentDate)
|
||||
currentDate = currentDate.plusDays(1)
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(7),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
items(daysToShow) { date ->
|
||||
DayCell(
|
||||
date = date,
|
||||
isSelected = date == selectedDate,
|
||||
isCurrentMonth = date.month == currentMonth.month,
|
||||
dayType = specialDays[date] ?: DayType.NORMAL,
|
||||
onClick = { onDateSelected(date) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Заголовок с днями недели
|
||||
*/
|
||||
@Composable
|
||||
fun DaysOfWeekHeader() {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
for (dayOfWeek in DayOfWeek.values()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = dayOfWeek.getDisplayName(TextStyle.SHORT, Locale("ru")).uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ячейка календаря для отдельного дня
|
||||
*/
|
||||
@Composable
|
||||
fun DayCell(
|
||||
date: LocalDate,
|
||||
isSelected: Boolean,
|
||||
isCurrentMonth: Boolean,
|
||||
dayType: DayType,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
// Цвета для разных типов дней
|
||||
val backgroundColor = when {
|
||||
isSelected -> MaterialTheme.colorScheme.primary
|
||||
!isCurrentMonth -> Color.Transparent
|
||||
else -> Color.Transparent
|
||||
}
|
||||
|
||||
val textColor = when {
|
||||
isSelected -> MaterialTheme.colorScheme.onPrimary
|
||||
!isCurrentMonth -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
|
||||
LocalDate.now() == date -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
|
||||
// Индикатор специального дня
|
||||
val indicatorColor = when (dayType) {
|
||||
DayType.PERIOD -> Color(0xFFE57373) // Красный для менструации
|
||||
DayType.OVULATION -> Color(0xFF64B5F6) // Синий для овуляции
|
||||
DayType.FERTILE -> Color(0xFF81C784) // Зеленый для фертильного окна
|
||||
DayType.PREDICTED_PERIOD -> Color(0xFFE57373).copy(alpha = 0.5f) // Полупрозрачный красный для прогноза
|
||||
DayType.NORMAL -> Color.Transparent
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.padding(2.dp)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundColor)
|
||||
.border(
|
||||
width = if (isSelected) 0.dp else if (LocalDate.now() == date) 1.dp else 0.dp,
|
||||
color = if (LocalDate.now() == date) MaterialTheme.colorScheme.primary else Color.Transparent,
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = date.dayOfMonth.toString(),
|
||||
color = textColor,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (LocalDate.now() == date) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
|
||||
if (dayType != DayType.NORMAL) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(indicatorColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Детали выбранного дня и записи
|
||||
*/
|
||||
@Composable
|
||||
fun SelectedDayDetails(
|
||||
selectedDate: LocalDate,
|
||||
entries: List<CalendarEntry>,
|
||||
cycleData: CycleData?,
|
||||
onAddEntryClick: () -> Unit
|
||||
) {
|
||||
val dateFormatter = DateTimeFormatter.ofPattern("d MMMM", Locale("ru"))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
// Заголовок выбранного дня
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = selectedDate.format(dateFormatter),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
IconButton(onClick = onAddEntryClick) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Добавить запись")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Информация о специальном дне
|
||||
cycleData?.let {
|
||||
when {
|
||||
selectedDate == it.cycleStartDate -> {
|
||||
SpecialDayInfo(
|
||||
title = "Начало цикла",
|
||||
description = "Сегодня первый день вашего менструального цикла.",
|
||||
color = Color(0xFFE57373)
|
||||
)
|
||||
}
|
||||
selectedDate == it.ovulationDate -> {
|
||||
SpecialDayInfo(
|
||||
title = "День овуляции",
|
||||
description = "Сегодня ваш день овуляции - наивысшая вероятность зачатия.",
|
||||
color = Color(0xFF64B5F6)
|
||||
)
|
||||
}
|
||||
it.fertileWindowStart?.let { start ->
|
||||
it.fertileWindowEnd?.let { end ->
|
||||
selectedDate in start..end
|
||||
}
|
||||
} == true -> {
|
||||
SpecialDayInfo(
|
||||
title = "Фертильное окно",
|
||||
description = "Вы находитесь в фертильном периоде с повышенной вероятностью зачатия.",
|
||||
color = Color(0xFF81C784)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Список записей для выбранного дня
|
||||
if (entries.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Записи",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 200.dp)
|
||||
) {
|
||||
items(entries) { entry ->
|
||||
EntryItem(entry = entry)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Пустое состояние
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Нет записей на выбранную дату",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Информация о специальном дне (менструация, овуляция и т.д.)
|
||||
*/
|
||||
@Composable
|
||||
fun SpecialDayInfo(
|
||||
title: String,
|
||||
description: String,
|
||||
color: Color
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = color.copy(alpha = 0.15f)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Элемент записи для выбранного дня
|
||||
*/
|
||||
@Composable
|
||||
fun EntryItem(entry: CalendarEntry) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Заголовок с типом записи
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val (icon, title) = when (entry.entryType) {
|
||||
com.example.womansafe.data.model.calendar.EntryType.PERIOD -> Pair(
|
||||
Icons.Default.Favorite,
|
||||
"Менструация: ${entry.flowIntensity?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: ""}"
|
||||
)
|
||||
com.example.womansafe.data.model.calendar.EntryType.OVULATION -> Pair(Icons.Default.Star, "Овуляция")
|
||||
com.example.womansafe.data.model.calendar.EntryType.SYMPTOMS -> Pair(Icons.Default.Warning, "Симптомы")
|
||||
com.example.womansafe.data.model.calendar.EntryType.MEDICATION -> Pair(Icons.Default.Healing, "Медикаменты")
|
||||
com.example.womansafe.data.model.calendar.EntryType.NOTE -> Pair(Icons.Default.Info, "Заметка")
|
||||
com.example.womansafe.data.model.calendar.EntryType.APPOINTMENT -> Pair(Icons.Default.Event, "Приём врача")
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Детали записи
|
||||
entry.symptoms?.takeIf { it.isNotEmpty() }?.let { symptoms ->
|
||||
EntryDetailRow(title = "Симптомы:", content = symptoms.joinToString(", ") {
|
||||
it.name.replace("_", " ").lowercase().replaceFirstChar { c -> c.uppercase() }
|
||||
})
|
||||
}
|
||||
|
||||
entry.mood?.let { mood ->
|
||||
EntryDetailRow(title = "Настроение:", content = mood.name.lowercase().replaceFirstChar { it.uppercase() })
|
||||
}
|
||||
|
||||
entry.energyLevel?.let { energy ->
|
||||
EntryDetailRow(title = "Энергия:", content = "$energy из 5")
|
||||
}
|
||||
|
||||
entry.sleepHours?.let { sleep ->
|
||||
EntryDetailRow(title = "Сон:", content = "$sleep часов")
|
||||
}
|
||||
|
||||
entry.medications?.takeIf { it.isNotEmpty() }?.let { meds ->
|
||||
EntryDetailRow(title = "Лекарства:", content = meds.joinToString(", "))
|
||||
}
|
||||
|
||||
entry.notes?.takeIf { it.isNotEmpty() }?.let { notes ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = notes,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка с деталями записи
|
||||
*/
|
||||
@Composable
|
||||
fun EntryDetailRow(title: String, content: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.width(90.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Предварительный просмотр инсайтов о здоровье
|
||||
*/
|
||||
@Composable
|
||||
fun InsightsPreview(
|
||||
insights: List<HealthInsight>,
|
||||
onViewAllClick: () -> Unit,
|
||||
onDismiss: (Long) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Заголовок
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Ваши инсайты",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
TextButton(onClick = onViewAllClick) {
|
||||
Text(text = "Все")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Инсайты в прокручиваемом ряду
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(insights.take(3)) { insight ->
|
||||
InsightCard(
|
||||
insight = insight,
|
||||
onDismiss = { onDismiss(insight.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка с инсайтом о здоровье
|
||||
*/
|
||||
@Composable
|
||||
fun InsightCard(
|
||||
insight: HealthInsight,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.width(280.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Заголовок и уровень достоверности
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = insight.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
val confidenceColor = when (insight.confidenceLevel) {
|
||||
com.example.womansafe.data.model.calendar.ConfidenceLevel.HIGH -> Color(0xFF4CAF50)
|
||||
com.example.womansafe.data.model.calendar.ConfidenceLevel.MEDIUM -> Color(0xFFFFC107)
|
||||
com.example.womansafe.data.model.calendar.ConfidenceLevel.LOW -> Color(0xFFFF5722)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(confidenceColor.copy(alpha = 0.2f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = when (insight.confidenceLevel) {
|
||||
com.example.womansafe.data.model.calendar.ConfidenceLevel.HIGH -> "Высокая точность"
|
||||
com.example.womansafe.data.model.calendar.ConfidenceLevel.MEDIUM -> "Средняя точность"
|
||||
com.example.womansafe.data.model.calendar.ConfidenceLevel.LOW -> "Низкая точность"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = confidenceColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Описание инсайта
|
||||
Text(
|
||||
text = insight.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.heightIn(min = 60.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Рекомендация
|
||||
Text(
|
||||
text = "Рекомендация:",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = insight.recommendation,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Кнопка отклонения инсайта
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text(text = "Скрыть")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.example.womansafe.ui.screens
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
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.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.example.womansafe.util.ContactsHelper
|
||||
import com.example.womansafe.util.PermissionManager
|
||||
import com.example.womansafe.util.RequestPermissions
|
||||
import com.example.womansafe.util.fixTouchEvents
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ContactPickerScreen(
|
||||
onContactSelected: (ContactsHelper.Contact, String) -> Unit,
|
||||
onBackPressed: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val contactsHelper = remember { ContactsHelper(context) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var contacts by remember { mutableStateOf<List<ContactsHelper.Contact>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var showRelationshipDialog by remember { mutableStateOf<ContactsHelper.Contact?>(null) }
|
||||
|
||||
// Фильтрованные контакты на основе поискового запроса
|
||||
val filteredContacts = remember(contacts, searchQuery) {
|
||||
if (searchQuery.isBlank()) contacts
|
||||
else contacts.filter {
|
||||
it.name.contains(searchQuery, ignoreCase = true) ||
|
||||
it.phoneNumber.contains(searchQuery)
|
||||
}
|
||||
}
|
||||
|
||||
// Запрос разрешения на чтение контактов
|
||||
RequestPermissions(
|
||||
permissions = listOf(PermissionManager.PERMISSION_CONTACTS),
|
||||
onAllPermissionsGranted = {
|
||||
// Загружаем контакты после получения разрешения
|
||||
coroutineScope.launch {
|
||||
isLoading = true
|
||||
contacts = contactsHelper.getContacts()
|
||||
isLoading = false
|
||||
}
|
||||
},
|
||||
onPermissionDenied = { deniedPermissions ->
|
||||
// Показываем сообщение, если пользователь отклонил разрешение
|
||||
isLoading = false
|
||||
}
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Выбрать контакт") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackPressed) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Назад")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Строка поиска
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
placeholder = { Text("Поиск") },
|
||||
singleLine = true,
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.7f)
|
||||
.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (isLoading) {
|
||||
// Показываем индикатор загрузки
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (contacts.isEmpty()) {
|
||||
// Показываем сообщение, если контакты не найдены
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Контакты не найдены или нет разрешения на доступ к контактам")
|
||||
}
|
||||
} else {
|
||||
// Показываем список контактов
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.fixTouchEvents()
|
||||
) {
|
||||
items(filteredContacts) { contact ->
|
||||
ContactItem(
|
||||
contact = contact,
|
||||
onClick = { showRelationshipDialog = contact }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Диалог выбора отношения к контакту
|
||||
showRelationshipDialog?.let { contact ->
|
||||
var relationship by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRelationshipDialog = null },
|
||||
title = { Text("Укажите отношение") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Контакт: ${contact.name}")
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = relationship,
|
||||
onValueChange = { relationship = it },
|
||||
label = { Text("Отношение (например: Мама, Папа, Друг)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onContactSelected(contact, relationship)
|
||||
showRelationshipDialog = null
|
||||
},
|
||||
enabled = relationship.isNotBlank()
|
||||
) {
|
||||
Text("Добавить")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRelationshipDialog = null }) {
|
||||
Text("Отмена")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContactItem(
|
||||
contact: ContactsHelper.Contact,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Аватар контакта
|
||||
val painter = if (contact.photoUri != null) {
|
||||
rememberAsyncImagePainter(contact.photoUri)
|
||||
} else {
|
||||
rememberVectorPainter(Icons.Default.Person)
|
||||
}
|
||||
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = contact.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = contact.phoneNumber,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
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.platform.LocalContext
|
||||
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
|
||||
import com.example.womansafe.util.ContactsHelper
|
||||
import com.example.womansafe.util.fixTouchEvents
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EmergencyContactsScreen(
|
||||
emergencyContactsViewModel: EmergencyContactsViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onNavigateToContactPicker: () -> Unit
|
||||
) {
|
||||
val uiState = emergencyContactsViewModel.uiState
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var showContactPickerDialog 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 = { showContactPickerDialog = 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 = { showContactPickerDialog = 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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Диалог выбора контакта
|
||||
if (showContactPickerDialog) {
|
||||
ContactPickerDialog(
|
||||
onDismiss = { showContactPickerDialog = false },
|
||||
onContactSelected = { contact ->
|
||||
emergencyContactsViewModel.addContact(contact)
|
||||
showContactPickerDialog = 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("Отмена")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactPickerDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onContactSelected: (EmergencyContactCreate) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var showContactPicker by remember { mutableStateOf(false) }
|
||||
|
||||
if (showContactPicker) {
|
||||
// Используем полноэкранный контактный пикер
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismiss() },
|
||||
title = { Text("Переход к выбору контактов") },
|
||||
text = {
|
||||
Text("Вы будете перенаправлены на экран выбора контактов из телефонной книги.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showContactPicker = false
|
||||
onDismiss()
|
||||
// Здесь будет логика перехода на ContactPickerScreen
|
||||
}) {
|
||||
Text("Понятно")
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Выберите способ добавления") },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = { showContactPicker = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Contacts,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Выбрать из контактов")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
// Показываем диалог ручного добавления
|
||||
// (используем существующий диалог добавления контакта)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Добавить вручную")
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { },
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Отмена")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package com.example.womansafe.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.womansafe.data.model.EmergencyContactResponse
|
||||
import com.example.womansafe.data.model.EmergencyType
|
||||
import com.example.womansafe.ui.viewmodel.EmergencyViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EmergencyScreen(
|
||||
emergencyViewModel: EmergencyViewModel = viewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uiState by emergencyViewModel.uiState.collectAsState()
|
||||
|
||||
// Запуск анимации для экстренной кнопки
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "emergency_pulse")
|
||||
val scale by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = if (uiState.isEmergencyActive) 1.1f else 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
), label = "emergency_scale"
|
||||
)
|
||||
|
||||
// Запрос разрешений на местоположение
|
||||
val locationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
val granted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
|
||||
permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
|
||||
emergencyViewModel.onLocationPermissionResult(granted)
|
||||
}
|
||||
|
||||
// Инициализация клиента местоположения
|
||||
LaunchedEffect(Unit) {
|
||||
emergencyViewModel.initLocationClient(context)
|
||||
emergencyViewModel.loadEmergencyContacts()
|
||||
|
||||
// Запрашиваем разрешения если их нет
|
||||
if (!uiState.hasLocationPermission) {
|
||||
locationPermissionLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Диалог выбора типа экстренной ситуации
|
||||
var showEmergencyTypeDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.background,
|
||||
MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Заголовок
|
||||
Text(
|
||||
text = "Экстренная ситуация",
|
||||
style = MaterialTheme.typography.headlineMedium.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
// Статус местоположения
|
||||
LocationStatusCard(
|
||||
hasPermission = uiState.hasLocationPermission,
|
||||
currentLocation = uiState.currentLocation?.address,
|
||||
onRequestPermission = {
|
||||
locationPermissionLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Главная кнопка экстренной ситуации
|
||||
EmergencyButton(
|
||||
isActive = uiState.isEmergencyActive,
|
||||
isLoading = uiState.isLoading,
|
||||
scale = scale,
|
||||
onClick = {
|
||||
if (uiState.isEmergencyActive) {
|
||||
emergencyViewModel.cancelEmergencyAlert()
|
||||
} else {
|
||||
showEmergencyTypeDialog = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Экстренные контакты
|
||||
EmergencyContactsSection(
|
||||
contacts = uiState.emergencyContacts,
|
||||
context = context
|
||||
)
|
||||
|
||||
// Сообщение об ошибке
|
||||
uiState.errorMessage?.let { error ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Диалог выбора типа экстренной ситуации
|
||||
if (showEmergencyTypeDialog) {
|
||||
EmergencyTypeDialog(
|
||||
onDismiss = { showEmergencyTypeDialog = false },
|
||||
onTypeSelected = { type ->
|
||||
emergencyViewModel.createEmergencyAlert(
|
||||
context = context,
|
||||
type = type
|
||||
)
|
||||
showEmergencyTypeDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocationStatusCard(
|
||||
hasPermission: Boolean,
|
||||
currentLocation: String?,
|
||||
onRequestPermission: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (hasPermission)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (hasPermission) Icons.Filled.LocationOn else Icons.Filled.Clear,
|
||||
contentDescription = null,
|
||||
tint = if (hasPermission)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (hasPermission) "Местоположение определено" else "Нет доступа к местоположению",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (hasPermission)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
if (hasPermission && currentLocation != null) {
|
||||
Text(
|
||||
text = currentLocation,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!hasPermission) {
|
||||
TextButton(onClick = onRequestPermission) {
|
||||
Text("Разрешить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmergencyButton(
|
||||
isActive: Boolean,
|
||||
isLoading: Boolean,
|
||||
scale: Float,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.size(200.dp)
|
||||
.scale(scale),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isActive)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
Color.Red
|
||||
),
|
||||
enabled = !isLoading
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.onError,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isActive) Icons.Filled.Close else Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (isActive) "ОТМЕНИТЬ" else "SOS",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmergencyContactsSection(
|
||||
contacts: List<EmergencyContactResponse>,
|
||||
context: Context
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Person,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Экстренные контакты",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
if (contacts.isEmpty()) {
|
||||
Text(
|
||||
text = "Добавьте экстренные контакты в настройках",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(contacts) { contact ->
|
||||
EmergencyContactItem(
|
||||
contact = contact,
|
||||
onCallClick = {
|
||||
val intent = Intent(Intent.ACTION_CALL).apply {
|
||||
data = Uri.parse("tel:${contact.phone_number}")
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
if (contact != contacts.last()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmergencyContactItem(
|
||||
contact: EmergencyContactResponse,
|
||||
onCallClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = contact.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = contact.phone_number,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
contact.relationship?.let { relationship ->
|
||||
Text(
|
||||
text = relationship,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = onCallClick,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Phone,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Вызов")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EmergencyTypeDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onTypeSelected: (EmergencyType) -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = "Выберите тип экстренной ситуации",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
text = {
|
||||
LazyColumn {
|
||||
items(EmergencyType.values()) { type ->
|
||||
val (title, icon) = when (type) {
|
||||
EmergencyType.HARASSMENT -> "Домогательства" to Icons.Filled.Warning
|
||||
EmergencyType.ASSAULT -> "Нападение" to Icons.Filled.Warning
|
||||
EmergencyType.STALKING -> "Преследование" to Icons.Filled.Search
|
||||
EmergencyType.DOMESTIC_VIOLENCE -> "Домашнее насилие" to Icons.Filled.Home
|
||||
EmergencyType.UNSAFE_AREA -> "Небезопасная зона" to Icons.Filled.LocationOn
|
||||
EmergencyType.MEDICAL -> "Медицинская помощь" to Icons.Filled.Favorite
|
||||
EmergencyType.OTHER -> "Другое" to Icons.Filled.MoreVert
|
||||
}
|
||||
|
||||
Card(
|
||||
onClick = { onTypeSelected(type) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Отмена")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
561
app/src/main/java/com/example/womansafe/ui/screens/HomeScreen.kt
Normal file
561
app/src/main/java/com/example/womansafe/ui/screens/HomeScreen.kt
Normal file
@@ -0,0 +1,561 @@
|
||||
package com.example.womansafe.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.womansafe.ui.viewmodel.AuthViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
authViewModel: AuthViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val uiState = authViewModel.uiState
|
||||
var showLocationDialog by remember { mutableStateOf(false) }
|
||||
var showEmergencyDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Приветствие пользователя
|
||||
WelcomeCard(userName = uiState.user?.username ?: "Пользователь")
|
||||
}
|
||||
|
||||
item {
|
||||
// Быстрые действия
|
||||
QuickActionsSection(
|
||||
onShareLocation = { showLocationDialog = true },
|
||||
onCallHelp = { /* TODO: Call emergency services */ },
|
||||
onSendSignal = { showEmergencyDialog = true }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
// Статус безопасности
|
||||
SafetyStatusCard(uiState)
|
||||
}
|
||||
|
||||
item {
|
||||
// Экстренные контакты
|
||||
EmergencyContactsCard(
|
||||
contacts = uiState.emergencyContacts ?: emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
// Последняя активность
|
||||
RecentActivityCard()
|
||||
}
|
||||
|
||||
item {
|
||||
// Календарь - краткий обзор
|
||||
CalendarOverviewCard()
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Диалог отправки местоположения
|
||||
if (showLocationDialog) {
|
||||
LocationSharingDialog(
|
||||
onDismiss = { showLocationDialog = false },
|
||||
onConfirm = { contacts ->
|
||||
// TODO: Отправить местоположение выбранным контактам
|
||||
showLocationDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Диалог экстренного сигнала
|
||||
if (showEmergencyDialog) {
|
||||
EmergencySignalDialog(
|
||||
onDismiss = { showEmergencyDialog = false },
|
||||
onConfirm = { message ->
|
||||
// TODO: Отправить экстренный сигнал
|
||||
showEmergencyDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeCard(userName: String) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Привет, $userName!",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Ваша безопасность - наш приоритет",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickActionsSection(
|
||||
onShareLocation: () -> Unit,
|
||||
onCallHelp: () -> Unit,
|
||||
onSendSignal: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Быстрые действия",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
QuickActionButton(
|
||||
icon = Icons.Filled.LocationOn,
|
||||
text = "Поделиться местоположением",
|
||||
onClick = onShareLocation
|
||||
)
|
||||
QuickActionButton(
|
||||
icon = Icons.Filled.Phone,
|
||||
text = "Вызвать помощь",
|
||||
onClick = onCallHelp
|
||||
)
|
||||
QuickActionButton(
|
||||
icon = Icons.Filled.Notifications,
|
||||
text = "Отправить сигнал",
|
||||
onClick = onSendSignal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickActionButton(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(80.dp)
|
||||
) {
|
||||
FilledIconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(56.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = text,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SafetyStatusCard(uiState: Any) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(20.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = "Статус безопасности",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Статус: Безопасно",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Все системы работают нормально",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmergencyContactsCard(contacts: List<Any>) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Экстренные контакты",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
TextButton(onClick = { /* TODO: Navigate to contacts */ }) {
|
||||
Text("Управление")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (contacts.isEmpty()) {
|
||||
Text(
|
||||
text = "Добавьте экстренные контакты для быстрого доступа",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Настроено контактов: ${contacts.size}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentActivityCard() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Последняя активность",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Placeholder для активности
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = "История",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Нет недавней активности",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarOverviewCard() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Календарь - краткий обзор",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Placeholder для календаря
|
||||
Text(
|
||||
text = "Нет предстоящих событий",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocationSharingDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (List<String>) -> Unit
|
||||
) {
|
||||
var selectedContacts by remember { mutableStateOf(setOf<String>()) }
|
||||
var customMessage by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Поделиться местоположением") },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Ваше текущее местоположение будет отправлено выбранным контактам",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
// Список контактов (заглушка)
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Экстренные контакты:",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
listOf("Мама", "Служба безопасности", "Врач").forEach { contact ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = selectedContacts.contains(contact),
|
||||
onCheckedChange = { checked ->
|
||||
selectedContacts = if (checked) {
|
||||
selectedContacts + contact
|
||||
} else {
|
||||
selectedContacts - contact
|
||||
}
|
||||
}
|
||||
)
|
||||
Text(
|
||||
text = contact,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = customMessage,
|
||||
onValueChange = { customMessage = it },
|
||||
label = { Text("Дополнительное сообщение (необязательно)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(selectedContacts.toList()) },
|
||||
enabled = selectedContacts.isNotEmpty()
|
||||
) {
|
||||
Text("Отправить")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Отмена")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EmergencySignalDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
var emergencyType by remember { mutableStateOf("Общая тревога") }
|
||||
var customMessage by remember { mutableStateOf("") }
|
||||
var includeLocation by remember { mutableStateOf(true) }
|
||||
var includePhoto by remember { mutableStateOf(false) }
|
||||
|
||||
val emergencyTypes = listOf("Общая тревога", "Медицинская помощь", "Преследование", "ДТП", "Другое")
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Экстренный сигнал") },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Сигнал будет отправлен всем вашим экстренным контактам",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
|
||||
// Тип экстренной ситуации
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = emergencyType,
|
||||
onValueChange = { },
|
||||
readOnly = true,
|
||||
label = { Text("Тип ситуации") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
emergencyTypes.forEach { type ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(type) },
|
||||
onClick = {
|
||||
emergencyType = type
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = customMessage,
|
||||
onValueChange = { customMessage = it },
|
||||
label = { Text("Дополнительная информация") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
placeholder = { Text("Опишите ситуацию...") }
|
||||
)
|
||||
|
||||
// Дополнительные опции
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = includeLocation,
|
||||
onCheckedChange = { includeLocation = it }
|
||||
)
|
||||
Text(
|
||||
text = "Включить местоположение",
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = includePhoto,
|
||||
onCheckedChange = { includePhoto = it }
|
||||
)
|
||||
Text(
|
||||
text = "Сделать фото с камеры",
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val message = buildString {
|
||||
append("ЭКСТРЕННЫЙ СИГНАЛ: $emergencyType")
|
||||
if (customMessage.isNotBlank()) {
|
||||
append("\nДетали: $customMessage")
|
||||
}
|
||||
if (includeLocation) {
|
||||
append("\nМестоположение: будет приложено")
|
||||
}
|
||||
if (includePhoto) {
|
||||
append("\nФото: будет приложено")
|
||||
}
|
||||
}
|
||||
onConfirm(message)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("ОТПРАВИТЬ SOS")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Отмена")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
144
app/src/main/java/com/example/womansafe/ui/screens/MainScreen.kt
Normal file
144
app/src/main/java/com/example/womansafe/ui/screens/MainScreen.kt
Normal file
@@ -0,0 +1,144 @@
|
||||
package com.example.womansafe.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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
|
||||
import java.time.LocalDate
|
||||
|
||||
@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
|
||||
) {
|
||||
// Создаем ViewModel для календаря здесь для общего доступа
|
||||
val calendarViewModel: CalendarViewModel = viewModel()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = BottomNavItem.Home.route,
|
||||
modifier = modifier
|
||||
) {
|
||||
composable(BottomNavItem.Home.route) {
|
||||
HomeScreen(authViewModel = authViewModel)
|
||||
}
|
||||
|
||||
composable(BottomNavItem.Emergency.route) {
|
||||
EmergencyScreen(emergencyViewModel = EmergencyViewModel())
|
||||
}
|
||||
|
||||
// Заменяем прямой вызов CalendarScreen на вложенную навигацию
|
||||
composable(BottomNavItem.Calendar.route) {
|
||||
CalendarNavigation(
|
||||
calendarViewModel = calendarViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
composable(BottomNavItem.Profile.route) {
|
||||
ProfileScreen(
|
||||
authViewModel = authViewModel,
|
||||
onNavigateToContacts = { navController.navigate("emergency_contacts") },
|
||||
onNavigateToSettings = { navController.navigate("profile_settings") }
|
||||
)
|
||||
}
|
||||
|
||||
// Дополнительные экраны
|
||||
composable("emergency_contacts") {
|
||||
EmergencyContactsScreen(
|
||||
emergencyContactsViewModel = EmergencyContactsViewModel(),
|
||||
onNavigateToContactPicker = { navController.navigate("contact_picker") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("profile_settings") {
|
||||
ProfileSettingsScreen(
|
||||
profileSettingsViewModel = ProfileSettingsViewModel(),
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// Экраны календаря
|
||||
composable("calendar_entry/{date}") { backStackEntry ->
|
||||
val dateString = backStackEntry.arguments?.getString("date") ?: LocalDate.now().toString()
|
||||
val date = LocalDate.parse(dateString)
|
||||
|
||||
CalendarEntryScreen(
|
||||
viewModel = calendarViewModel,
|
||||
selectedDate = date,
|
||||
onClose = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable("calendar_insights") {
|
||||
CalendarInsightsScreen(
|
||||
viewModel = calendarViewModel,
|
||||
onBackClick = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Вложенная навигация для календаря
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarNavigation(
|
||||
calendarViewModel: CalendarViewModel,
|
||||
navController: NavHostController
|
||||
) {
|
||||
val selectedDate by calendarViewModel.selectedDate.observeAsState(LocalDate.now())
|
||||
|
||||
CalendarScreen(
|
||||
viewModel = calendarViewModel,
|
||||
onAddEntryClick = { date ->
|
||||
navController.navigate("calendar_entry/${date}")
|
||||
},
|
||||
onViewInsightsClick = {
|
||||
navController.navigate("calendar_insights")
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package com.example.womansafe.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.womansafe.ui.viewmodel.AuthViewModel
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
authViewModel: AuthViewModel,
|
||||
onNavigateToContacts: (() -> Unit)? = null,
|
||||
onNavigateToSettings: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val uiState = authViewModel.uiState
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Профиль пользователя
|
||||
UserProfileCard(
|
||||
userName = uiState.user?.username ?: "Пользователь",
|
||||
userEmail = uiState.user?.email ?: "",
|
||||
userPhone = uiState.user?.phone,
|
||||
onEditProfile = { onNavigateToSettings?.invoke() }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
// Настройки безопасности
|
||||
SecuritySettingsSection()
|
||||
}
|
||||
|
||||
item {
|
||||
// Экстренные контакты
|
||||
EmergencyContactsSection(
|
||||
contactsCount = uiState.emergencyContacts?.size ?: 0,
|
||||
onManageContacts = { onNavigateToContacts?.invoke() }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
// Приватность и уведомления
|
||||
PrivacyAndNotificationsSection(uiState)
|
||||
}
|
||||
|
||||
item {
|
||||
// Дополнительные настройки
|
||||
AdditionalSettingsSection()
|
||||
}
|
||||
|
||||
item {
|
||||
// Кнопка выхода
|
||||
LogoutButton(onLogout = { authViewModel.logout() })
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserProfileCard(
|
||||
userName: String,
|
||||
userEmail: String,
|
||||
userPhone: String?,
|
||||
onEditProfile: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Аватар пользователя
|
||||
Surface(
|
||||
modifier = Modifier.size(80.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Person,
|
||||
contentDescription = "Аватар",
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = userName,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
if (userEmail.isNotEmpty()) {
|
||||
Text(
|
||||
text = userEmail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
userPhone?.let { phone ->
|
||||
Text(
|
||||
text = phone,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onEditProfile
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Edit,
|
||||
contentDescription = "Редактировать",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Редактировать профиль")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecuritySettingsSection() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Настройки безопасности",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.LocationOn,
|
||||
title = "Отслеживание местоположения",
|
||||
subtitle = "Разрешить приложению отслеживать ваше местоположение",
|
||||
hasSwitch = true,
|
||||
switchState = true,
|
||||
onSwitchChange = { /* TODO */ }
|
||||
)
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Lock,
|
||||
title = "Сменить пароль",
|
||||
subtitle = "Обновить пароль для входа в приложение",
|
||||
onClick = { /* TODO */ }
|
||||
)
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Lock,
|
||||
title = "Двухфакторная аутентификация",
|
||||
subtitle = "Дополнительная защита вашего аккаунта",
|
||||
onClick = { /* TODO */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmergencyContactsSection(contactsCount: Int, onManageContacts: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "Экстренные контакты",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = "Настроено: $contactsCount",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onManageContacts) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowForward,
|
||||
contentDescription = "Управление контактами"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrivacyAndNotificationsSection(uiState: Any) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Приватность и уведомления",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Notifications,
|
||||
title = "Push-уведомления",
|
||||
subtitle = "Получать уведомления о важных событиях",
|
||||
hasSwitch = true,
|
||||
switchState = true,
|
||||
onSwitchChange = { /* TODO */ }
|
||||
)
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Email,
|
||||
title = "Email-уведомления",
|
||||
subtitle = "Получать уведомления на электронную почту",
|
||||
hasSwitch = true,
|
||||
switchState = false,
|
||||
onSwitchChange = { /* TODO */ }
|
||||
)
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Lock,
|
||||
title = "Приватность данных",
|
||||
subtitle = "Управление видимостью личной информации",
|
||||
onClick = { /* TODO */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdditionalSettingsSection() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Дополнительно",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Info,
|
||||
title = "Справка и поддержка",
|
||||
subtitle = "Получить помощь по использованию приложения",
|
||||
onClick = { /* TODO */ }
|
||||
)
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Info,
|
||||
title = "О приложении",
|
||||
subtitle = "Информация о версии и разработчиках",
|
||||
onClick = { /* TODO */ }
|
||||
)
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Info,
|
||||
title = "Политика конфиденциальности",
|
||||
subtitle = "Ознакомиться с правилами обработки данных",
|
||||
onClick = { /* TODO */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
hasSwitch: Boolean = false,
|
||||
switchState: Boolean = false,
|
||||
onSwitchChange: ((Boolean) -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
val itemModifier = if (onClick != null && !hasSwitch) {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
} else {
|
||||
Modifier.fillMaxWidth()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = itemModifier.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (hasSwitch && onSwitchChange != null) {
|
||||
Switch(
|
||||
checked = switchState,
|
||||
onCheckedChange = onSwitchChange
|
||||
)
|
||||
} else if (onClick != null) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowForward,
|
||||
contentDescription = "Открыть"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogoutButton(onLogout: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onLogout,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ExitToApp,
|
||||
contentDescription = "Выйти",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Выйти из аккаунта",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
package com.example.womansafe.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.womansafe.data.model.UserUpdate
|
||||
import com.example.womansafe.ui.viewmodel.ProfileSettingsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProfileSettingsScreen(
|
||||
profileSettingsViewModel: ProfileSettingsViewModel,
|
||||
onNavigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val uiState = profileSettingsViewModel.uiState
|
||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
profileSettingsViewModel.loadProfile()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Заголовок
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = "Назад"
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Настройки профиля",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
// Основная информация
|
||||
PersonalInfoSection(
|
||||
uiState = uiState,
|
||||
onUpdate = { userUpdate -> profileSettingsViewModel.updateProfile(userUpdate) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Настройки безопасности
|
||||
SecuritySection(
|
||||
onChangePassword = { showPasswordDialog = true },
|
||||
locationEnabled = uiState.locationSharingEnabled,
|
||||
onLocationToggle = { enabled ->
|
||||
profileSettingsViewModel.updateLocationSharing(enabled)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Настройки уведомлений
|
||||
NotificationSection(
|
||||
pushEnabled = uiState.pushNotificationsEnabled,
|
||||
emailEnabled = uiState.emailNotificationsEnabled,
|
||||
emergencyEnabled = uiState.emergencyNotificationsEnabled,
|
||||
onPushToggle = { enabled ->
|
||||
profileSettingsViewModel.updateNotificationSettings(push = enabled)
|
||||
},
|
||||
onEmailToggle = { enabled ->
|
||||
profileSettingsViewModel.updateNotificationSettings(email = enabled)
|
||||
},
|
||||
onEmergencyToggle = { enabled ->
|
||||
profileSettingsViewModel.updateNotificationSettings(emergency = enabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Диалог смены пароля
|
||||
if (showPasswordDialog) {
|
||||
ChangePasswordDialog(
|
||||
onDismiss = { showPasswordDialog = false },
|
||||
onConfirm = { currentPassword, newPassword ->
|
||||
profileSettingsViewModel.changePassword(currentPassword, newPassword)
|
||||
showPasswordDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Обработка ошибок
|
||||
uiState.error?.let { error ->
|
||||
LaunchedEffect(error) {
|
||||
// Показать snackbar с ошибкой
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonalInfoSection(
|
||||
uiState: com.example.womansafe.ui.viewmodel.ProfileSettingsUiState,
|
||||
onUpdate: (UserUpdate) -> Unit
|
||||
) {
|
||||
var firstName by remember { mutableStateOf(uiState.firstName ?: "") }
|
||||
var lastName by remember { mutableStateOf(uiState.lastName ?: "") }
|
||||
var phone by remember { mutableStateOf(uiState.phone ?: "") }
|
||||
var bio by remember { mutableStateOf(uiState.bio ?: "") }
|
||||
var isEditing by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Личная информация",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (isEditing) {
|
||||
// Сохранить изменения
|
||||
onUpdate(UserUpdate(
|
||||
first_name = firstName.takeIf { it.isNotBlank() },
|
||||
last_name = lastName.takeIf { it.isNotBlank() },
|
||||
phone = phone.takeIf { it.isNotBlank() },
|
||||
bio = bio.takeIf { it.isNotBlank() }
|
||||
))
|
||||
}
|
||||
isEditing = !isEditing
|
||||
}
|
||||
) {
|
||||
Text(if (isEditing) "Сохранить" else "Редактировать")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (isEditing) {
|
||||
OutlinedTextField(
|
||||
value = firstName,
|
||||
onValueChange = { firstName = it },
|
||||
label = { Text("Имя") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = lastName,
|
||||
onValueChange = { lastName = it },
|
||||
label = { Text("Фамилия") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it },
|
||||
label = { Text("Телефон") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = bio,
|
||||
onValueChange = { bio = it },
|
||||
label = { Text("О себе") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3
|
||||
)
|
||||
} else {
|
||||
// Отображение информации
|
||||
InfoRow("Имя", firstName.ifBlank { "Не указано" })
|
||||
InfoRow("Фамилия", lastName.ifBlank { "Не указано" })
|
||||
InfoRow("Телефон", phone.ifBlank { "Не указан" })
|
||||
InfoRow("Email", uiState.email ?: "Не указан")
|
||||
if (bio.isNotBlank()) {
|
||||
InfoRow("О себе", bio)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecuritySection(
|
||||
onChangePassword: () -> Unit,
|
||||
locationEnabled: Boolean,
|
||||
onLocationToggle: (Boolean) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Безопасность",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Смена пароля
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Сменить пароль",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = "Обновить пароль для входа в приложение",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(onClick = onChangePassword) {
|
||||
Text("Изменить")
|
||||
}
|
||||
}
|
||||
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Отслеживание местоположения
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Отслеживание местоположения",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = "Разрешить приложению отслеживать ваше местоположение",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = locationEnabled,
|
||||
onCheckedChange = onLocationToggle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationSection(
|
||||
pushEnabled: Boolean,
|
||||
emailEnabled: Boolean,
|
||||
emergencyEnabled: Boolean,
|
||||
onPushToggle: (Boolean) -> Unit,
|
||||
onEmailToggle: (Boolean) -> Unit,
|
||||
onEmergencyToggle: (Boolean) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Уведомления",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Push-уведомления
|
||||
NotificationToggle(
|
||||
title = "Push-уведомления",
|
||||
subtitle = "Получать уведомления о важных событиях",
|
||||
enabled = pushEnabled,
|
||||
onToggle = onPushToggle
|
||||
)
|
||||
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Email-уведомления
|
||||
NotificationToggle(
|
||||
title = "Email-уведомления",
|
||||
subtitle = "Получать уведомления на электронную почту",
|
||||
enabled = emailEnabled,
|
||||
onToggle = onEmailToggle
|
||||
)
|
||||
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Экстренные уведомления
|
||||
NotificationToggle(
|
||||
title = "Экстренные уведомления",
|
||||
subtitle = "Уведомления о чрезвычайных ситуациях",
|
||||
enabled = emergencyEnabled,
|
||||
onToggle = onEmergencyToggle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationToggle(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChangePasswordDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String, String) -> Unit
|
||||
) {
|
||||
var currentPassword by remember { mutableStateOf("") }
|
||||
var newPassword by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var showCurrentPassword by remember { mutableStateOf(false) }
|
||||
var showNewPassword by remember { mutableStateOf(false) }
|
||||
var showConfirmPassword by remember { mutableStateOf(false) }
|
||||
|
||||
val isValid = currentPassword.isNotBlank() &&
|
||||
newPassword.isNotBlank() &&
|
||||
confirmPassword == newPassword &&
|
||||
newPassword.length >= 8
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Сменить пароль") },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = currentPassword,
|
||||
onValueChange = { currentPassword = it },
|
||||
label = { Text("Текущий пароль") },
|
||||
visualTransformation = if (showCurrentPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showCurrentPassword = !showCurrentPassword }) {
|
||||
Icon(
|
||||
imageVector = if (showCurrentPassword) Icons.Filled.Lock else Icons.Filled.Info,
|
||||
contentDescription = if (showCurrentPassword) "Скрыть пароль" else "Показать пароль"
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = newPassword,
|
||||
onValueChange = { newPassword = it },
|
||||
label = { Text("Новый пароль") },
|
||||
visualTransformation = if (showNewPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showNewPassword = !showNewPassword }) {
|
||||
Icon(
|
||||
imageVector = if (showNewPassword) Icons.Filled.Lock else Icons.Filled.Info,
|
||||
contentDescription = if (showNewPassword) "Скрыть пароль" else "Показать пароль"
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
isError = newPassword.isNotEmpty() && newPassword.length < 8,
|
||||
supportingText = if (newPassword.isNotEmpty() && newPassword.length < 8) {
|
||||
{ Text("Пароль должен содержать минимум 8 символов") }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = { Text("Подтвердите пароль") },
|
||||
visualTransformation = if (showConfirmPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showConfirmPassword = !showConfirmPassword }) {
|
||||
Icon(
|
||||
imageVector = if (showConfirmPassword) Icons.Filled.Lock else Icons.Filled.Info,
|
||||
contentDescription = if (showConfirmPassword) "Скрыть пароль" else "Показать пароль"
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
isError = confirmPassword.isNotEmpty() && confirmPassword != newPassword,
|
||||
supportingText = if (confirmPassword.isNotEmpty() && confirmPassword != newPassword) {
|
||||
{ Text("Пароли не совпадают") }
|
||||
} else null
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(currentPassword, newPassword) },
|
||||
enabled = isValid
|
||||
) {
|
||||
Text("Изменить")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Отмена")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.example.womansafe.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Цвета приложения для использования в EnhancedCalendarScreen и других компонентах
|
||||
*/
|
||||
object AppColors {
|
||||
// Основные цвета приложения
|
||||
val PrimaryPink = Color(0xFFE91E63)
|
||||
val SecondaryBlue = Color(0xFF2196F3)
|
||||
val TertiaryGreen = Color(0xFF4CAF50)
|
||||
|
||||
// Цвета для индикаторов менструального цикла
|
||||
val PeriodColor = Color(0xFFE91E63)
|
||||
val PeriodLightColor = Color(0xFFF8BBD0)
|
||||
val OvulationColor = Color(0xFF2196F3)
|
||||
val OvulationLightColor = Color(0xFFBBDEFB)
|
||||
val FertileColor = Color(0xFF00BCD4)
|
||||
val FertileLightColor = Color(0xFFB2EBF2)
|
||||
|
||||
// Цвета настроений
|
||||
val HappyColor = Color(0xFF4CAF50)
|
||||
val NeutralColor = Color(0xFF9E9E9E)
|
||||
val SadColor = Color(0xFF9C27B0)
|
||||
val IrritatedColor = Color(0xFFFF5722)
|
||||
val AnxiousColor = Color(0xFFFFEB3B)
|
||||
|
||||
// Цвета для симптомов
|
||||
val SymptomColor = Color(0xFFFFC107)
|
||||
val MedicationColor = Color(0xFF673AB7)
|
||||
val NoteColor = Color(0xFF607D8B)
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import com.example.womansafe.data.model.*
|
||||
import com.example.womansafe.data.network.NetworkClient
|
||||
import com.example.womansafe.data.repository.ApiRepository
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class ApiTestState(
|
||||
val isLoading: Boolean = false,
|
||||
@@ -17,10 +19,16 @@ data class ApiTestState(
|
||||
val authToken: String? = null,
|
||||
val isAuthenticated: Boolean = false,
|
||||
val emergencyContacts: List<EmergencyContactResponse> = emptyList(),
|
||||
val emergencyReports: List<EmergencyReportResponse> = emptyList(),
|
||||
val emergencyAlerts: List<EmergencyAlertResponse> = emptyList(),
|
||||
val calendarEntries: List<CalendarEntryResponse> = emptyList(),
|
||||
val locationHistory: List<LocationResponse> = emptyList(),
|
||||
val safePlaces: List<SafePlaceResponse> = emptyList(),
|
||||
val notificationHistory: List<NotificationHistory> = emptyList(),
|
||||
val lastApiResponse: String = "",
|
||||
val lastApiError: String = "",
|
||||
val selectedEndpoint: String = "",
|
||||
val baseUrl: String = "http://10.0.2.2:8000/"
|
||||
val baseUrl: String = "http://192.168.0.103:8000/"
|
||||
)
|
||||
|
||||
class ApiTestViewModel : ViewModel() {
|
||||
@@ -55,6 +63,8 @@ class ApiTestViewModel : ViewModel() {
|
||||
lastApiResponse = "Login successful! Token: ${it.accessToken.take(20)}...",
|
||||
isLoading = false
|
||||
)
|
||||
// Запрос профиля сразу после авторизации
|
||||
getCurrentUser()
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||
@@ -87,9 +97,10 @@ class ApiTestViewModel : ViewModel() {
|
||||
val response = repository.register(email, username, password, fullName, phoneNumber)
|
||||
if (response.isSuccessful) {
|
||||
val user = response.body()
|
||||
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
|
||||
_state.value = _state.value.copy(
|
||||
currentUser = user,
|
||||
lastApiResponse = gson.toJson(user),
|
||||
lastApiResponse = userJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
@@ -123,9 +134,10 @@ class ApiTestViewModel : ViewModel() {
|
||||
val response = repository.getCurrentUser()
|
||||
if (response.isSuccessful) {
|
||||
val user = response.body()
|
||||
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
|
||||
_state.value = _state.value.copy(
|
||||
currentUser = user,
|
||||
lastApiResponse = gson.toJson(user),
|
||||
lastApiResponse = userJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
@@ -159,8 +171,9 @@ class ApiTestViewModel : ViewModel() {
|
||||
val response = repository.getDashboard()
|
||||
if (response.isSuccessful) {
|
||||
val dashboard = response.body()
|
||||
val dashboardJson = withContext(Dispatchers.Default) { gson.toJson(dashboard) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = gson.toJson(dashboard),
|
||||
lastApiResponse = dashboardJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
@@ -194,9 +207,10 @@ class ApiTestViewModel : ViewModel() {
|
||||
val response = repository.getEmergencyContacts()
|
||||
if (response.isSuccessful) {
|
||||
val contacts = response.body() ?: emptyList()
|
||||
val contactsJson = withContext(Dispatchers.Default) { gson.toJson(contacts) }
|
||||
_state.value = _state.value.copy(
|
||||
emergencyContacts = contacts,
|
||||
lastApiResponse = gson.toJson(contacts),
|
||||
lastApiResponse = contactsJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
@@ -231,8 +245,9 @@ class ApiTestViewModel : ViewModel() {
|
||||
val response = repository.createEmergencyContact(contact)
|
||||
if (response.isSuccessful) {
|
||||
val createdContact = response.body()
|
||||
val contactJson = withContext(Dispatchers.Default) { gson.toJson(createdContact) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = gson.toJson(createdContact),
|
||||
lastApiResponse = contactJson,
|
||||
isLoading = false
|
||||
)
|
||||
// Refresh the contacts list
|
||||
@@ -255,6 +270,368 @@ class ApiTestViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUser(
|
||||
firstName: String? = null,
|
||||
lastName: String? = null,
|
||||
phone: String? = null,
|
||||
dateOfBirth: String? = null,
|
||||
bio: String? = null,
|
||||
avatarUrl: String? = null,
|
||||
emergencyContact1Name: String? = null,
|
||||
emergencyContact1Phone: String? = null,
|
||||
emergencyContact2Name: String? = null,
|
||||
emergencyContact2Phone: String? = null,
|
||||
locationSharingEnabled: Boolean? = null,
|
||||
emergencyNotificationsEnabled: Boolean? = null,
|
||||
pushNotificationsEnabled: Boolean? = null
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = true,
|
||||
selectedEndpoint = "PUT /api/v1/users/me",
|
||||
lastApiError = "",
|
||||
lastApiResponse = ""
|
||||
)
|
||||
|
||||
try {
|
||||
val userUpdate = UserUpdate(
|
||||
first_name = firstName,
|
||||
last_name = lastName,
|
||||
phone = phone,
|
||||
date_of_birth = dateOfBirth,
|
||||
bio = bio,
|
||||
avatar_url = avatarUrl,
|
||||
emergency_contact_1_name = emergencyContact1Name,
|
||||
emergency_contact_1_phone = emergencyContact1Phone,
|
||||
emergency_contact_2_name = emergencyContact2Name,
|
||||
emergency_contact_2_phone = emergencyContact2Phone,
|
||||
location_sharing_enabled = locationSharingEnabled,
|
||||
emergency_notifications_enabled = emergencyNotificationsEnabled,
|
||||
push_notifications_enabled = pushNotificationsEnabled
|
||||
)
|
||||
val response = repository.updateCurrentUser(userUpdate)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val user = response.body()
|
||||
val userJson = withContext(Dispatchers.Default) { gson.toJson(user) }
|
||||
_state.value = _state.value.copy(
|
||||
currentUser = user,
|
||||
lastApiResponse = userJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Error ${response.code()}: $errorBody",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Network error: ${e.message}",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateEmergencyContact(contactId: Int, name: String?, phoneNumber: String?, relationship: String?, notes: String?) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = true,
|
||||
selectedEndpoint = "PATCH /api/v1/users/me/emergency-contacts/$contactId",
|
||||
lastApiError = "",
|
||||
lastApiResponse = ""
|
||||
)
|
||||
|
||||
try {
|
||||
val contactUpdate = EmergencyContactUpdate(name, phoneNumber, relationship, notes)
|
||||
val response = repository.updateEmergencyContact(contactId, contactUpdate)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val contact = response.body()
|
||||
val contactJson = withContext(Dispatchers.Default) { gson.toJson(contact) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = contactJson,
|
||||
isLoading = false
|
||||
)
|
||||
// Refresh contacts
|
||||
getEmergencyContacts()
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Error ${response.code()}: $errorBody",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Network error: ${e.message}",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteEmergencyContact(contactId: Int) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = true,
|
||||
selectedEndpoint = "DELETE /api/v1/users/me/emergency-contacts/$contactId",
|
||||
lastApiError = "",
|
||||
lastApiResponse = ""
|
||||
)
|
||||
|
||||
try {
|
||||
val response = repository.deleteEmergencyContact(contactId)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val message = response.body()
|
||||
val messageJson = withContext(Dispatchers.Default) { gson.toJson(message) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = messageJson,
|
||||
isLoading = false
|
||||
)
|
||||
// Refresh contacts
|
||||
getEmergencyContacts()
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Error ${response.code()}: $errorBody",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Network error: ${e.message}",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getEmergencyReports() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = true,
|
||||
selectedEndpoint = "GET /api/v1/emergency/reports",
|
||||
lastApiError = "",
|
||||
lastApiResponse = ""
|
||||
)
|
||||
|
||||
try {
|
||||
val response = repository.getEmergencyReports()
|
||||
if (response.isSuccessful) {
|
||||
val reports = response.body()
|
||||
val reportsJson = withContext(Dispatchers.Default) { gson.toJson(reports) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = reportsJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Error ${response.code()}: $errorBody",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Network error: ${e.message}",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getMyEmergencyAlerts() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = true,
|
||||
selectedEndpoint = "GET /api/v1/emergency/alerts/my",
|
||||
lastApiError = "",
|
||||
lastApiResponse = ""
|
||||
)
|
||||
|
||||
try {
|
||||
val response = repository.getMyEmergencyAlerts()
|
||||
if (response.isSuccessful) {
|
||||
val alerts = response.body()
|
||||
val alertsJson = withContext(Dispatchers.Default) { gson.toJson(alerts) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = alertsJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Error ${response.code()}: $errorBody",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Network error: ${e.message}",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocationHistory() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = true,
|
||||
selectedEndpoint = "GET /api/v1/locations/history",
|
||||
lastApiError = "",
|
||||
lastApiResponse = ""
|
||||
)
|
||||
|
||||
try {
|
||||
val response = repository.getLocationHistory()
|
||||
if (response.isSuccessful) {
|
||||
val history = response.body()
|
||||
val historyJson = withContext(Dispatchers.Default) { gson.toJson(history) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = historyJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Error ${response.code()}: $errorBody",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Network error: ${e.message}",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSafePlaces() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = true,
|
||||
selectedEndpoint = "GET /api/v1/locations/safe-places",
|
||||
lastApiError = "",
|
||||
lastApiResponse = ""
|
||||
)
|
||||
|
||||
try {
|
||||
val response = repository.getSafePlaces()
|
||||
if (response.isSuccessful) {
|
||||
val places = response.body()
|
||||
val placesJson = withContext(Dispatchers.Default) { gson.toJson(places) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = placesJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Error ${response.code()}: $errorBody",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Network error: ${e.message}",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCalendarEntries() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = true,
|
||||
selectedEndpoint = "GET /api/v1/calendar/entries",
|
||||
lastApiError = "",
|
||||
lastApiResponse = ""
|
||||
)
|
||||
|
||||
try {
|
||||
val response = repository.getCalendarEntries()
|
||||
if (response.isSuccessful) {
|
||||
val entries = response.body()
|
||||
val entriesJson = withContext(Dispatchers.Default) { gson.toJson(entries) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = entriesJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Error ${response.code()}: $errorBody",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Network error: ${e.message}",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getNotificationHistory() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = true,
|
||||
selectedEndpoint = "GET /api/v1/notifications/history",
|
||||
lastApiError = "",
|
||||
lastApiResponse = ""
|
||||
)
|
||||
|
||||
try {
|
||||
val response = repository.getNotificationHistory()
|
||||
if (response.isSuccessful) {
|
||||
val history = response.body()
|
||||
val historyJson = withContext(Dispatchers.Default) { gson.toJson(history) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = historyJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Error ${response.code()}: $errorBody",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Network error: ${e.message}",
|
||||
lastApiResponse = "",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun testGenericEndpoint(endpoint: String, method: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
@@ -272,10 +649,20 @@ class ApiTestViewModel : ViewModel() {
|
||||
"/api/v1/users/dashboard" -> repository.getDashboard()
|
||||
"/api/v1/emergency/reports" -> repository.getEmergencyReports()
|
||||
"/api/v1/emergency/alerts" -> repository.getEmergencyAlerts()
|
||||
"/api/v1/emergency/alerts/my" -> repository.getMyEmergencyAlerts()
|
||||
"/api/v1/emergency/alerts/nearby" -> repository.getNearbyEmergencyAlerts()
|
||||
"/api/v1/locations/last" -> repository.getLastLocation()
|
||||
"/api/v1/locations/history" -> repository.getLocationHistory()
|
||||
"/api/v1/locations/safe-places" -> repository.getSafePlaces()
|
||||
"/api/v1/locations/users/nearby" -> repository.getNearbyUsers()
|
||||
"/api/v1/calendar/entries" -> repository.getCalendarEntries()
|
||||
"/api/v1/calendar/cycle-overview" -> repository.getCycleOverview()
|
||||
"/api/v1/calendar/insights" -> repository.getCalendarInsights()
|
||||
"/api/v1/calendar/reminders" -> repository.getCalendarReminders()
|
||||
"/api/v1/calendar/settings" -> repository.getCalendarSettings()
|
||||
"/api/v1/notifications/preferences" -> repository.getNotificationPreferences()
|
||||
"/api/v1/notifications/devices" -> repository.getNotificationDevices()
|
||||
"/api/v1/notifications/history" -> repository.getNotificationHistory()
|
||||
else -> {
|
||||
_state.value = _state.value.copy(
|
||||
lastApiError = "Endpoint not implemented in this test app",
|
||||
@@ -287,8 +674,9 @@ class ApiTestViewModel : ViewModel() {
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val bodyJson = withContext(Dispatchers.Default) { gson.toJson(body) }
|
||||
_state.value = _state.value.copy(
|
||||
lastApiResponse = gson.toJson(body),
|
||||
lastApiResponse = bodyJson,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
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("@")
|
||||
println("=== LOGIN ATTEMPT ===")
|
||||
println("Input: $usernameOrEmail, isEmail: $isEmail")
|
||||
|
||||
val response = repository.login(
|
||||
email = if (isEmail) usernameOrEmail else null,
|
||||
username = if (!isEmail) usernameOrEmail else null,
|
||||
password = password
|
||||
)
|
||||
|
||||
println("Login Response Code: ${response.code()}")
|
||||
println("Login Response Message: ${response.message()}")
|
||||
println("Login Response Body: ${response.body()}")
|
||||
println("Login Response Error Body: ${response.errorBody()?.string()}")
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val token = response.body()
|
||||
token?.let {
|
||||
println("Login Success: Token received - ${it.accessToken.take(10)}...")
|
||||
NetworkClient.setAuthToken(it.accessToken)
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
isLoggedIn = true,
|
||||
token = it.accessToken,
|
||||
tokenType = it.tokenType
|
||||
)
|
||||
// Получаем профиль пользователя сразу после успешного входа
|
||||
getCurrentUser()
|
||||
} ?: run {
|
||||
println("Login Error: Token is null in successful response")
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка авторизации: Получен пустой токен"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Неизвестная ошибка"
|
||||
println("Login Error: ${response.code()} - $errorBody")
|
||||
|
||||
// Более специфичные сообщения для разных кодов ошибок
|
||||
val errorMessage = when (response.code()) {
|
||||
401 -> "Неверный логин или пароль"
|
||||
403 -> "Доступ запрещен"
|
||||
404 -> "Пользователь не найден"
|
||||
else -> "Ошибка авторизации: ${response.code()} - $errorBody"
|
||||
}
|
||||
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = errorMessage
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Login Exception: ${e.message}")
|
||||
e.printStackTrace()
|
||||
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 {
|
||||
println("=== REGISTER ATTEMPT ===")
|
||||
println("Username: $username, Email: $email, Full Name: $fullName, Phone Number: $phoneNumber")
|
||||
println("Password length: ${password.length}")
|
||||
|
||||
val response = repository.register(
|
||||
email = email,
|
||||
username = username,
|
||||
password = password,
|
||||
fullName = fullName,
|
||||
phoneNumber = phoneNumber
|
||||
)
|
||||
|
||||
println("Register Response Code: ${response.code()}")
|
||||
println("Register Response Message: ${response.message()}")
|
||||
println("Register Response Body: ${response.body()}")
|
||||
println("Register Response Error Body: ${response.errorBody()?.string()}")
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val userResponse = response.body()
|
||||
userResponse?.let {
|
||||
println("Registration Success: User ID: ${it.id}, Username: ${it.username}")
|
||||
|
||||
// После успешной регистрации выполняем автоматический вход
|
||||
println("Attempting auto-login after registration")
|
||||
// Выбираем имя пользователя или email для входа
|
||||
val loginIdentifier = username.ifBlank { email }
|
||||
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
user = it
|
||||
)
|
||||
|
||||
// Выполняем автоматический вход
|
||||
login(loginIdentifier, password)
|
||||
} ?: run {
|
||||
println("Register Error: User object is null in successful response")
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка регистрации: Получен пустой ответ"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Неизвестная ошибка"
|
||||
println("Register Error: ${response.code()} - $errorBody")
|
||||
|
||||
// Более специфичные сообщения для разных кодов ошибок
|
||||
val errorMessage = when (response.code()) {
|
||||
400 -> {
|
||||
if (errorBody.contains("email", ignoreCase = true)) {
|
||||
"Этот email уже используется или имеет неверный формат"
|
||||
} else if (errorBody.contains("username", ignoreCase = true)) {
|
||||
"Это имя пользователя уже используется"
|
||||
} else if (errorBody.contains("password", ignoreCase = true)) {
|
||||
"Пароль не соответствует требованиям безопасности"
|
||||
} else {
|
||||
"Ошибка в отправленных данных: $errorBody"
|
||||
}
|
||||
}
|
||||
409 -> "Пользователь с таким email или именем уже существует"
|
||||
422 -> "Неверный формат данных: $errorBody"
|
||||
else -> "Ошибка регистрации: ${response.code()} - $errorBody"
|
||||
}
|
||||
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = errorMessage
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Register Exception: ${e.message}")
|
||||
e.printStackTrace()
|
||||
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 autoLogin(token: String) {
|
||||
viewModelScope.launch {
|
||||
uiState = uiState.copy(isLoading = true, error = null)
|
||||
try {
|
||||
println("=== AUTO LOGIN ATTEMPT ===")
|
||||
println("Using saved token: ${token.take(10)}...")
|
||||
|
||||
// Устанавливаем токен в NetworkClient
|
||||
NetworkClient.setAuthToken(token)
|
||||
|
||||
// Устанавливаем состояние авторизации
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
isLoggedIn = true,
|
||||
token = token
|
||||
)
|
||||
|
||||
// Получаем профиль пользователя
|
||||
getCurrentUser()
|
||||
} catch (e: Exception) {
|
||||
println("Auto Login Exception: ${e.message}")
|
||||
e.printStackTrace()
|
||||
|
||||
// В случае ошибки сбрасываем токен и состояние
|
||||
NetworkClient.clearAuthToken()
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка автоматического входа: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
// Очищаем токен в NetworkClient
|
||||
NetworkClient.clearAuthToken()
|
||||
uiState = AuthUiState()
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
uiState = uiState.copy(error = null)
|
||||
}
|
||||
}
|
||||
|
||||
data class AuthUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isLoggedIn: Boolean = false,
|
||||
val user: UserResponse? = null,
|
||||
val emergencyContacts: List<EmergencyContactResponse>? = null,
|
||||
val dashboard: Any? = null,
|
||||
val profileExtended: UserResponse? = null,
|
||||
val token: String? = null,
|
||||
val tokenType: String? = null,
|
||||
val registrationSuccess: Boolean = false,
|
||||
val profileLoaded: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.example.womansafe.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.womansafe.data.api.CalendarApi
|
||||
import com.example.womansafe.data.local.CalendarDatabase
|
||||
import com.example.womansafe.data.model.calendar.*
|
||||
import com.example.womansafe.data.network.NetworkClient
|
||||
import com.example.womansafe.data.repository.CalendarRepository
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* ViewModel для функциональности менструального календаря
|
||||
*/
|
||||
class CalendarViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Состояние UI
|
||||
private val _calendarUiState = MutableLiveData(CalendarUiState())
|
||||
val calendarUiState: LiveData<CalendarUiState> = _calendarUiState
|
||||
|
||||
// Состояние загрузки
|
||||
private val _isLoading = MutableLiveData(false)
|
||||
val isLoading: LiveData<Boolean> = _isLoading
|
||||
|
||||
// Сообщения об ошибках
|
||||
private val _error = MutableLiveData<String?>(null)
|
||||
val error: LiveData<String?> = _error
|
||||
|
||||
// Дата, выбранная пользователем
|
||||
private val _selectedDate = MutableLiveData(LocalDate.now())
|
||||
val selectedDate: LiveData<LocalDate> = _selectedDate
|
||||
|
||||
// Репозиторий для работы с данными календаря
|
||||
private val repository: CalendarRepository
|
||||
|
||||
// Месяц, просматриваемый пользователем
|
||||
private var viewingMonth: LocalDate = LocalDate.now()
|
||||
|
||||
init {
|
||||
// Инициализация репозитория
|
||||
val calendarApi = NetworkClient.createService(CalendarApi::class.java)
|
||||
val calendarDao = CalendarDatabase.getDatabase(application).calendarDao()
|
||||
repository = CalendarRepository(calendarDao, calendarApi)
|
||||
|
||||
// Загрузка данных
|
||||
loadCalendarEntries()
|
||||
loadCycleData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка записей календаря
|
||||
*/
|
||||
private fun loadCalendarEntries() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
// Получение записей из репозитория как Flow
|
||||
repository.getCalendarEntriesFlow("userId").collect { entries: List<CalendarEntry> ->
|
||||
updateCalendarStateWithEntries(entries)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Ошибка при загрузке записей: ${e.localizedMessage}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка данных о цикле
|
||||
*/
|
||||
private fun loadCycleData() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
// Получение данных о цикле из репозитория
|
||||
val cycleData = repository.getCycleData()
|
||||
updateCalendarStateWithCycleData(cycleData)
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Ошибка при загрузке данных цикла: ${e.localizedMessage}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор даты пользователем
|
||||
*/
|
||||
fun selectDate(date: LocalDate) {
|
||||
_selectedDate.value = date
|
||||
|
||||
// Если выбрана дата другого месяца, обновляем просматриваемый месяц
|
||||
if (date.month != viewingMonth.month || date.year != viewingMonth.year) {
|
||||
viewingMonth = date.withDayOfMonth(1)
|
||||
updateMonthData()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление данных для текущего месяца
|
||||
*/
|
||||
private fun updateMonthData() {
|
||||
val currentState = _calendarUiState.value ?: CalendarUiState()
|
||||
_calendarUiState.value = currentState.copy(
|
||||
viewingMonth = viewingMonth
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление состояния UI с записями календаря
|
||||
*/
|
||||
private fun updateCalendarStateWithEntries(entries: List<CalendarEntry>) {
|
||||
val updatedState = _calendarUiState.value?.copy(
|
||||
entries = entries,
|
||||
selectedDateEntries = entries.filter {
|
||||
it.entryDate.isEqual(selectedDate.value)
|
||||
}
|
||||
) ?: CalendarUiState(entries = entries)
|
||||
_calendarUiState.value = updatedState
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление состояния UI с данными о цикле
|
||||
*/
|
||||
private fun updateCalendarStateWithCycleData(cycleData: CycleData?) {
|
||||
if (cycleData == null) return
|
||||
|
||||
val updatedState = _calendarUiState.value?.copy(
|
||||
cycleStartDate = cycleData.lastPeriodStartDate,
|
||||
cycleData = cycleData,
|
||||
avgCycleLength = cycleData.averageCycleLength,
|
||||
avgPeriodLength = cycleData.averagePeriodLength,
|
||||
cycleRegularityScore = cycleData.regularityScore
|
||||
) ?: CalendarUiState()
|
||||
_calendarUiState.value = updatedState
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление инсайта
|
||||
*/
|
||||
fun dismissInsight(id: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Здесь должен быть вызов метода репозитория для удаления инсайта
|
||||
// repository.dismissInsight(id)
|
||||
|
||||
// Обновляем список инсайтов, удаляя указанный
|
||||
_calendarUiState.value = _calendarUiState.value?.copy(
|
||||
insights = _calendarUiState.value?.insights?.filter { it.id != id } ?: emptyList()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Ошибка при удалении инсайта: ${e.localizedMessage}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка сообщения об ошибке
|
||||
*/
|
||||
fun clearErrorMessage() {
|
||||
_error.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавление новой записи календаря
|
||||
*/
|
||||
fun addCalendarEntry(entry: CalendarEntry) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
repository.addCalendarEntry(entry)
|
||||
// Обновляем данные после добавления
|
||||
loadCalendarEntries()
|
||||
_error.value = null
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Ошибка при добавлении записи: ${e.localizedMessage}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление существующей записи
|
||||
*/
|
||||
fun updateCalendarEntry(entry: CalendarEntry) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
repository.updateCalendarEntry(entry)
|
||||
// Обновляем данные после изменения
|
||||
loadCalendarEntries()
|
||||
_error.value = null
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Ошибка при обновлении записи: ${e.localizedMessage}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление записи календаря
|
||||
*/
|
||||
fun deleteCalendarEntry(entryId: Long) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
repository.deleteCalendarEntry(entryId)
|
||||
// Обновляем данные после удаления
|
||||
loadCalendarEntries()
|
||||
_error.value = null
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Ошибка при удалении записи: ${e.localizedMessage}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Класс, представляющий состояние UI календаря
|
||||
*/
|
||||
data class CalendarUiState(
|
||||
val entries: List<CalendarEntry> = emptyList(),
|
||||
val selectedDateEntries: List<CalendarEntry> = emptyList(),
|
||||
val cycleStartDate: LocalDate? = null,
|
||||
val cycleData: CycleData? = null,
|
||||
val specialDays: Map<LocalDate, DayType> = emptyMap(),
|
||||
val avgCycleLength: Int? = null,
|
||||
val avgPeriodLength: Int? = null,
|
||||
val cycleRegularityScore: Int? = null,
|
||||
val insights: List<HealthInsight> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val viewingMonth: LocalDate = LocalDate.now().withDayOfMonth(1)
|
||||
)
|
||||
|
||||
/**
|
||||
* Типы дней в календаре
|
||||
*/
|
||||
enum class DayType {
|
||||
PERIOD, // День менструации
|
||||
OVULATION, // День овуляции
|
||||
FERTILE, // Фертильный день
|
||||
PREDICTED_PERIOD, // Прогноз дня менструации
|
||||
NORMAL // Обычный день
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.example.womansafe.ui.viewmodel
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.womansafe.data.model.EmergencyContactCreate
|
||||
import com.example.womansafe.data.model.EmergencyContactResponse
|
||||
import com.example.womansafe.data.model.EmergencyContactUpdate
|
||||
import com.example.womansafe.data.repository.ApiRepository
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class EmergencyContactsViewModel : ViewModel() {
|
||||
private val repository = ApiRepository()
|
||||
|
||||
var uiState by mutableStateOf(EmergencyContactsUiState())
|
||||
private set
|
||||
|
||||
fun loadContacts() {
|
||||
viewModelScope.launch {
|
||||
uiState = uiState.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val response = repository.getEmergencyContacts()
|
||||
if (response.isSuccessful) {
|
||||
uiState = uiState.copy(
|
||||
contacts = response.body() ?: emptyList(),
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка загрузки контактов: ${response.code()}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка сети: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addContact(contact: EmergencyContactCreate) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.createEmergencyContact(contact)
|
||||
if (response.isSuccessful) {
|
||||
loadContacts() // Перезагружаем список
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка добавления контакта: ${response.code()}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка добавления контакта: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun editContact(contact: EmergencyContactResponse) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val update = EmergencyContactUpdate(
|
||||
name = contact.name,
|
||||
phone_number = contact.phone_number,
|
||||
relationship = contact.relationship
|
||||
)
|
||||
val response = repository.updateEmergencyContact(contact.id, update)
|
||||
if (response.isSuccessful) {
|
||||
loadContacts() // Перезагружаем список
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка редактирования контакта: ${response.code()}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка редактирования контакта: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContact(contactId: Int) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.deleteEmergencyContact(contactId)
|
||||
if (response.isSuccessful) {
|
||||
loadContacts() // Перезагружаем список
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка удаления контакта: ${response.code()}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка удаления контакта: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun callContact(phoneNumber: String) {
|
||||
// В реальном приложении здесь будет интент для звонка
|
||||
// Пока просто логируем
|
||||
println("Calling: $phoneNumber")
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
uiState = uiState.copy(error = null)
|
||||
}
|
||||
}
|
||||
|
||||
data class EmergencyContactsUiState(
|
||||
val contacts: List<EmergencyContactResponse> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.example.womansafe.ui.viewmodel
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Address
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.womansafe.data.api.WomanSafeApi
|
||||
import com.example.womansafe.data.model.*
|
||||
import com.example.womansafe.data.network.RetrofitClient
|
||||
import com.google.android.gms.location.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
data class EmergencyUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val currentLocation: UserLocation? = null,
|
||||
val emergencyContacts: List<EmergencyContactResponse> = emptyList(),
|
||||
val isEmergencyActive: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val hasLocationPermission: Boolean = false,
|
||||
val locationPermissionRequested: Boolean = false
|
||||
)
|
||||
|
||||
class EmergencyViewModel : ViewModel() {
|
||||
private val api: WomanSafeApi = RetrofitClient.api
|
||||
|
||||
private val _uiState = MutableStateFlow(EmergencyUiState())
|
||||
val uiState: StateFlow<EmergencyUiState> = _uiState.asStateFlow()
|
||||
|
||||
private lateinit var fusedLocationClient: FusedLocationProviderClient
|
||||
|
||||
fun initLocationClient(context: Context) {
|
||||
fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
|
||||
checkLocationPermission(context)
|
||||
}
|
||||
|
||||
private fun checkLocationPermission(context: Context) {
|
||||
val hasPermission = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
_uiState.value = _uiState.value.copy(hasLocationPermission = hasPermission)
|
||||
}
|
||||
|
||||
fun onLocationPermissionResult(granted: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
hasLocationPermission = granted,
|
||||
locationPermissionRequested = true
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getCurrentLocation(context: Context): UserLocation? {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
try {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
continuation.resume(null)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
|
||||
fusedLocationClient.lastLocation
|
||||
.addOnSuccessListener { location: Location? ->
|
||||
if (location != null) {
|
||||
val address = getAddressFromLocation(context, location.latitude, location.longitude)
|
||||
val userLocation = UserLocation(
|
||||
latitude = location.latitude,
|
||||
longitude = location.longitude,
|
||||
accuracy = location.accuracy,
|
||||
address = address
|
||||
)
|
||||
_uiState.value = _uiState.value.copy(currentLocation = userLocation)
|
||||
continuation.resume(userLocation)
|
||||
} else {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { exception ->
|
||||
continuation.resumeWithException(exception)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAddressFromLocation(context: Context, latitude: Double, longitude: Double): String? {
|
||||
return try {
|
||||
val geocoder = Geocoder(context, Locale.getDefault())
|
||||
val addresses: List<Address> = geocoder.getFromLocation(latitude, longitude, 1) ?: emptyList()
|
||||
if (addresses.isNotEmpty()) {
|
||||
val address = addresses[0]
|
||||
"${address.getAddressLine(0)}"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun loadEmergencyContacts() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
val response = api.getEmergencyContacts()
|
||||
if (response.isSuccessful) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
emergencyContacts = response.body() ?: emptyList(),
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
val errorMsg = when (response.code()) {
|
||||
401 -> "Необходима авторизация. Пожалуйста, войдите снова."
|
||||
403 -> "У вас нет доступа к этому ресурсу."
|
||||
404 -> "Список контактов не найден."
|
||||
500, 502, 503 -> "Ошибка сервера. Пожалуйста, попробуйте позже."
|
||||
else -> "Не удалось загрузить контакты: код ${response.code()}"
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = errorMsg,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val errorMsg = when {
|
||||
e.message?.contains("Unable to resolve host") == true -> "Нет соединения с сервером. Проверьте подключение к интернету."
|
||||
e.message?.contains("timeout") == true -> "Время ожидания истекло. Проверьте подключение к интернету."
|
||||
else -> "Ошибка загрузки контактов: ${e.message ?: "неизвестная ошибка"}"
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = errorMsg,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createEmergencyAlert(
|
||||
context: Context,
|
||||
type: EmergencyType,
|
||||
description: String? = null,
|
||||
isAnonymous: Boolean = false
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
|
||||
// Получаем текущее местоположение
|
||||
val location = getCurrentLocation(context)
|
||||
if (location == null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = "Не удалось получить местоположение",
|
||||
isLoading = false
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Создаем запрос используя обновленную модель из ApiModels.kt
|
||||
val request = EmergencyAlertCreate(
|
||||
type = type.name.lowercase(),
|
||||
description = description,
|
||||
latitude = location.latitude,
|
||||
longitude = location.longitude,
|
||||
address = location.address,
|
||||
is_anonymous = isAnonymous
|
||||
)
|
||||
|
||||
// Отправляем на сервер
|
||||
val response = api.createEmergencyAlert(request)
|
||||
if (response.isSuccessful) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isEmergencyActive = true,
|
||||
isLoading = false,
|
||||
errorMessage = null
|
||||
)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = "Не удалось создать экстренное событие: ${response.code()}",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = "Ошибка создания экстренного события: ${e.message}",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelEmergencyAlert() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
// Здесь можно добавить API вызов для отмены экстренного события
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isEmergencyActive = false,
|
||||
isLoading = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = "Ошибка отмены экстренного события: ${e.message}",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
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.calendar.*
|
||||
import com.example.womansafe.data.repository.CalendarRepository
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* Состояние UI для экрана календаря
|
||||
*/
|
||||
data class EnhancedCalendarUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val currentMonth: LocalDate = LocalDate.now(),
|
||||
val selectedDate: LocalDate = LocalDate.now(),
|
||||
val events: Map<LocalDate, List<CalendarEvent>> = emptyMap(),
|
||||
val selectedEvent: CalendarEvent? = null,
|
||||
val cycleData: CycleData? = null,
|
||||
val predictions: CyclePrediction? = null,
|
||||
val statistics: CycleStatistics? = null,
|
||||
val insights: List<HealthInsight> = emptyList(),
|
||||
val showEventDialog: Boolean = false,
|
||||
val showInsightsDialog: Boolean = false,
|
||||
val showSettingsDialog: Boolean = false,
|
||||
val editingEvent: CalendarEvent? = null
|
||||
)
|
||||
|
||||
class EnhancedCalendarViewModel(
|
||||
private val repository: CalendarRepository
|
||||
) : ViewModel() {
|
||||
|
||||
var uiState by mutableStateOf(EnhancedCalendarUiState())
|
||||
private set
|
||||
|
||||
private val userId: String = "userId" // TODO: заменить на реальный userId
|
||||
|
||||
init {
|
||||
loadInitialData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка всех необходимых данных при запуске
|
||||
*/
|
||||
fun loadInitialData() {
|
||||
loadCalendarData(LocalDate.now())
|
||||
loadCycleData()
|
||||
loadHealthInsights()
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка данных календаря для выбранного месяца
|
||||
*/
|
||||
fun loadCalendarData(selectedDate: LocalDate) {
|
||||
viewModelScope.launch {
|
||||
uiState = uiState.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val entries = repository.getCalendarEntries()
|
||||
|
||||
// Преобразуем CalendarEntry в CalendarEvent
|
||||
val events = entries.map { convertToCalendarEvent(it) }
|
||||
|
||||
// Группируем события по дате
|
||||
val groupedEvents = events.groupBy { it.date }
|
||||
|
||||
uiState = uiState.copy(
|
||||
events = groupedEvents,
|
||||
selectedDate = selectedDate,
|
||||
currentMonth = selectedDate.withDayOfMonth(1),
|
||||
isLoading = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка: ${e.message}",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует CalendarEntry в CalendarEvent для совместимости в UI
|
||||
*/
|
||||
private fun convertToCalendarEvent(entry: CalendarEntry): CalendarEvent {
|
||||
return CalendarEvent(
|
||||
id = entry.id.toString(),
|
||||
userId = entry.userId,
|
||||
date = entry.entryDate,
|
||||
type = entry.entryType.name.lowercase(),
|
||||
flowIntensity = entry.flowIntensity?.name?.lowercase(),
|
||||
mood = entry.mood?.name?.lowercase(),
|
||||
energyLevel = entry.energyLevel,
|
||||
sleepHours = entry.sleepHours,
|
||||
symptoms = entry.symptoms?.map { it.name.lowercase() },
|
||||
medications = entry.medications,
|
||||
notes = entry.notes,
|
||||
isDismissed = false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка данных о цикле
|
||||
*/
|
||||
private fun loadCycleData() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val cycleData = repository.getCycleData()
|
||||
uiState = uiState.copy(cycleData = cycleData)
|
||||
} catch (e: Exception) {
|
||||
// Ошибка загрузки данных цикла
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка статистики цикла
|
||||
*/
|
||||
private fun loadCycleStatistics() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.getCycleStatistics(userId)
|
||||
if (response.isSuccessful) {
|
||||
val statistics = response.body()
|
||||
uiState = uiState.copy(statistics = statistics)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ошибка загрузки статистики
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка прогнозов цикла
|
||||
*/
|
||||
private fun loadCyclePredictions() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.getCyclePredictions(userId)
|
||||
if (response.isSuccessful) {
|
||||
val predictions = response.body()
|
||||
uiState = uiState.copy(predictions = predictions)
|
||||
} else {
|
||||
val cachedPredictions = repository.getCachedPredictions(userId)
|
||||
uiState = uiState.copy(predictions = cachedPredictions)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val cachedPredictions = repository.getCachedPredictions(userId)
|
||||
uiState = uiState.copy(predictions = cachedPredictions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка инсайтов о здоровье
|
||||
*/
|
||||
private fun loadHealthInsights() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.getHealthInsights()
|
||||
if (response.isSuccessful) {
|
||||
val insights = response.body() ?: emptyList()
|
||||
uiState = uiState.copy(insights = insights)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ошибка загрузки инсайтов
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор даты
|
||||
*/
|
||||
fun selectDate(date: LocalDate) {
|
||||
uiState = uiState.copy(selectedDate = date)
|
||||
}
|
||||
|
||||
/**
|
||||
* Изменение отображаемого месяца
|
||||
*/
|
||||
fun changeMonth(offset: Int) {
|
||||
val newCurrentMonth = uiState.currentMonth.plusMonths(offset.toLong())
|
||||
loadCalendarData(newCurrentMonth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор события для просмотра деталей
|
||||
*/
|
||||
fun selectEvent(event: CalendarEvent) {
|
||||
uiState = uiState.copy(selectedEvent = event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка выбранного события
|
||||
*/
|
||||
fun clearSelectedEvent() {
|
||||
uiState = uiState.copy(selectedEvent = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать диалог добавления/редактирования события
|
||||
*/
|
||||
fun showEventDialog(event: CalendarEvent? = null) {
|
||||
uiState = uiState.copy(
|
||||
showEventDialog = true,
|
||||
editingEvent = event
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Скрыть диалог добавления/редактирования события
|
||||
*/
|
||||
fun hideEventDialog() {
|
||||
uiState = uiState.copy(
|
||||
showEventDialog = false,
|
||||
editingEvent = null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать диалог инсайтов
|
||||
*/
|
||||
fun showInsightsDialog() {
|
||||
uiState = uiState.copy(showInsightsDialog = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Скрыть диалог инсайтов
|
||||
*/
|
||||
fun hideInsightsDialog() {
|
||||
uiState = uiState.copy(showInsightsDialog = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать диалог настроек
|
||||
*/
|
||||
fun showSettingsDialog() {
|
||||
uiState = uiState.copy(showSettingsDialog = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Скрыть диалог настроек
|
||||
*/
|
||||
fun hideSettingsDialog() {
|
||||
uiState = uiState.copy(showSettingsDialog = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавление нового события
|
||||
*/
|
||||
fun addCalendarEntry(entry: CalendarEntry) {
|
||||
viewModelScope.launch {
|
||||
uiState = uiState.copy(isLoading = true)
|
||||
try {
|
||||
val result = repository.addCalendarEntry(entry)
|
||||
if (result.isSuccess) {
|
||||
loadCalendarData(entry.entryDate)
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка при добавлении события",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка: ${e.message}",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление существующего события
|
||||
*/
|
||||
fun updateCalendarEntry(entry: CalendarEntry) {
|
||||
viewModelScope.launch {
|
||||
uiState = uiState.copy(isLoading = true)
|
||||
try {
|
||||
val result = repository.updateCalendarEntry(entry)
|
||||
if (result.isSuccess) {
|
||||
loadCalendarData(entry.entryDate)
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка при обновлении события",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка: ${e.message}",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление события
|
||||
*/
|
||||
fun deleteCalendarEntry(entryId: Long) {
|
||||
viewModelScope.launch {
|
||||
uiState = uiState.copy(isLoading = true)
|
||||
try {
|
||||
val result = repository.deleteCalendarEntry(entryId)
|
||||
if (result.isSuccess) {
|
||||
loadCalendarData(uiState.selectedDate)
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка при удалении события",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка: ${e.message}",
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отметка инсайта как прочитанного/отклоненного
|
||||
*/
|
||||
fun dismissInsight(insightId: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.dismissInsight(insightId)
|
||||
loadHealthInsights()
|
||||
} catch (e: Exception) {
|
||||
// Обработка ошибки без блокировки интерфейса
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка сообщения об ошибке
|
||||
*/
|
||||
fun clearError() {
|
||||
uiState = uiState.copy(error = null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.example.womansafe.ui.viewmodel
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.womansafe.data.model.UserUpdate
|
||||
import com.example.womansafe.data.repository.ApiRepository
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ProfileSettingsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val username: String = "",
|
||||
val email: String = "",
|
||||
val firstName: String = "",
|
||||
val lastName: String = "",
|
||||
val phone: String = "",
|
||||
val bio: String = "",
|
||||
val locationSharingEnabled: Boolean = false,
|
||||
val pushNotificationsEnabled: Boolean = true,
|
||||
val emailNotificationsEnabled: Boolean = false,
|
||||
val emergencyNotificationsEnabled: Boolean = true,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class ProfileSettingsViewModel : ViewModel() {
|
||||
private val repository = ApiRepository()
|
||||
|
||||
var uiState by mutableStateOf(ProfileSettingsUiState())
|
||||
private set
|
||||
|
||||
fun loadProfile() {
|
||||
viewModelScope.launch {
|
||||
uiState = uiState.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val response = repository.getCurrentUser()
|
||||
if (response.isSuccessful) {
|
||||
val user = response.body()
|
||||
user?.let {
|
||||
uiState = uiState.copy(
|
||||
username = it.username ?: "",
|
||||
email = it.email,
|
||||
firstName = it.first_name ?: "",
|
||||
lastName = it.last_name ?: "",
|
||||
phone = it.phone ?: "",
|
||||
bio = it.bio ?: "",
|
||||
locationSharingEnabled = it.location_sharing_enabled,
|
||||
pushNotificationsEnabled = it.push_notifications_enabled,
|
||||
emailNotificationsEnabled = it.email_notifications_enabled ?: false,
|
||||
emergencyNotificationsEnabled = it.emergency_notifications_enabled,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка загрузки профиля: ${response.code()}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка сети: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProfile(userUpdate: UserUpdate) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.updateCurrentUser(userUpdate)
|
||||
if (response.isSuccessful) {
|
||||
loadProfile() // Перезагружаем профиль
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка обновления профиля: ${response.code()}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка обновления профиля: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changePassword(currentPassword: String, newPassword: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.changePassword(currentPassword, newPassword)
|
||||
if (response.isSuccessful) {
|
||||
// Успешно изменен пароль
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка смены пароля: ${response.code()}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка смены пароля: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLocationSharing(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val userUpdate = UserUpdate(location_sharing_enabled = enabled)
|
||||
val response = repository.updateCurrentUser(userUpdate)
|
||||
if (response.isSuccessful) {
|
||||
uiState = uiState.copy(locationSharingEnabled = enabled)
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка обновления настроек: ${response.code()}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка обновления настроек: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotificationSettings(
|
||||
push: Boolean? = null,
|
||||
email: Boolean? = null,
|
||||
emergency: Boolean? = null
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val userUpdate = UserUpdate(
|
||||
push_notifications_enabled = push,
|
||||
email_notifications_enabled = email,
|
||||
emergency_notifications_enabled = emergency
|
||||
)
|
||||
val response = repository.updateCurrentUser(userUpdate)
|
||||
if (response.isSuccessful) {
|
||||
uiState = uiState.copy(
|
||||
pushNotificationsEnabled = push ?: uiState.pushNotificationsEnabled,
|
||||
emailNotificationsEnabled = email ?: uiState.emailNotificationsEnabled,
|
||||
emergencyNotificationsEnabled = emergency ?: uiState.emergencyNotificationsEnabled
|
||||
)
|
||||
} else {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка обновления уведомлений: ${response.code()}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(
|
||||
error = "Ошибка обновления уведомлений: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
107
app/src/main/java/com/example/womansafe/util/ContactsHelper.kt
Normal file
107
app/src/main/java/com/example/womansafe/util/ContactsHelper.kt
Normal file
@@ -0,0 +1,107 @@
|
||||
package com.example.womansafe.util
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Класс для работы с контактами устройства
|
||||
*/
|
||||
class ContactsHelper(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Класс модели контакта
|
||||
*/
|
||||
data class Contact(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val photoUri: Uri? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Получение списка контактов устройства
|
||||
*/
|
||||
suspend fun getContacts(): List<Contact> = withContext(Dispatchers.IO) {
|
||||
val contacts = mutableListOf<Contact>()
|
||||
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
|
||||
ContactsContract.Contacts.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
ContactsContract.Contacts.PHOTO_URI
|
||||
)
|
||||
|
||||
val cursor: Cursor? = context.contentResolver.query(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
ContactsContract.Contacts.DISPLAY_NAME + " ASC"
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
val idColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
|
||||
val nameColumn = it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
|
||||
val numberColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
val photoColumn = it.getColumnIndex(ContactsContract.Contacts.PHOTO_URI)
|
||||
|
||||
while (it.moveToNext()) {
|
||||
val id = it.getString(idColumn)
|
||||
val name = it.getString(nameColumn) ?: "Неизвестно"
|
||||
val number = it.getString(numberColumn) ?: "Нет номера"
|
||||
val photoUri = it.getString(photoColumn)?.let { uri -> Uri.parse(uri) }
|
||||
|
||||
contacts.add(Contact(id, name, number, photoUri))
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext contacts.distinctBy { it.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение контакта по ID
|
||||
*/
|
||||
suspend fun getContactById(contactId: String): Contact? = withContext(Dispatchers.IO) {
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
|
||||
ContactsContract.Contacts.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
ContactsContract.Contacts.PHOTO_URI
|
||||
)
|
||||
|
||||
val selection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?"
|
||||
val selectionArgs = arrayOf(contactId)
|
||||
|
||||
val cursor: Cursor? = context.contentResolver.query(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
null
|
||||
)
|
||||
|
||||
var contact: Contact? = null
|
||||
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val idColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
|
||||
val nameColumn = it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
|
||||
val numberColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
val photoColumn = it.getColumnIndex(ContactsContract.Contacts.PHOTO_URI)
|
||||
|
||||
val id = it.getString(idColumn)
|
||||
val name = it.getString(nameColumn) ?: "Неизвестно"
|
||||
val number = it.getString(numberColumn) ?: "Нет номера"
|
||||
val photoUri = it.getString(photoColumn)?.let { uri -> Uri.parse(uri) }
|
||||
|
||||
contact = Contact(id, name, number, photoUri)
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext contact
|
||||
}
|
||||
}
|
||||
130
app/src/main/java/com/example/womansafe/util/DateUtils.kt
Normal file
130
app/src/main/java/com/example/womansafe/util/DateUtils.kt
Normal file
@@ -0,0 +1,130 @@
|
||||
package com.example.womansafe.util
|
||||
|
||||
import android.os.Build
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.LocalDate
|
||||
import java.time.Period
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Утилитарный класс для работы с датами на устройствах с разными версиями API
|
||||
*/
|
||||
object DateUtils {
|
||||
|
||||
/**
|
||||
* Форматирует LocalDate в строку с учетом API устройства
|
||||
*/
|
||||
fun formatDate(date: LocalDate?, pattern: String): String {
|
||||
date ?: return ""
|
||||
|
||||
// Для устройств с API 26+ используем Java 8 Time API
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val formatter = DateTimeFormatter.ofPattern(pattern)
|
||||
date.format(formatter)
|
||||
} else {
|
||||
// Для более старых устройств используем легаси API
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar.set(date.year, date.monthValue - 1, date.dayOfMonth)
|
||||
|
||||
val format = SimpleDateFormat(pattern, Locale.getDefault())
|
||||
format.format(calendar.time)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, являются ли две даты одним и тем же днем
|
||||
*/
|
||||
fun isSameDay(date1: LocalDate, date2: LocalDate): Boolean {
|
||||
return date1.year == date2.year &&
|
||||
date1.monthValue == date2.monthValue &&
|
||||
date1.dayOfMonth == date2.dayOfMonth
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит строку в LocalDate с учетом API устройства
|
||||
* @param dateStr строка с датой
|
||||
* @param pattern шаблон формата даты
|
||||
* @return LocalDate или null, если парсинг не удался
|
||||
*/
|
||||
fun parseDate(dateStr: String?, pattern: String): LocalDate? {
|
||||
dateStr ?: return null
|
||||
if (dateStr.isBlank()) return null
|
||||
|
||||
try {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val formatter = DateTimeFormatter.ofPattern(pattern)
|
||||
LocalDate.parse(dateStr, formatter)
|
||||
} else {
|
||||
val format = SimpleDateFormat(pattern, Locale.getDefault())
|
||||
val date = format.parse(dateStr) ?: return null
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar.time = date
|
||||
LocalDate.of(
|
||||
calendar.get(Calendar.YEAR),
|
||||
calendar.get(Calendar.MONTH) + 1,
|
||||
calendar.get(Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
}
|
||||
} catch (e: DateTimeParseException) {
|
||||
return null
|
||||
} catch (e: ParseException) {
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает текущую дату
|
||||
* @return текущая дата как LocalDate
|
||||
*/
|
||||
fun getCurrentDate(): LocalDate {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
LocalDate.now()
|
||||
} else {
|
||||
val calendar = Calendar.getInstance()
|
||||
LocalDate.of(
|
||||
calendar.get(Calendar.YEAR),
|
||||
calendar.get(Calendar.MONTH) + 1,
|
||||
calendar.get(Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет разницу в днях между двумя датами
|
||||
* @param startDate начальная дата
|
||||
* @param endDate конечная дата
|
||||
* @return количество дней между датами (может быть отрицательным)
|
||||
*/
|
||||
fun daysBetween(startDate: LocalDate, endDate: LocalDate): Int {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Period.between(startDate, endDate).days + Period.between(startDate, endDate).months * 30 + Period.between(startDate, endDate).years * 365
|
||||
} else {
|
||||
val startCalendar = Calendar.getInstance()
|
||||
startCalendar.set(startDate.year, startDate.monthValue - 1, startDate.dayOfMonth)
|
||||
|
||||
val endCalendar = Calendar.getInstance()
|
||||
endCalendar.set(endDate.year, endDate.monthValue - 1, endDate.dayOfMonth)
|
||||
|
||||
val diffInMillis = endCalendar.timeInMillis - startCalendar.timeInMillis
|
||||
TimeUnit.MILLISECONDS.toDays(diffInMillis).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, находится ли дата между двумя другими датами (включительно)
|
||||
* @param date проверяемая дата
|
||||
* @param startDate начальная дата диапазона
|
||||
* @param endDate конечная дата диапазона
|
||||
* @return true, если дата находится в диапазоне (включительно)
|
||||
*/
|
||||
fun isDateInRange(date: LocalDate, startDate: LocalDate, endDate: LocalDate): Boolean {
|
||||
return (date.isEqual(startDate) || date.isAfter(startDate)) &&
|
||||
(date.isEqual(endDate) || date.isBefore(endDate))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.example.womansafe.util
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Класс для управления разрешениями в приложении
|
||||
*/
|
||||
class PermissionManager(private val context: Context) {
|
||||
|
||||
private val preferenceManager = PreferenceManager.getInstance(context)
|
||||
|
||||
// Проверка разрешения
|
||||
fun isPermissionGranted(permission: String): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
permission
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
// Сохранение статуса разрешения
|
||||
fun savePermissionStatus(permission: String, granted: Boolean) {
|
||||
preferenceManager.savePermissionGranted(permission, granted)
|
||||
}
|
||||
|
||||
// Получение сохраненного статуса разрешения
|
||||
fun getSavedPermissionStatus(permission: String): Boolean {
|
||||
return preferenceManager.isPermissionGranted(permission)
|
||||
}
|
||||
|
||||
// Получение списка необходимых разрешений
|
||||
fun getRequiredPermissions(): List<String> {
|
||||
return listOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.READ_CONTACTS
|
||||
)
|
||||
}
|
||||
|
||||
// Получение непредоставленных разрешений
|
||||
fun getMissingPermissions(): List<String> {
|
||||
return getRequiredPermissions().filter { !isPermissionGranted(it) }
|
||||
}
|
||||
|
||||
// Открытие настроек приложения для управления разрешениями
|
||||
fun openAppSettings() {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
val uri = Uri.fromParts("package", context.packageName, null)
|
||||
intent.data = uri
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Разрешения
|
||||
const val PERMISSION_LOCATION = Manifest.permission.ACCESS_FINE_LOCATION
|
||||
const val PERMISSION_CAMERA = Manifest.permission.CAMERA
|
||||
const val PERMISSION_CONTACTS = Manifest.permission.READ_CONTACTS
|
||||
|
||||
// Синглтон для доступа к PermissionManager
|
||||
@Volatile private var INSTANCE: PermissionManager? = null
|
||||
|
||||
fun getInstance(context: Context): PermissionManager {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: PermissionManager(context).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable для запроса разрешений
|
||||
*/
|
||||
@Composable
|
||||
fun RequestPermissions(
|
||||
permissions: List<String>,
|
||||
onAllPermissionsGranted: () -> Unit = {},
|
||||
onPermissionDenied: (List<String>) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val permissionManager = remember { PermissionManager.getInstance(context) }
|
||||
|
||||
// Проверка всех разрешений
|
||||
val allPermissionsGranted = permissions.all {
|
||||
permissionManager.isPermissionGranted(it)
|
||||
}
|
||||
|
||||
// Запрашиваем разрешения, если они не предоставлены
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissionsMap ->
|
||||
// Сохраняем статусы разрешений
|
||||
permissionsMap.forEach { (permission, granted) ->
|
||||
permissionManager.savePermissionStatus(permission, granted)
|
||||
}
|
||||
|
||||
// Проверяем, все ли разрешения предоставлены
|
||||
val deniedPermissions = permissionsMap.filterValues { !it }.keys.toList()
|
||||
if (deniedPermissions.isEmpty()) {
|
||||
onAllPermissionsGranted()
|
||||
} else {
|
||||
onPermissionDenied(deniedPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
// Запрашиваем необходимые разрешения при первом запуске composable
|
||||
LaunchedEffect(permissions) {
|
||||
if (!allPermissionsGranted) {
|
||||
launcher.launch(permissions.toTypedArray())
|
||||
} else {
|
||||
onAllPermissionsGranted()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.example.womansafe.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
/**
|
||||
* Класс для управления безопасным хранением данных пользователя
|
||||
*/
|
||||
class PreferenceManager(context: Context) {
|
||||
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val sharedPreferences: SharedPreferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFERENCE_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
// Сохранение токена
|
||||
fun saveAuthToken(token: String?) {
|
||||
sharedPreferences.edit().putString(KEY_AUTH_TOKEN, token).apply()
|
||||
}
|
||||
|
||||
// Получение токена
|
||||
fun getAuthToken(): String? {
|
||||
return sharedPreferences.getString(KEY_AUTH_TOKEN, null)
|
||||
}
|
||||
|
||||
// Сохранение разрешений
|
||||
fun savePermissionGranted(permission: String, granted: Boolean) {
|
||||
sharedPreferences.edit().putBoolean(permission, granted).apply()
|
||||
}
|
||||
|
||||
// Получение статуса разрешения
|
||||
fun isPermissionGranted(permission: String): Boolean {
|
||||
return sharedPreferences.getBoolean(permission, false)
|
||||
}
|
||||
|
||||
// Очистка данных при выходе
|
||||
fun clearAuthData() {
|
||||
sharedPreferences.edit().remove(KEY_AUTH_TOKEN).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFERENCE_NAME = "woman_safe_prefs"
|
||||
private const val KEY_AUTH_TOKEN = "auth_token"
|
||||
|
||||
// Ключи для отслеживания разрешений
|
||||
const val PERMISSION_LOCATION = "permission_location"
|
||||
const val PERMISSION_CAMERA = "permission_camera"
|
||||
const val PERMISSION_CONTACTS = "permission_contacts"
|
||||
|
||||
// Синглтон для доступа к PreferenceManager
|
||||
@Volatile private var INSTANCE: PreferenceManager? = null
|
||||
|
||||
fun getInstance(context: Context): PreferenceManager {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: PreferenceManager(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.womansafe.util
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
||||
/**
|
||||
* Модификатор, который исправляет проблему с ACTION_HOVER_EXIT в Jetpack Compose
|
||||
*/
|
||||
fun Modifier.fixTouchEvents(): Modifier = composed {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
this.pointerInput(interactionSource) {
|
||||
awaitEachGesture {
|
||||
// Просто ждем первое нажатие и отпускание, чтобы гарантировать правильную очистку событий
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
waitForUpOrCancellation()
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/src/main/java/com/example/womansafe/utils/DateUtils.kt
Normal file
59
app/src/main/java/com/example/womansafe/utils/DateUtils.kt
Normal file
@@ -0,0 +1,59 @@
|
||||
package com.example.womansafe.utils
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
/**
|
||||
* Вспомогательный класс для работы с датами
|
||||
*/
|
||||
object DateUtils {
|
||||
|
||||
/**
|
||||
* Форматтер для отображения дат в удобном формате
|
||||
*/
|
||||
val READABLE_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
|
||||
/**
|
||||
* Форматтер для отображения месяца и года
|
||||
*/
|
||||
val MONTH_YEAR_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MMMM yyyy")
|
||||
|
||||
/**
|
||||
* Проверяет, находится ли дата в указанном диапазоне (включая начало и конец)
|
||||
*/
|
||||
fun isDateInRange(date: LocalDate, start: LocalDate?, end: LocalDate?): Boolean {
|
||||
if (start == null || end == null) return false
|
||||
return (date.isEqual(start) || date.isAfter(start)) &&
|
||||
(date.isEqual(end) || date.isBefore(end))
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет количество дней между двумя датами
|
||||
*/
|
||||
fun daysBetween(start: LocalDate, end: LocalDate): Long {
|
||||
return ChronoUnit.DAYS.between(start, end).absoluteValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли дата сегодняшней
|
||||
*/
|
||||
fun isToday(date: LocalDate): Boolean {
|
||||
return date.isEqual(LocalDate.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует дату в читаемый формат
|
||||
*/
|
||||
fun formatReadableDate(date: LocalDate?): String {
|
||||
return date?.format(READABLE_DATE_FORMATTER) ?: "Нет данных"
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует месяц и год
|
||||
*/
|
||||
fun formatMonthYear(date: LocalDate): String {
|
||||
return date.format(MONTH_YEAR_FORMATTER)
|
||||
}
|
||||
}
|
||||
9
app/src/main/res/xml/network_security_config.xml
Normal file
9
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">192.168.0.112</domain>
|
||||
<domain includeSubdomains="false">192.168.0.103</domain>
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -2,5 +2,4 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
}
|
||||
56
docs/CALENDAR_API_RESPONSE.json
Normal file
56
docs/CALENDAR_API_RESPONSE.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "uuid-1",
|
||||
"date": "2025-09-10",
|
||||
"type": "MENSTRUATION",
|
||||
"mood": "GOOD",
|
||||
"symptoms": ["CRAMPS", "FATIGUE"],
|
||||
"notes": "День начала цикла",
|
||||
"flow_intensity": 3,
|
||||
"is_predicted": false
|
||||
},
|
||||
{
|
||||
"id": "uuid-2",
|
||||
"date": "2025-09-11",
|
||||
"type": "MENSTRUATION",
|
||||
"mood": "NEUTRAL",
|
||||
"symptoms": ["CRAMPS"],
|
||||
"notes": "",
|
||||
"flow_intensity": 2,
|
||||
"is_predicted": false
|
||||
},
|
||||
{
|
||||
"id": "uuid-3",
|
||||
"date": "2025-09-12",
|
||||
"type": "MENSTRUATION",
|
||||
"mood": "GOOD",
|
||||
"symptoms": [],
|
||||
"notes": "",
|
||||
"flow_intensity": 1,
|
||||
"is_predicted": false
|
||||
},
|
||||
{
|
||||
"id": "uuid-4",
|
||||
"date": "2025-10-08",
|
||||
"type": "MENSTRUATION",
|
||||
"is_predicted": true
|
||||
},
|
||||
{
|
||||
"id": "uuid-5",
|
||||
"date": "2025-09-24",
|
||||
"type": "OVULATION",
|
||||
"is_predicted": true
|
||||
}
|
||||
],
|
||||
"cycle_info": {
|
||||
"average_cycle_length": 28,
|
||||
"average_period_length": 5,
|
||||
"last_period_start": "2025-09-10",
|
||||
"next_period_predicted": "2025-10-08",
|
||||
"next_ovulation_predicted": "2025-09-24",
|
||||
"fertile_window_start": "2025-09-21",
|
||||
"fertile_window_end": "2025-09-25"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
[versions]
|
||||
agp = "8.13.0"
|
||||
kotlin = "2.0.21"
|
||||
kotlin = "1.9.20"
|
||||
coreKtx = "1.10.1"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.1.5"
|
||||
espressoCore = "3.5.1"
|
||||
lifecycleRuntimeKtx = "2.6.1"
|
||||
activityCompose = "1.8.0"
|
||||
composeBom = "2024.09.00"
|
||||
composeBom = "2023.08.00"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -28,5 +28,3 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user