Compare commits

...

7 Commits

77 changed files with 12856 additions and 54 deletions

26
.idea/appInsightsSettings.xml generated Normal file
View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

6
.idea/copilot.data.migration.agent.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

6
.idea/copilot.data.migration.ask.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

6
.idea/copilot.data.migration.edit.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

18
.idea/deploymentTargetSelector.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<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>
</project>

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

1
.idea/gradle.xml generated
View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>

3
.idea/misc.xml generated
View File

@@ -1,4 +1,7 @@
<<<<<<< HEAD
=======
<?xml version="1.0" encoding="UTF-8"?>
>>>>>>> 198d43db29cb709eaba6466e4d4490c76c01bb2d
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

1
app/API desc Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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,32 +36,84 @@ 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)
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)
// Material Icons
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.compose.material:material-icons-extended")
// Keyboard options for text fields
implementation("androidx.compose.foundation:foundation")
// Security
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.6")
// ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
// 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")
// Location Services
implementation("com.google.android.gms:play-services-location:21.0.1")
implementation("com.google.android.gms:play-services-maps:18.2.0")
// 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")
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,196 @@
package com.example.womansafe.data.api
import com.example.womansafe.data.model.*
import retrofit2.Response
import retrofit2.http.*
interface WomanSafeApi {
// Authentication endpoints
@POST("api/v1/auth/login")
suspend fun login(@Body request: UserLogin): Response<Token>
@POST("api/v1/auth/register")
suspend fun register(@Body request: UserCreate): Response<UserResponse>
// User endpoints
@GET("api/v1/users/me")
suspend fun getCurrentUser(): Response<UserResponse>
@PUT("api/v1/users/me")
suspend fun updateCurrentUser(@Body request: ApiRequestBody): Response<UserResponse>
@PATCH("api/v1/users/me")
suspend fun patchCurrentUser(@Body request: ApiRequestBody): Response<UserResponse>
@POST("api/v1/users/me/change-password")
suspend fun changePassword(@Body request: ApiRequestBody): Response<Unit>
@GET("api/v1/users/dashboard")
suspend fun getDashboard(): Response<Any>
// Profile endpoints
@GET("api/v1/profile")
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(): Response<List<EmergencyContactResponse>>
@POST("api/v1/users/me/emergency-contacts")
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): Response<EmergencyContactResponse>
@PATCH("api/v1/users/me/emergency-contacts/{contact_id}")
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): Response<Unit>
// Emergency Reports endpoints
@GET("api/v1/emergency/reports")
suspend fun getEmergencyReports(): Response<Any>
@POST("api/v1/emergency/reports")
suspend fun createEmergencyReport(): Response<Any>
@GET("api/v1/emergency/reports/nearby")
suspend fun getNearbyEmergencyReports(): Response<Any>
@GET("api/v1/emergency/reports/{report_id}")
suspend fun getEmergencyReport(@Path("report_id") reportId: String): Response<Any>
@PATCH("api/v1/emergency/reports/{report_id}")
suspend fun updateEmergencyReport(@Path("report_id") reportId: String): Response<Any>
@DELETE("api/v1/emergency/reports/{report_id}")
suspend fun deleteEmergencyReport(@Path("report_id") reportId: String): Response<Any>
// Emergency Alerts endpoints
@GET("api/v1/emergency/alerts")
suspend fun getEmergencyAlerts(): Response<List<EmergencyAlertResponse>>
@POST("api/v1/emergency/alerts")
suspend fun createEmergencyAlert(@Body request: EmergencyAlertCreate): Response<EmergencyAlertResponse>
@GET("api/v1/emergency/alerts/my")
suspend fun getMyEmergencyAlerts(): Response<List<EmergencyAlertResponse>>
@GET("api/v1/emergency/alerts/nearby")
suspend fun getNearbyEmergencyAlerts(): Response<Any>
@GET("api/v1/emergency/alerts/{alert_id}")
suspend fun getEmergencyAlert(@Path("alert_id") alertId: String): Response<Any>
@PATCH("api/v1/emergency/alerts/{alert_id}")
suspend fun updateEmergencyAlert(@Path("alert_id") alertId: String): Response<Any>
@DELETE("api/v1/emergency/alerts/{alert_id}")
suspend fun deleteEmergencyAlert(@Path("alert_id") alertId: String): Response<Any>
@PATCH("api/v1/emergency/alerts/{alert_id}/cancel")
suspend fun cancelEmergencyAlert(@Path("alert_id") alertId: String): Response<Any>
// Location endpoints
@POST("api/v1/locations/update")
suspend fun updateLocation(): Response<Any>
@GET("api/v1/locations/last")
suspend fun getLastLocation(): Response<Any>
@GET("api/v1/locations/history")
suspend fun getLocationHistory(): Response<Any>
@GET("api/v1/locations/users/nearby")
suspend fun getNearbyUsers(): Response<Any>
@GET("api/v1/locations/safe-places")
suspend fun getSafePlaces(): Response<Any>
@POST("api/v1/locations/safe-places")
suspend fun createSafePlace(): Response<Any>
@GET("api/v1/locations/safe-places/{place_id}")
suspend fun getSafePlace(@Path("place_id") placeId: String): Response<Any>
@PATCH("api/v1/locations/safe-places/{place_id}")
suspend fun updateSafePlace(@Path("place_id") placeId: String): Response<Any>
@DELETE("api/v1/locations/safe-places/{place_id}")
suspend fun deleteSafePlace(@Path("place_id") placeId: String): Response<Any>
// Календарь и отслеживание цикла
@GET("api/v1/calendar/cycle-data")
suspend fun getCycleData(): Response<CycleData>
@GET("api/v1/calendar/entries")
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(@Body entry: CalendarEntryRequest): Response<CalendarEvent>
@PUT("api/v1/calendar/entries/{entry_id}")
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<Unit>
@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 getHealthInsights(): Response<List<HealthInsight>>
@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")
suspend fun getNotificationDevices(): Response<Any>
@POST("api/v1/notifications/devices")
suspend fun createNotificationDevice(): Response<Any>
@GET("api/v1/notifications/devices/{device_id}")
suspend fun getNotificationDevice(@Path("device_id") deviceId: String): Response<Any>
@DELETE("api/v1/notifications/devices/{device_id}")
suspend fun deleteNotificationDevice(@Path("device_id") deviceId: String): Response<Any>
@GET("api/v1/notifications/preferences")
suspend fun getNotificationPreferences(): Response<Any>
@POST("api/v1/notifications/preferences")
suspend fun updateNotificationPreferences(): Response<Any>
@POST("api/v1/notifications/test")
suspend fun testNotification(): Response<Any>
@GET("api/v1/notifications/history")
suspend fun getNotificationHistory(): Response<Any>
// Health check endpoints
@GET("api/v1/health")
suspend fun getHealth(): Response<Any>
@GET("api/v1/services-status")
suspend fun getServicesStatus(): Response<Any>
@GET("/")
suspend fun getRoot(): Response<Any>
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,353 @@
package com.example.womansafe.data.model
import com.example.womansafe.data.model.calendar.CalendarEntry
import com.google.gson.annotations.SerializedName
// 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 Token(
@SerializedName("access_token")
val accessToken: String,
@SerializedName("token_type")
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,
val phone_number: String,
val relationship: String? = null,
val notes: String? = null
)
data class EmergencyContactUpdate(
val name: String? = null,
val phone_number: String? = null,
val relationship: String? = null,
val notes: String? = null
)
data class EmergencyContactResponse(
val id: Int,
val uuid: String,
val name: String,
val phone_number: String,
val relationship: String? = null,
val notes: String? = null,
val user_id: Int
)
// 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
)
// 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<Any>,
val msg: String,
val type: String
)
data class HTTPValidationError(
val detail: List<ValidationError>
)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
package com.example.womansafe.data.model
import java.util.Date
// Типы экстренных событий
enum class EmergencyType {
HARASSMENT, // Домогательства
ASSAULT, // Нападение
STALKING, // Преследование
DOMESTIC_VIOLENCE, // Домашнее насилие
UNSAFE_AREA, // Небезопасная зона
MEDICAL, // Медицинская помощь
OTHER // Другое
}
// Статус экстренного события
enum class EmergencyStatus {
ACTIVE, // Активное
RESOLVED, // Решено
FALSE_ALARM // Ложная тревога
}
// Локальная модель экстренного события
data class EmergencyAlert(
val id: Int? = null,
val uuid: String? = null,
val type: EmergencyType,
val description: String? = null,
val latitude: Double,
val longitude: Double,
val address: String? = null,
val isAnonymous: Boolean = false,
val status: EmergencyStatus = EmergencyStatus.ACTIVE,
val createdAt: Date? = null,
val updatedAt: Date? = null,
val isActive: Boolean = true
)
// Местоположение пользователя
data class UserLocation(
val latitude: Double,
val longitude: Double,
val accuracy: Float? = null,
val address: String? = null,
val timestamp: Date = Date()
)
// Emergency models for UI layer
// Модель экстренного события для отображения в списке
data class EmergencyAlertItem(
val id: Int,
val type: EmergencyType,
val description: String?,
val address: String?,
val status: EmergencyStatus,
val createdAt: Date
)
// Модель экстренного события для детального просмотра
data class EmergencyAlertDetail(
val id: Int,
val uuid: String,
val type: EmergencyType,
val description: String?,
val latitude: Double,
val longitude: Double,
val address: String?,
val isAnonymous: Boolean,
val status: EmergencyStatus,
val createdAt: Date,
val updatedAt: Date,
val isActive: Boolean
)

View File

@@ -0,0 +1,27 @@
data class UserResponse(
val id: Int,
val uuid: String?,
val username: String,
val email: String,
val phone: String?,
val firstName: String?,
val lastName: String?,
val dateOfBirth: String?,
val avatarUrl: String?,
val bio: String?,
val emergencyContact1Name: String?,
val emergencyContact1Phone: String?,
val emergencyContact2Name: String?,
val emergencyContact2Phone: String?,
val locationSharingEnabled: Boolean?,
val emergencyNotificationsEnabled: Boolean?,
val pushNotificationsEnabled: Boolean?,
val emailNotificationsEnabled: Boolean?,
val emailVerified: Boolean?,
val phoneVerified: Boolean?,
val isBlocked: Boolean?,
val isActive: Boolean?,
val createdAt: String?,
val updatedAt: String?
)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
package com.example.womansafe.data.model.calendar
/**
* Уровень уверенности в прогнозе или инсайте
*/
enum class ConfidenceLevel {
LOW, // Низкая уверенность
MEDIUM, // Средняя уверенность
HIGH // Высокая уверенность
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
package com.example.womansafe.data.model.calendar
/**
* Типы записей в календаре женского здоровья
*/
enum class EntryType {
PERIOD, // Менструация
OVULATION, // Овуляция
SYMPTOMS, // Симптомы
MEDICATION, // Лекарства
NOTE, // Заметка
APPOINTMENT // Приём у врача
}

View File

@@ -0,0 +1,12 @@
package com.example.womansafe.data.model.calendar
/**
* Интенсивность менструального кровотечения
*/
enum class FlowIntensity {
SPOTTING, // Мажущие выделения
LIGHT, // Легкие
MEDIUM, // Средние
HEAVY, // Сильные
VERY_HEAVY // Очень сильные
}

View File

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

View File

@@ -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 // Рекомендации по физическим упражнениям
}

View File

@@ -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 // Усталое
}

View File

@@ -0,0 +1,14 @@
package com.example.womansafe.data.model.calendar
/**
* Состояние кожи в менструальном календаре
*/
enum class SkinCondition {
NORMAL, // Нормальное состояние кожи
IRRITATED, // Раздраженная кожа
SENSITIVE, // Чувствительная кожа
DRY, // Сухая кожа
OILY, // Жирная кожа
ACNE, // Высыпания/акне
REDNESS // Покраснения
}

View File

@@ -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 // Мажущие выделения
}

View File

@@ -0,0 +1,120 @@
package com.example.womansafe.data.network
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
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object NetworkClient {
private var BASE_URL = "http://192.168.0.112:8000/"
private var authToken: String? = null
private lateinit var preferenceManager: PreferenceManager
// Метод для получения экземпляра клиента 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 requestBuilder = chain.request().newBuilder()
authToken?.let { token ->
requestBuilder.addHeader("Authorization", "Bearer $token")
}
// 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 {
level = HttpLoggingInterceptor.Level.BODY
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
val apiService: WomanSafeApi by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.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)
}
}

View File

@@ -0,0 +1,31 @@
package com.example.womansafe.data.network
import com.example.womansafe.data.api.WomanSafeApi
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object RetrofitClient {
private const val BASE_URL = "http://192.168.0.112:8000/"
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val api: WomanSafeApi = retrofit.create(WomanSafeApi::class.java)
}

View File

@@ -0,0 +1,293 @@
package com.example.womansafe.data.repository
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 apiService = NetworkClient.apiService
// Auth methods
suspend fun login(email: String?, username: String?, password: String): Response<Token> {
val request = UserLogin(email, username, password)
return apiService.login(request)
}
suspend fun register(
email: String,
username: String? = null,
password: String,
fullName: String? = null,
phoneNumber: String? = null,
firstName: String? = null,
lastName: String? = null,
dateOfBirth: String? = null,
bio: String? = null
): Response<UserResponse> {
val request = UserCreate(
email = email,
username = username,
password = password,
full_name = fullName,
phone_number = phoneNumber,
first_name = firstName,
last_name = lastName,
date_of_birth = dateOfBirth,
bio = bio
)
return apiService.register(request)
}
// User methods
suspend fun getCurrentUser(): Response<UserResponse> {
return apiService.getCurrentUser()
}
suspend fun updateCurrentUser(userUpdate: UserUpdate): Response<UserResponse> {
val body = ApiRequestBody(user_update = userUpdate)
return apiService.updateCurrentUser(body)
}
suspend fun patchCurrentUser(userUpdate: UserUpdate): Response<UserResponse> {
val body = ApiRequestBody(user_update = userUpdate)
return apiService.patchCurrentUser(body)
}
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 apiService.getDashboard()
}
suspend fun getUserProfile(): Response<UserResponse> {
return apiService.getProfile()
}
suspend fun updateUserProfile(userUpdate: UserUpdate): Response<UserResponse> {
val body = ApiRequestBody(user_update = userUpdate)
return apiService.updateProfile(body)
}
// Emergency Contact methods
suspend fun getEmergencyContacts(): Response<List<EmergencyContactResponse>> {
return apiService.getEmergencyContacts()
}
suspend fun createEmergencyContact(contact: EmergencyContactCreate): Response<EmergencyContactResponse> {
return apiService.createEmergencyContact(contact)
}
suspend fun getEmergencyContact(contactId: Int): Response<EmergencyContactResponse> {
return apiService.getEmergencyContact(contactId.toString())
}
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: Int): Response<Unit> {
return apiService.deleteEmergencyContact(contactId.toString())
}
// Emergency methods - возвращают Any согласно WomanSafeApi
suspend fun getEmergencyReports(): Response<Any> {
return apiService.getEmergencyReports()
}
suspend fun createEmergencyReport(): Response<Any> {
return apiService.createEmergencyReport()
}
suspend fun getNearbyEmergencyReports(): Response<Any> {
return apiService.getNearbyEmergencyReports()
}
suspend fun getEmergencyReport(reportId: Int): Response<Any> {
return apiService.getEmergencyReport(reportId.toString())
}
suspend fun updateEmergencyReport(reportId: Int): Response<Any> {
return apiService.updateEmergencyReport(reportId.toString())
}
suspend fun deleteEmergencyReport(reportId: Int): Response<Any> {
return apiService.deleteEmergencyReport(reportId.toString())
}
suspend fun getEmergencyAlerts(): Response<List<EmergencyAlertResponse>> {
return apiService.getEmergencyAlerts()
}
suspend fun createEmergencyAlert(request: EmergencyAlertCreate): Response<EmergencyAlertResponse> {
return apiService.createEmergencyAlert(request)
}
suspend fun getMyEmergencyAlerts(): Response<List<EmergencyAlertResponse>> {
return apiService.getMyEmergencyAlerts()
}
suspend fun getNearbyEmergencyAlerts(): Response<Any> {
return apiService.getNearbyEmergencyAlerts()
}
suspend fun getEmergencyAlert(alertId: Int): Response<Any> {
return apiService.getEmergencyAlert(alertId.toString())
}
suspend fun updateEmergencyAlert(alertId: Int): Response<Any> {
return apiService.updateEmergencyAlert(alertId.toString())
}
suspend fun cancelEmergencyAlert(alertId: Int): Response<Any> {
return apiService.cancelEmergencyAlert(alertId.toString())
}
suspend fun deleteEmergencyAlert(alertId: Int): Response<Any> {
return apiService.deleteEmergencyAlert(alertId.toString())
}
// Location methods
suspend fun updateLocation(): Response<Any> {
return apiService.updateLocation()
}
suspend fun getLastLocation(): Response<Any> {
return apiService.getLastLocation()
}
suspend fun getLocationHistory(): Response<Any> {
return apiService.getLocationHistory()
}
suspend fun getNearbyUsers(): Response<Any> {
return apiService.getNearbyUsers()
}
suspend fun getSafePlaces(): Response<Any> {
return apiService.getSafePlaces()
}
suspend fun createSafePlace(): Response<Any> {
return apiService.createSafePlace()
}
suspend fun getSafePlace(placeId: Int): Response<Any> {
return apiService.getSafePlace(placeId.toString())
}
suspend fun updateSafePlace(placeId: Int): Response<Any> {
return apiService.updateSafePlace(placeId.toString())
}
suspend fun deleteSafePlace(placeId: Int): Response<Any> {
return apiService.deleteSafePlace(placeId.toString())
}
// Calendar methods
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(entry: CalendarEntry): Response<CalendarEntry> {
// В WomanSafeApi нет метода createCalendarEntry, нужно использовать другой API
// Здесь должна быть интеграция с CalendarApi
throw NotImplementedError("Method createCalendarEntry not implemented in WomanSafeApi")
}
suspend fun updateCalendarEntry(id: String, entry: CalendarEntry): Response<CalendarEntry> {
// В WomanSafeApi нет метода updateCalendarEntry, нужно использовать другой API
// Здесь должна быть интеграция с CalendarApi
throw NotImplementedError("Method updateCalendarEntry not implemented in WomanSafeApi")
}
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 apiService.getHealth() // Временная заглушка
}
suspend fun getCalendarInsights(): Response<Any> {
return apiService.getHealth() // Временная заглушка
}
suspend fun getCalendarReminders(): Response<Any> {
return apiService.getHealth() // Временная заглушка
}
suspend fun createCalendarReminder(): Response<Any> {
return apiService.getHealth() // Временная заглушка
}
suspend fun getCalendarSettings(): Response<Any> {
return apiService.getHealth() // Временная заглушка
}
suspend fun updateCalendarSettings(): Response<Any> {
return apiService.getHealth() // Временная заглушка
}
// Notification methods
suspend fun getNotificationDevices(): Response<Any> {
return apiService.getNotificationDevices()
}
suspend fun registerNotificationDevice(): Response<Any> {
return apiService.createNotificationDevice()
}
suspend fun getNotificationDevice(deviceId: String): Response<Any> {
return apiService.getNotificationDevice(deviceId)
}
suspend fun unregisterNotificationDevice(deviceId: String): Response<Any> {
return apiService.deleteNotificationDevice(deviceId)
}
suspend fun getNotificationPreferences(): Response<Any> {
return apiService.getNotificationPreferences()
}
suspend fun updateNotificationPreferences(): Response<Any> {
return apiService.updateNotificationPreferences()
}
suspend fun sendTestNotification(): Response<Any> {
return apiService.testNotification()
}
suspend fun getNotificationHistory(): Response<Any> {
return apiService.getNotificationHistory()
}
// Health and status methods
suspend fun getHealth(): Response<Any> {
return apiService.getHealth()
}
suspend fun getServicesStatus(): Response<Any> {
return apiService.getServicesStatus()
}
suspend fun getRoot(): Response<Any> {
return apiService.getRoot()
}
}

View File

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

View File

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

View File

@@ -0,0 +1,582 @@
package com.example.womansafe.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
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.FontFamily
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.unit.dp
import androidx.compose.ui.unit.sp
import com.example.womansafe.data.model.EmergencyContactResponse
import com.example.womansafe.data.model.UserResponse
import com.example.womansafe.ui.viewmodel.ApiTestViewModel
@Composable
fun AuthTab(
email: String,
onEmailChange: (String) -> Unit,
username: String,
onUsernameChange: (String) -> Unit,
password: String,
onPasswordChange: (String) -> Unit,
fullName: String,
onFullNameChange: (String) -> Unit,
phoneNumber: String,
onPhoneNumberChange: (String) -> Unit,
viewModel: ApiTestViewModel,
isLoading: Boolean
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "🔐 Аутентификация",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = email,
onValueChange = onEmailChange,
label = { Text("Email") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = onUsernameChange,
label = { Text("Username (опционально)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { Text("Пароль") },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = {
viewModel.login(
email = if (email.isNotBlank()) email else null,
username = if (username.isNotBlank()) username else null,
password = password
)
},
modifier = Modifier.weight(1f),
enabled = !isLoading && password.isNotBlank()
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
} else {
Text("Войти")
}
}
OutlinedButton(
onClick = { viewModel.clearAuth() },
modifier = Modifier.weight(1f)
) {
Text("Выйти")
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Регистрация",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = fullName,
onValueChange = onFullNameChange,
label = { Text("Полное имя") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = phoneNumber,
onValueChange = onPhoneNumberChange,
label = { Text("Номер телефона") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone)
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
viewModel.register(
email = email,
username = if (username.isNotBlank()) username else null,
password = password,
fullName = if (fullName.isNotBlank()) fullName else null,
phoneNumber = if (phoneNumber.isNotBlank()) phoneNumber else null
)
},
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading && email.isNotBlank() && password.isNotBlank()
) {
Text("Зарегистрироваться")
}
}
}
}
}
@Composable
fun UserTab(
viewModel: ApiTestViewModel,
currentUser: UserResponse?,
isLoading: Boolean
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "👤 Профиль пользователя",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.getCurrentUser() },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
) {
Text("Получить профиль")
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.getDashboard() },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
) {
Text("Получить дашборд")
}
if (currentUser != null) {
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Информация о пользователе:",
fontWeight = FontWeight.Bold
)
Text("ID: ${currentUser.id}")
Text("UUID: ${currentUser.uuid}")
Text("Email: ${currentUser.email}")
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 ?: "Не указан"})")
}
}
}
}
}
}
}
}
@Composable
fun ContactsTab(
contactName: String,
onContactNameChange: (String) -> Unit,
contactPhone: String,
onContactPhoneChange: (String) -> Unit,
contactRelationship: String,
onContactRelationshipChange: (String) -> Unit,
contactNotes: String,
onContactNotesChange: (String) -> Unit,
viewModel: ApiTestViewModel,
contacts: List<EmergencyContactResponse>,
isLoading: Boolean
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "🚨 Экстренные контакты",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.getEmergencyContacts() },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
) {
Text("Получить список контактов")
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Добавить новый контакт:",
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = contactName,
onValueChange = onContactNameChange,
label = { Text("Имя контакта") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = contactPhone,
onValueChange = onContactPhoneChange,
label = { Text("Номер телефона") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = contactRelationship,
onValueChange = onContactRelationshipChange,
label = { Text("Отношение (опционально)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = contactNotes,
onValueChange = onContactNotesChange,
label = { Text("Заметки (опционально)") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
viewModel.createEmergencyContact(
name = contactName,
phoneNumber = contactPhone,
relationship = if (contactRelationship.isNotBlank()) contactRelationship else null,
notes = if (contactNotes.isNotBlank()) contactNotes else null
)
},
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading && contactName.isNotBlank() && contactPhone.isNotBlank()
) {
Text("Создать контакт")
}
if (contacts.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Существующие контакты:",
fontWeight = FontWeight.Bold
)
contacts.forEach { contact ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier.padding(12.dp)
) {
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)
}
}
}
}
}
}
}
}
@Composable
fun ApiTestsTab(
viewModel: ApiTestViewModel,
isLoading: Boolean
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "🧪 API Тесты",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
val endpoints = listOf(
"Health Check" to "/api/v1/health",
"Services Status" to "/api/v1/services-status",
"Root" to "/",
"Emergency Reports" to "/api/v1/emergency/reports",
"Emergency Alerts" to "/api/v1/emergency/alerts",
"Last Location" to "/api/v1/locations/last",
"Location History" to "/api/v1/locations/history",
"Calendar Entries" to "/api/v1/calendar/entries",
"Notification Preferences" to "/api/v1/notifications/preferences"
)
endpoints.forEach { (name, endpoint) ->
Button(
onClick = { viewModel.testGenericEndpoint(endpoint, "GET") },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
enabled = !isLoading
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(name)
Text(
text = endpoint,
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
}
}
}
}
@Composable
fun SettingsTab(
baseUrl: String,
onBaseUrlChange: (String) -> Unit,
viewModel: ApiTestViewModel
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "⚙️ Настройки",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = baseUrl,
onValueChange = onBaseUrlChange,
label = { Text("Base URL API") },
modifier = Modifier.fillMaxWidth(),
supportingText = {
Text("Для эмулятора: http://10.0.2.2:8000/\nДля реального устройства: http://YOUR_IP:8000/")
}
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Примеры URL:",
fontWeight = FontWeight.Bold
)
val exampleUrls = listOf(
"Эмулятор" to "http://10.0.2.2:8000/",
"Localhost" to "http://127.0.0.1:8000/",
"Удаленный сервер" to "https://api.womansafe.com/"
)
exampleUrls.forEach { (name, url) ->
OutlinedButton(
onClick = { onBaseUrlChange(url) },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(name)
Text(url, fontSize = 12.sp)
}
}
}
}
}
}
}
@Composable
fun ResultsSection(
endpoint: String,
response: String,
error: String,
onClear: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (error.isNotEmpty())
MaterialTheme.colorScheme.errorContainer
else
MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📡 Результат: $endpoint",
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
TextButton(onClick = onClear) {
Text("Очистить")
}
}
Spacer(modifier = Modifier.height(8.dp))
if (error.isNotEmpty()) {
Text(
text = "❌ Ошибка:",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
Text(
text = error,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error
)
}
if (response.isNotEmpty()) {
Text(
text = "✅ Ответ:",
fontWeight = FontWeight.Bold,
color = if (error.isEmpty()) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurface
)
Text(
text = response,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}

View File

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

View File

@@ -0,0 +1,61 @@
package com.example.womansafe.ui.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
sealed class BottomNavItem(val route: String, val icon: ImageVector, val title: String) {
object Home : BottomNavItem("home", Icons.Filled.Home, "Главная")
object Emergency : BottomNavItem("emergency", Icons.Filled.Warning, "Тревога")
object Calendar : BottomNavItem("calendar", Icons.Filled.DateRange, "Календарь")
object Profile : BottomNavItem("profile", Icons.Filled.Person, "Профиль")
}
@Composable
fun BottomNavigationBar(navController: NavController) {
val items = listOf(
BottomNavItem.Home,
BottomNavItem.Emergency,
BottomNavItem.Calendar,
BottomNavItem.Profile
)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
NavigationBar {
items.forEach { item ->
NavigationBarItem(
icon = {
Icon(
imageVector = item.icon,
contentDescription = item.title
)
},
label = { Text(item.title) },
selected = currentRoute == item.route,
onClick = {
navController.navigate(item.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
}
}

View File

@@ -0,0 +1,159 @@
package com.example.womansafe.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.womansafe.ui.viewmodel.ApiTestViewModel
import com.example.womansafe.ui.components.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ApiTestScreen(
modifier: Modifier = Modifier,
viewModel: ApiTestViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
var email by remember { mutableStateOf("user@example.com") }
var username by remember { mutableStateOf("user123") }
var password by remember { mutableStateOf("Password123!") }
var fullName by remember { mutableStateOf("John Doe") }
var phoneNumber by remember { mutableStateOf("+7123456789") }
var baseUrl by remember { mutableStateOf(state.baseUrl) }
var contactName by remember { mutableStateOf("Emergency Contact") }
var contactPhone by remember { mutableStateOf("+7987654321") }
var contactRelationship by remember { mutableStateOf("Friend") }
var contactNotes by remember { mutableStateOf("Test contact") }
var selectedTab by remember { mutableStateOf(0) }
val tabs = listOf("Аутентификация", "Пользователь", "Контакты", "API Тесты", "Настройки")
LaunchedEffect(baseUrl) {
viewModel.updateBaseUrl(baseUrl)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// Header
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Women's Safety API Tester",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
if (state.isAuthenticated) {
Text(
text = "✅ Авторизован",
color = Color(0xFF4CAF50),
fontSize = 14.sp
)
} else {
Text(
text = "Не авторизован",
color = Color(0xFFF44336),
fontSize = 14.sp
)
}
Text(
text = "API: $baseUrl",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Tabs
ScrollableTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Content based on selected tab
when (selectedTab) {
0 -> AuthTab(
email = email,
onEmailChange = { email = it },
username = username,
onUsernameChange = { username = it },
password = password,
onPasswordChange = { password = it },
fullName = fullName,
onFullNameChange = { fullName = it },
phoneNumber = phoneNumber,
onPhoneNumberChange = { phoneNumber = it },
viewModel = viewModel,
isLoading = state.isLoading
)
1 -> UserTab(
viewModel = viewModel,
currentUser = state.currentUser,
isLoading = state.isLoading
)
2 -> ContactsTab(
contactName = contactName,
onContactNameChange = { contactName = it },
contactPhone = contactPhone,
onContactPhoneChange = { contactPhone = it },
contactRelationship = contactRelationship,
onContactRelationshipChange = { contactRelationship = it },
contactNotes = contactNotes,
onContactNotesChange = { contactNotes = it },
viewModel = viewModel,
contacts = state.emergencyContacts,
isLoading = state.isLoading
)
3 -> ApiTestsTab(
viewModel = viewModel,
isLoading = state.isLoading
)
4 -> SettingsTab(
baseUrl = baseUrl,
onBaseUrlChange = { baseUrl = it },
viewModel = viewModel
)
}
Spacer(modifier = Modifier.height(16.dp))
// Results section
if (state.selectedEndpoint.isNotEmpty()) {
ResultsSection(
endpoint = state.selectedEndpoint,
response = state.lastApiResponse,
error = state.lastApiError,
onClear = { viewModel.clearResults() }
)
}
}
}

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

View File

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

View File

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

View File

@@ -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 = "Скрыть")
}
}
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,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")
}
)
}

View File

@@ -0,0 +1,403 @@
package com.example.womansafe.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.womansafe.ui.viewmodel.AuthViewModel
@Composable
fun ProfileScreen(
authViewModel: AuthViewModel,
onNavigateToContacts: (() -> Unit)? = null,
onNavigateToSettings: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
val uiState = authViewModel.uiState
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Spacer(modifier = Modifier.height(16.dp))
// Профиль пользователя
UserProfileCard(
userName = uiState.user?.username ?: "Пользователь",
userEmail = uiState.user?.email ?: "",
userPhone = uiState.user?.phone,
onEditProfile = { onNavigateToSettings?.invoke() }
)
}
item {
// Настройки безопасности
SecuritySettingsSection()
}
item {
// Экстренные контакты
EmergencyContactsSection(
contactsCount = uiState.emergencyContacts?.size ?: 0,
onManageContacts = { onNavigateToContacts?.invoke() }
)
}
item {
// Приватность и уведомления
PrivacyAndNotificationsSection(uiState)
}
item {
// Дополнительные настройки
AdditionalSettingsSection()
}
item {
// Кнопка выхода
LogoutButton(onLogout = { authViewModel.logout() })
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@Composable
private fun UserProfileCard(
userName: String,
userEmail: String,
userPhone: String?,
onEditProfile: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Аватар пользователя
Surface(
modifier = Modifier.size(80.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer
) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = "Аватар",
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = userName,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
if (userEmail.isNotEmpty()) {
Text(
text = userEmail,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
userPhone?.let { phone ->
Text(
text = phone,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = onEditProfile
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = "Редактировать",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Редактировать профиль")
}
}
}
}
@Composable
private fun SecuritySettingsSection() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = "Настройки безопасности",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
SettingItem(
icon = Icons.Filled.LocationOn,
title = "Отслеживание местоположения",
subtitle = "Разрешить приложению отслеживать ваше местоположение",
hasSwitch = true,
switchState = true,
onSwitchChange = { /* TODO */ }
)
SettingItem(
icon = Icons.Filled.Lock,
title = "Сменить пароль",
subtitle = "Обновить пароль для входа в приложение",
onClick = { /* TODO */ }
)
SettingItem(
icon = Icons.Filled.Lock,
title = "Двухфакторная аутентификация",
subtitle = "Дополнительная защита вашего аккаунта",
onClick = { /* TODO */ }
)
}
}
}
@Composable
private fun EmergencyContactsSection(contactsCount: Int, onManageContacts: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Экстренные контакты",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = "Настроено: $contactsCount",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onManageContacts) {
Icon(
imageVector = Icons.Filled.ArrowForward,
contentDescription = "Управление контактами"
)
}
}
}
}
}
@Composable
private fun PrivacyAndNotificationsSection(uiState: Any) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = "Приватность и уведомления",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
SettingItem(
icon = Icons.Filled.Notifications,
title = "Push-уведомления",
subtitle = "Получать уведомления о важных событиях",
hasSwitch = true,
switchState = true,
onSwitchChange = { /* TODO */ }
)
SettingItem(
icon = Icons.Filled.Email,
title = "Email-уведомления",
subtitle = "Получать уведомления на электронную почту",
hasSwitch = true,
switchState = false,
onSwitchChange = { /* TODO */ }
)
SettingItem(
icon = Icons.Filled.Lock,
title = "Приватность данных",
subtitle = "Управление видимостью личной информации",
onClick = { /* TODO */ }
)
}
}
}
@Composable
private fun AdditionalSettingsSection() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = "Дополнительно",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
SettingItem(
icon = Icons.Filled.Info,
title = "Справка и поддержка",
subtitle = "Получить помощь по использованию приложения",
onClick = { /* TODO */ }
)
SettingItem(
icon = Icons.Filled.Info,
title = "О приложении",
subtitle = "Информация о версии и разработчиках",
onClick = { /* TODO */ }
)
SettingItem(
icon = Icons.Filled.Info,
title = "Политика конфиденциальности",
subtitle = "Ознакомиться с правилами обработки данных",
onClick = { /* TODO */ }
)
}
}
}
@Composable
private fun SettingItem(
icon: ImageVector,
title: String,
subtitle: String,
hasSwitch: Boolean = false,
switchState: Boolean = false,
onSwitchChange: ((Boolean) -> Unit)? = null,
onClick: (() -> Unit)? = null
) {
val itemModifier = if (onClick != null && !hasSwitch) {
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
} else {
Modifier.fillMaxWidth()
}
Row(
modifier = itemModifier.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = title,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (hasSwitch && onSwitchChange != null) {
Switch(
checked = switchState,
onCheckedChange = onSwitchChange
)
} else if (onClick != null) {
IconButton(onClick = onClick) {
Icon(
imageVector = Icons.Filled.ArrowForward,
contentDescription = "Открыть"
)
}
}
}
}
@Composable
private fun LogoutButton(onLogout: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
TextButton(
onClick = onLogout,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Icon(
imageVector = Icons.Filled.ExitToApp,
contentDescription = "Выйти",
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Выйти из аккаунта",
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Medium
)
}
}
}

View File

@@ -0,0 +1,509 @@
package com.example.womansafe.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.example.womansafe.data.model.UserUpdate
import com.example.womansafe.ui.viewmodel.ProfileSettingsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileSettingsScreen(
profileSettingsViewModel: ProfileSettingsViewModel,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier
) {
val uiState = profileSettingsViewModel.uiState
var showPasswordDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
profileSettingsViewModel.loadProfile()
}
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Заголовок
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Назад"
)
}
Text(
text = "Настройки профиля",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
// Основная информация
PersonalInfoSection(
uiState = uiState,
onUpdate = { userUpdate -> profileSettingsViewModel.updateProfile(userUpdate) }
)
Spacer(modifier = Modifier.height(24.dp))
// Настройки безопасности
SecuritySection(
onChangePassword = { showPasswordDialog = true },
locationEnabled = uiState.locationSharingEnabled,
onLocationToggle = { enabled ->
profileSettingsViewModel.updateLocationSharing(enabled)
}
)
Spacer(modifier = Modifier.height(24.dp))
// Настройки уведомлений
NotificationSection(
pushEnabled = uiState.pushNotificationsEnabled,
emailEnabled = uiState.emailNotificationsEnabled,
emergencyEnabled = uiState.emergencyNotificationsEnabled,
onPushToggle = { enabled ->
profileSettingsViewModel.updateNotificationSettings(push = enabled)
},
onEmailToggle = { enabled ->
profileSettingsViewModel.updateNotificationSettings(email = enabled)
},
onEmergencyToggle = { enabled ->
profileSettingsViewModel.updateNotificationSettings(emergency = enabled)
}
)
}
}
// Диалог смены пароля
if (showPasswordDialog) {
ChangePasswordDialog(
onDismiss = { showPasswordDialog = false },
onConfirm = { currentPassword, newPassword ->
profileSettingsViewModel.changePassword(currentPassword, newPassword)
showPasswordDialog = false
}
)
}
// Обработка ошибок
uiState.error?.let { error ->
LaunchedEffect(error) {
// Показать snackbar с ошибкой
}
}
}
@Composable
private fun PersonalInfoSection(
uiState: com.example.womansafe.ui.viewmodel.ProfileSettingsUiState,
onUpdate: (UserUpdate) -> Unit
) {
var firstName by remember { mutableStateOf(uiState.firstName ?: "") }
var lastName by remember { mutableStateOf(uiState.lastName ?: "") }
var phone by remember { mutableStateOf(uiState.phone ?: "") }
var bio by remember { mutableStateOf(uiState.bio ?: "") }
var isEditing by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Личная информация",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
TextButton(
onClick = {
if (isEditing) {
// Сохранить изменения
onUpdate(UserUpdate(
first_name = firstName.takeIf { it.isNotBlank() },
last_name = lastName.takeIf { it.isNotBlank() },
phone = phone.takeIf { it.isNotBlank() },
bio = bio.takeIf { it.isNotBlank() }
))
}
isEditing = !isEditing
}
) {
Text(if (isEditing) "Сохранить" else "Редактировать")
}
}
Spacer(modifier = Modifier.height(16.dp))
if (isEditing) {
OutlinedTextField(
value = firstName,
onValueChange = { firstName = it },
label = { Text("Имя") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = lastName,
onValueChange = { lastName = it },
label = { Text("Фамилия") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = phone,
onValueChange = { phone = it },
label = { Text("Телефон") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = bio,
onValueChange = { bio = it },
label = { Text("О себе") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3
)
} else {
// Отображение информации
InfoRow("Имя", firstName.ifBlank { "Не указано" })
InfoRow("Фамилия", lastName.ifBlank { "Не указано" })
InfoRow("Телефон", phone.ifBlank { "Не указан" })
InfoRow("Email", uiState.email ?: "Не указан")
if (bio.isNotBlank()) {
InfoRow("О себе", bio)
}
}
}
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp)
)
Spacer(modifier = Modifier.height(12.dp))
}
}
@Composable
private fun SecuritySection(
onChangePassword: () -> Unit,
locationEnabled: Boolean,
onLocationToggle: (Boolean) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = "Безопасность",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
// Смена пароля
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Сменить пароль",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Text(
text = "Обновить пароль для входа в приложение",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
TextButton(onClick = onChangePassword) {
Text("Изменить")
}
}
Divider(modifier = Modifier.padding(vertical = 8.dp))
// Отслеживание местоположения
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Отслеживание местоположения",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Text(
text = "Разрешить приложению отслеживать ваше местоположение",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = locationEnabled,
onCheckedChange = onLocationToggle
)
}
}
}
}
@Composable
private fun NotificationSection(
pushEnabled: Boolean,
emailEnabled: Boolean,
emergencyEnabled: Boolean,
onPushToggle: (Boolean) -> Unit,
onEmailToggle: (Boolean) -> Unit,
onEmergencyToggle: (Boolean) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = "Уведомления",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
// Push-уведомления
NotificationToggle(
title = "Push-уведомления",
subtitle = "Получать уведомления о важных событиях",
enabled = pushEnabled,
onToggle = onPushToggle
)
Divider(modifier = Modifier.padding(vertical = 8.dp))
// Email-уведомления
NotificationToggle(
title = "Email-уведомления",
subtitle = "Получать уведомления на электронную почту",
enabled = emailEnabled,
onToggle = onEmailToggle
)
Divider(modifier = Modifier.padding(vertical = 8.dp))
// Экстренные уведомления
NotificationToggle(
title = "Экстренные уведомления",
subtitle = "Уведомления о чрезвычайных ситуациях",
enabled = emergencyEnabled,
onToggle = onEmergencyToggle
)
}
}
}
@Composable
private fun NotificationToggle(
title: String,
subtitle: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = enabled,
onCheckedChange = onToggle
)
}
}
@Composable
private fun ChangePasswordDialog(
onDismiss: () -> Unit,
onConfirm: (String, String) -> Unit
) {
var currentPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var showCurrentPassword by remember { mutableStateOf(false) }
var showNewPassword by remember { mutableStateOf(false) }
var showConfirmPassword by remember { mutableStateOf(false) }
val isValid = currentPassword.isNotBlank() &&
newPassword.isNotBlank() &&
confirmPassword == newPassword &&
newPassword.length >= 8
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Сменить пароль") },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
value = currentPassword,
onValueChange = { currentPassword = it },
label = { Text("Текущий пароль") },
visualTransformation = if (showCurrentPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { showCurrentPassword = !showCurrentPassword }) {
Icon(
imageVector = if (showCurrentPassword) Icons.Filled.Lock else Icons.Filled.Info,
contentDescription = if (showCurrentPassword) "Скрыть пароль" else "Показать пароль"
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = { Text("Новый пароль") },
visualTransformation = if (showNewPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { showNewPassword = !showNewPassword }) {
Icon(
imageVector = if (showNewPassword) Icons.Filled.Lock else Icons.Filled.Info,
contentDescription = if (showNewPassword) "Скрыть пароль" else "Показать пароль"
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = newPassword.isNotEmpty() && newPassword.length < 8,
supportingText = if (newPassword.isNotEmpty() && newPassword.length < 8) {
{ Text("Пароль должен содержать минимум 8 символов") }
} else null
)
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = { Text("Подтвердите пароль") },
visualTransformation = if (showConfirmPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { showConfirmPassword = !showConfirmPassword }) {
Icon(
imageVector = if (showConfirmPassword) Icons.Filled.Lock else Icons.Filled.Info,
contentDescription = if (showConfirmPassword) "Скрыть пароль" else "Показать пароль"
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = confirmPassword.isNotEmpty() && confirmPassword != newPassword,
supportingText = if (confirmPassword.isNotEmpty() && confirmPassword != newPassword) {
{ Text("Пароли не совпадают") }
} else null
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(currentPassword, newPassword) },
enabled = isValid
) {
Text("Изменить")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Отмена")
}
}
)
}

View File

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

View File

@@ -0,0 +1,716 @@
package com.example.womansafe.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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,
val currentUser: UserResponse? = null,
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://192.168.0.103:8000/"
)
class ApiTestViewModel : ViewModel() {
private val repository = ApiRepository()
private val gson = Gson()
private val _state = MutableStateFlow(ApiTestState())
val state: StateFlow<ApiTestState> = _state.asStateFlow()
fun updateBaseUrl(url: String) {
_state.value = _state.value.copy(baseUrl = url)
}
fun login(email: String?, username: String?, password: String) {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "POST /api/v1/auth/login",
lastApiError = "",
lastApiResponse = ""
)
try {
val response = repository.login(email, username, password)
if (response.isSuccessful) {
val token = response.body()
token?.let {
NetworkClient.setAuthToken(it.accessToken)
_state.value = _state.value.copy(
authToken = it.accessToken,
isAuthenticated = true,
lastApiResponse = "Login successful! Token: ${it.accessToken.take(20)}...",
isLoading = false
)
// Запрос профиля сразу после авторизации
getCurrentUser()
}
} 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 register(email: String, username: String?, password: String, fullName: String?, phoneNumber: String?) {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "POST /api/v1/auth/register",
lastApiError = "",
lastApiResponse = ""
)
try {
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 = 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 getCurrentUser() {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "GET /api/v1/users/me",
lastApiError = "",
lastApiResponse = ""
)
try {
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 = 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 getDashboard() {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "GET /api/v1/users/dashboard",
lastApiError = "",
lastApiResponse = ""
)
try {
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 = dashboardJson,
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 getEmergencyContacts() {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "GET /api/v1/users/me/emergency-contacts",
lastApiError = "",
lastApiResponse = ""
)
try {
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 = contactsJson,
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 createEmergencyContact(name: String, phoneNumber: String, relationship: String?, notes: String?) {
viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true,
selectedEndpoint = "POST /api/v1/users/me/emergency-contacts",
lastApiError = "",
lastApiResponse = ""
)
try {
val contact = EmergencyContactCreate(name, phoneNumber, relationship, notes)
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 = contactJson,
isLoading = false
)
// Refresh the contacts list
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 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(
isLoading = true,
selectedEndpoint = "$method $endpoint",
lastApiError = "",
lastApiResponse = ""
)
try {
val response = when (endpoint.lowercase()) {
"/api/v1/health" -> repository.getHealth()
"/api/v1/services-status" -> repository.getServicesStatus()
"/" -> repository.getRoot()
"/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",
isLoading = false
)
return@launch
}
}
if (response.isSuccessful) {
val body = response.body()
val bodyJson = withContext(Dispatchers.Default) { gson.toJson(body) }
_state.value = _state.value.copy(
lastApiResponse = bodyJson,
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 clearAuth() {
NetworkClient.setAuthToken(null)
_state.value = _state.value.copy(
authToken = null,
isAuthenticated = false,
currentUser = null
)
}
fun clearResults() {
_state.value = _state.value.copy(
lastApiResponse = "",
lastApiError = "",
selectedEndpoint = ""
)
}
}

View File

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

View File

@@ -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 // Обычный день
}

View File

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

View File

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

View File

@@ -0,0 +1,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)
}
}

View File

@@ -0,0 +1,159 @@
package com.example.womansafe.ui.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.womansafe.data.model.UserUpdate
import com.example.womansafe.data.repository.ApiRepository
import kotlinx.coroutines.launch
data class ProfileSettingsUiState(
val isLoading: Boolean = false,
val username: String = "",
val email: String = "",
val firstName: String = "",
val lastName: String = "",
val phone: String = "",
val bio: String = "",
val locationSharingEnabled: Boolean = false,
val pushNotificationsEnabled: Boolean = true,
val emailNotificationsEnabled: Boolean = false,
val emergencyNotificationsEnabled: Boolean = true,
val error: String? = null
)
class ProfileSettingsViewModel : ViewModel() {
private val repository = ApiRepository()
var uiState by mutableStateOf(ProfileSettingsUiState())
private set
fun loadProfile() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, error = null)
try {
val response = repository.getCurrentUser()
if (response.isSuccessful) {
val user = response.body()
user?.let {
uiState = uiState.copy(
username = it.username ?: "",
email = it.email,
firstName = it.first_name ?: "",
lastName = it.last_name ?: "",
phone = it.phone ?: "",
bio = it.bio ?: "",
locationSharingEnabled = it.location_sharing_enabled,
pushNotificationsEnabled = it.push_notifications_enabled,
emailNotificationsEnabled = it.email_notifications_enabled ?: false,
emergencyNotificationsEnabled = it.emergency_notifications_enabled,
isLoading = false
)
}
} else {
uiState = uiState.copy(
isLoading = false,
error = "Ошибка загрузки профиля: ${response.code()}"
)
}
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
error = "Ошибка сети: ${e.message}"
)
}
}
}
fun updateProfile(userUpdate: UserUpdate) {
viewModelScope.launch {
try {
val response = repository.updateCurrentUser(userUpdate)
if (response.isSuccessful) {
loadProfile() // Перезагружаем профиль
} else {
uiState = uiState.copy(
error = "Ошибка обновления профиля: ${response.code()}"
)
}
} catch (e: Exception) {
uiState = uiState.copy(
error = "Ошибка обновления профиля: ${e.message}"
)
}
}
}
fun changePassword(currentPassword: String, newPassword: String) {
viewModelScope.launch {
try {
val response = repository.changePassword(currentPassword, newPassword)
if (response.isSuccessful) {
// Успешно изменен пароль
} else {
uiState = uiState.copy(
error = "Ошибка смены пароля: ${response.code()}"
)
}
} catch (e: Exception) {
uiState = uiState.copy(
error = "Ошибка смены пароля: ${e.message}"
)
}
}
}
fun updateLocationSharing(enabled: Boolean) {
viewModelScope.launch {
try {
val userUpdate = UserUpdate(location_sharing_enabled = enabled)
val response = repository.updateCurrentUser(userUpdate)
if (response.isSuccessful) {
uiState = uiState.copy(locationSharingEnabled = enabled)
} else {
uiState = uiState.copy(
error = "Ошибка обновления настроек: ${response.code()}"
)
}
} catch (e: Exception) {
uiState = uiState.copy(
error = "Ошибка обновления настроек: ${e.message}"
)
}
}
}
fun updateNotificationSettings(
push: Boolean? = null,
email: Boolean? = null,
emergency: Boolean? = null
) {
viewModelScope.launch {
try {
val userUpdate = UserUpdate(
push_notifications_enabled = push,
email_notifications_enabled = email,
emergency_notifications_enabled = emergency
)
val response = repository.updateCurrentUser(userUpdate)
if (response.isSuccessful) {
uiState = uiState.copy(
pushNotificationsEnabled = push ?: uiState.pushNotificationsEnabled,
emailNotificationsEnabled = email ?: uiState.emailNotificationsEnabled,
emergencyNotificationsEnabled = emergency ?: uiState.emergencyNotificationsEnabled
)
} else {
uiState = uiState.copy(
error = "Ошибка обновления уведомлений: ${response.code()}"
)
}
} catch (e: Exception) {
uiState = uiState.copy(
error = "Ошибка обновления уведомлений: ${e.message}"
)
}
}
}
}

View File

@@ -0,0 +1,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
}
}

View File

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

View File

@@ -0,0 +1,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()
}
}
}

View File

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

View File

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

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

View File

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

View File

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

View 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"
}
}

View File

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