UI refactor
This commit is contained in:
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@@ -4,10 +4,10 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2025-09-25T20:36:01.018251800Z">
|
<DropdownSelection timestamp="2025-10-07T06:51:31.183962394Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="LocalEmulator" identifier="path=/home/trevor/.android/avd/Medium_Phone.avd" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=LGMG600S9b4da66b" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
id("com.google.devtools.ksp") version "1.9.20-1.0.14"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -19,6 +19,11 @@ android {
|
|||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Включаем десугаринг для поддержки Java 8 API на старых устройствах
|
||||||
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -41,7 +46,7 @@ android {
|
|||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion = "1.5.1"
|
kotlinCompilerExtensionVersion = "1.5.4"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
@@ -60,6 +65,16 @@ dependencies {
|
|||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
|
|
||||||
|
// 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
|
// Navigation
|
||||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||||
|
|
||||||
@@ -78,6 +93,18 @@ dependencies {
|
|||||||
// Permissions
|
// Permissions
|
||||||
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
|
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
|
// Testing
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
@@ -86,4 +113,7 @@ dependencies {
|
|||||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import androidx.activity.viewModels
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.example.womansafe.data.network.NetworkClient
|
||||||
import com.example.womansafe.ui.screens.AuthScreen
|
import com.example.womansafe.ui.screens.AuthScreen
|
||||||
import com.example.womansafe.ui.screens.MainScreen
|
import com.example.womansafe.ui.screens.MainScreen
|
||||||
import com.example.womansafe.ui.theme.WomanSafeTheme
|
import com.example.womansafe.ui.theme.WomanSafeTheme
|
||||||
@@ -19,6 +21,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Инициализируем NetworkClient для работы с сохраненным токеном
|
||||||
|
NetworkClient.initialize(applicationContext)
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
WomanSafeTheme {
|
WomanSafeTheme {
|
||||||
@@ -26,6 +32,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
|
// Проверяем сохраненный токен и пытаемся выполнить автоматический вход
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
NetworkClient.getAuthToken()?.let { token ->
|
||||||
|
// Если токен существует, пытаемся выполнить автоматический вход
|
||||||
|
authViewModel.autoLogin(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Показываем либо экран авторизации, либо главный экран
|
// Показываем либо экран авторизации, либо главный экран
|
||||||
if (authViewModel.uiState.isLoggedIn) {
|
if (authViewModel.uiState.isLoggedIn) {
|
||||||
MainScreen(authViewModel = authViewModel)
|
MainScreen(authViewModel = authViewModel)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.example.womansafe.data.api
|
||||||
|
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleData
|
||||||
|
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API интерфейс для взаимодействия с серверной частью календаря
|
||||||
|
*/
|
||||||
|
interface CalendarApi {
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/cycle-data")
|
||||||
|
suspend fun getCycleData(): Response<CycleData>
|
||||||
|
|
||||||
|
@POST("api/v1/calendar/cycle-data")
|
||||||
|
suspend fun updateCycleData(@Body cycleData: CycleData): Response<CycleData>
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/entries")
|
||||||
|
suspend fun getCalendarEntries(
|
||||||
|
@Query("start_date") startDate: String? = null,
|
||||||
|
@Query("end_date") endDate: String? = null
|
||||||
|
): Response<List<CalendarEntry>>
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/entries/{entry_id}")
|
||||||
|
suspend fun getCalendarEntry(
|
||||||
|
@Path("entry_id") entryId: Long
|
||||||
|
): Response<CalendarEntry>
|
||||||
|
|
||||||
|
@POST("api/v1/calendar/entries")
|
||||||
|
suspend fun addCalendarEntry(
|
||||||
|
@Body entry: CalendarEntry
|
||||||
|
): Response<CalendarEntry>
|
||||||
|
|
||||||
|
@PUT("api/v1/calendar/entries/{entry_id}")
|
||||||
|
suspend fun updateCalendarEntry(
|
||||||
|
@Path("entry_id") entryId: Long,
|
||||||
|
@Body entry: CalendarEntry
|
||||||
|
): Response<CalendarEntry>
|
||||||
|
|
||||||
|
@DELETE("api/v1/calendar/entries/{entry_id}")
|
||||||
|
suspend fun deleteCalendarEntry(
|
||||||
|
@Path("entry_id") entryId: Long
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/insights")
|
||||||
|
suspend fun getHealthInsights(): Response<List<HealthInsight>>
|
||||||
|
|
||||||
|
@POST("api/v1/calendar/insights/{insight_id}/dismiss")
|
||||||
|
suspend fun dismissInsight(
|
||||||
|
@Path("insight_id") insightId: Long
|
||||||
|
): Response<Unit>
|
||||||
|
}
|
||||||
@@ -123,39 +123,41 @@ interface WomanSafeApi {
|
|||||||
@DELETE("api/v1/locations/safe-places/{place_id}")
|
@DELETE("api/v1/locations/safe-places/{place_id}")
|
||||||
suspend fun deleteSafePlace(@Path("place_id") placeId: String): Response<Any>
|
suspend fun deleteSafePlace(@Path("place_id") placeId: String): Response<Any>
|
||||||
|
|
||||||
// Calendar endpoints
|
// Календарь и отслеживание цикла
|
||||||
|
@GET("api/v1/calendar/cycle-data")
|
||||||
|
suspend fun getCycleData(): Response<CycleData>
|
||||||
|
|
||||||
@GET("api/v1/calendar/entries")
|
@GET("api/v1/calendar/entries")
|
||||||
suspend fun getCalendarEntries(): Response<CalendarEntriesResponse>
|
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/entry")
|
@POST("api/v1/calendar/entries")
|
||||||
suspend fun createCalendarEntry(@Body entry: CalendarEntryCreate): Response<CalendarEvent>
|
suspend fun createCalendarEntry(@Body entry: CalendarEntryRequest): Response<CalendarEvent>
|
||||||
|
|
||||||
@GET("api/v1/calendar/entries/{entry_id}")
|
|
||||||
suspend fun getCalendarEntry(@Path("entry_id") entryId: String): Response<CalendarEvent>
|
|
||||||
|
|
||||||
@PUT("api/v1/calendar/entries/{entry_id}")
|
@PUT("api/v1/calendar/entries/{entry_id}")
|
||||||
suspend fun updateCalendarEntry(@Path("entry_id") entryId: String, @Body entry: CalendarEntryUpdate): Response<CalendarEvent>
|
suspend fun updateCalendarEntry(
|
||||||
|
@Path("entry_id") entryId: String,
|
||||||
|
@Body entry: CalendarEntryRequest
|
||||||
|
): Response<CalendarEvent>
|
||||||
|
|
||||||
@DELETE("api/v1/calendar/entries/{entry_id}")
|
@DELETE("api/v1/calendar/entries/{entry_id}")
|
||||||
suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response<Any>
|
suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response<Unit>
|
||||||
|
|
||||||
@GET("api/v1/calendar/cycle-overview")
|
@GET("api/v1/calendar/statistics")
|
||||||
suspend fun getCycleOverview(): Response<Any>
|
suspend fun getCycleStatistics(): Response<CycleStatistics>
|
||||||
|
|
||||||
|
@GET("api/v1/calendar/predictions")
|
||||||
|
suspend fun getCyclePredictions(): Response<CyclePrediction>
|
||||||
|
|
||||||
@GET("api/v1/calendar/insights")
|
@GET("api/v1/calendar/insights")
|
||||||
suspend fun getCalendarInsights(): Response<Any>
|
suspend fun getHealthInsights(): Response<List<HealthInsight>>
|
||||||
|
|
||||||
@GET("api/v1/calendar/reminders")
|
@PATCH("api/v1/calendar/insights/{insight_id}/dismiss")
|
||||||
suspend fun getCalendarReminders(): Response<Any>
|
suspend fun dismissInsight(@Path("insight_id") insightId: String): Response<HealthInsight>
|
||||||
|
|
||||||
@POST("api/v1/calendar/reminders")
|
|
||||||
suspend fun createCalendarReminder(): Response<Any>
|
|
||||||
|
|
||||||
@GET("api/v1/calendar/settings")
|
|
||||||
suspend fun getCalendarSettings(): Response<Any>
|
|
||||||
|
|
||||||
@PUT("api/v1/calendar/settings")
|
|
||||||
suspend fun updateCalendarSettings(): Response<Any>
|
|
||||||
|
|
||||||
// Notification endpoints
|
// Notification endpoints
|
||||||
@GET("api/v1/notifications/devices")
|
@GET("api/v1/notifications/devices")
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.example.womansafe.data.local
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleData
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO для работы с данными менструального календаря
|
||||||
|
*/
|
||||||
|
@Dao
|
||||||
|
interface CalendarDao {
|
||||||
|
// CycleData operations
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertCycleData(cycleData: CycleData)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_data WHERE userId = :userId")
|
||||||
|
suspend fun getCycleDataByUserId(userId: String): CycleData?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_data WHERE userId = :userId")
|
||||||
|
fun getCycleDataFlowByUserId(userId: String): Flow<CycleData?>
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteCycleData(cycleData: CycleData)
|
||||||
|
|
||||||
|
// CalendarEntry operations
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertCalendarEntry(entry: CalendarEntry): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertCalendarEntries(entries: List<CalendarEntry>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateCalendarEntry(entry: CalendarEntry)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calendar_entries WHERE userId = :userId ORDER BY entryDate DESC")
|
||||||
|
fun getAllCalendarEntriesFlow(userId: String): Flow<List<CalendarEntry>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calendar_entries WHERE userId = :userId AND entryDate = :date")
|
||||||
|
suspend fun getCalendarEntryByDate(userId: String, date: LocalDate): CalendarEntry?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calendar_entries WHERE userId = :userId AND entryDate BETWEEN :startDate AND :endDate ORDER BY entryDate")
|
||||||
|
suspend fun getCalendarEntriesBetweenDates(userId: String, startDate: LocalDate, endDate: LocalDate): List<CalendarEntry>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calendar_entries WHERE userId = :userId AND entryDate BETWEEN :startDate AND :endDate ORDER BY entryDate")
|
||||||
|
fun getCalendarEntriesBetweenDatesFlow(userId: String, startDate: LocalDate, endDate: LocalDate): Flow<List<CalendarEntry>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calendar_entries WHERE id = :entryId")
|
||||||
|
suspend fun getCalendarEntryById(entryId: Long): CalendarEntry?
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteCalendarEntry(entry: CalendarEntry)
|
||||||
|
|
||||||
|
@Query("DELETE FROM calendar_entries WHERE userId = :userId AND entryDate = :date")
|
||||||
|
suspend fun deleteCalendarEntryByDate(userId: String, date: LocalDate)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM calendar_entries ORDER BY entryDate DESC")
|
||||||
|
suspend fun getAllCalendarEntries(): List<CalendarEntry>
|
||||||
|
|
||||||
|
// HealthInsight operations
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertHealthInsight(insight: HealthInsight): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertHealthInsights(insights: List<HealthInsight>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateHealthInsight(insight: HealthInsight)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM health_insights WHERE userId = :userId AND isDismissed = 0 ORDER BY createdAt DESC")
|
||||||
|
fun getActiveHealthInsightsFlow(userId: String): Flow<List<HealthInsight>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM health_insights WHERE userId = :userId ORDER BY createdAt DESC")
|
||||||
|
fun getAllHealthInsightsFlow(userId: String): Flow<List<HealthInsight>>
|
||||||
|
|
||||||
|
@Query("UPDATE health_insights SET isDismissed = 1 WHERE id = :insightId")
|
||||||
|
suspend fun dismissHealthInsight(insightId: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM health_insights WHERE userId = :userId AND createdAt < :timestamp")
|
||||||
|
suspend fun deleteOldInsights(userId: String, timestamp: Long)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cycle_data ORDER BY lastUpdated DESC LIMIT 1")
|
||||||
|
suspend fun getLatestCycleData(): CycleData?
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.example.womansafe.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleData
|
||||||
|
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||||
|
|
||||||
|
/**
|
||||||
|
* База данных Room для хранения всех данных менструального календаря
|
||||||
|
*/
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
CycleData::class,
|
||||||
|
CalendarEntry::class,
|
||||||
|
HealthInsight::class
|
||||||
|
],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
@TypeConverters(CalendarTypeConverters::class)
|
||||||
|
abstract class CalendarDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun calendarDao(): CalendarDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: CalendarDatabase? = null
|
||||||
|
|
||||||
|
fun getDatabase(context: Context): CalendarDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
CalendarDatabase::class.java,
|
||||||
|
"calendar_database"
|
||||||
|
)
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package com.example.womansafe.data.local
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import com.example.womansafe.data.model.calendar.*
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертеры типов для Room базы данных для работы с нестандартными типами
|
||||||
|
*/
|
||||||
|
class CalendarTypeConverters {
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromLocalDate(value: LocalDate?): String? {
|
||||||
|
return value?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toLocalDate(value: String?): LocalDate? {
|
||||||
|
return value?.let { LocalDate.parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromSymptomList(value: List<Symptom>?): String? {
|
||||||
|
return value?.let { gson.toJson(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toSymptomList(value: String?): List<Symptom>? {
|
||||||
|
if (value == null) return null
|
||||||
|
val listType = object : TypeToken<List<Symptom>>() {}.type
|
||||||
|
return gson.fromJson(value, listType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromStringList(value: List<String>?): String? {
|
||||||
|
return value?.let { gson.toJson(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toStringList(value: String?): List<String>? {
|
||||||
|
if (value == null) return null
|
||||||
|
val listType = object : TypeToken<List<String>>() {}.type
|
||||||
|
return gson.fromJson(value, listType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromEntryType(value: EntryType): String {
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toEntryType(value: String): EntryType {
|
||||||
|
return try {
|
||||||
|
EntryType.valueOf(value)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
EntryType.NOTE // Дефолтное значение
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromFlowIntensity(value: FlowIntensity?): String? {
|
||||||
|
return value?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toFlowIntensity(value: String?): FlowIntensity? {
|
||||||
|
if (value == null) return null
|
||||||
|
return try {
|
||||||
|
FlowIntensity.valueOf(value)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromMood(value: Mood?): String? {
|
||||||
|
return value?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toMood(value: String?): Mood? {
|
||||||
|
if (value == null) return null
|
||||||
|
return try {
|
||||||
|
Mood.valueOf(value)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromInsightType(value: InsightType): String {
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toInsightType(value: String): InsightType {
|
||||||
|
return InsightType.valueOf(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromConfidenceLevel(value: ConfidenceLevel): String {
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toConfidenceLevel(value: String): ConfidenceLevel {
|
||||||
|
return ConfidenceLevel.valueOf(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.example.womansafe.data.model
|
package com.example.womansafe.data.model
|
||||||
|
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
// Request body wrapper for API Gateway proxy endpoints
|
// Request body wrapper for API Gateway proxy endpoints
|
||||||
@@ -249,17 +250,6 @@ data class NearbyUser(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Calendar models
|
// Calendar models
|
||||||
data class CalendarEntry(
|
|
||||||
val title: String,
|
|
||||||
val description: String? = null,
|
|
||||||
val start_date: String,
|
|
||||||
val end_date: String? = null,
|
|
||||||
val entry_type: String,
|
|
||||||
val mood: String? = null,
|
|
||||||
val symptoms: List<String>? = null,
|
|
||||||
val notes: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class LegacyCalendarEntryResponse( // Переименовано, чтобы избежать конфликта с CalendarModels.kt
|
data class LegacyCalendarEntryResponse( // Переименовано, чтобы избежать конфликта с CalendarModels.kt
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val title: String,
|
val title: String,
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ enum class SymptomType {
|
|||||||
NAUSEA // Тошнота
|
NAUSEA // Тошнота
|
||||||
}
|
}
|
||||||
|
|
||||||
// Событие календаря
|
// Событие календаря (доменная модель)
|
||||||
data class CalendarEvent(
|
data class CalendarEvent(
|
||||||
val id: String? = null, // Изменено с Int? на String?, так как в API используются UUID
|
val id: String? = null,
|
||||||
val date: LocalDate,
|
val date: LocalDate,
|
||||||
val type: CalendarEventType,
|
val type: CalendarEventType,
|
||||||
val isActual: Boolean = true, // true - фактическое, false - прогноз
|
val isActual: Boolean = true, // true - фактическое, false - прогноз
|
||||||
@@ -46,7 +46,24 @@ data class CalendarEvent(
|
|||||||
val flowIntensity: Int? = null, // Интенсивность выделений 1-5
|
val flowIntensity: Int? = null, // Интенсивность выделений 1-5
|
||||||
val createdAt: LocalDate = LocalDate.now(),
|
val createdAt: LocalDate = LocalDate.now(),
|
||||||
val updatedAt: LocalDate = LocalDate.now(),
|
val updatedAt: LocalDate = LocalDate.now(),
|
||||||
val isPredicted: Boolean = false // Добавлено поле для совместимости с API
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
// Настройки цикла
|
// Настройки цикла
|
||||||
@@ -61,26 +78,6 @@ data class CycleSettings(
|
|||||||
val reminderDaysBefore: Int = 3 // За сколько дней напоминать о приближающемся цикле
|
val reminderDaysBefore: Int = 3 // За сколько дней напоминать о приближающемся цикле
|
||||||
)
|
)
|
||||||
|
|
||||||
// Прогноз цикла
|
|
||||||
data class CyclePrediction(
|
|
||||||
val nextPeriodStart: LocalDate,
|
|
||||||
val nextPeriodEnd: LocalDate,
|
|
||||||
val nextOvulation: LocalDate,
|
|
||||||
val fertileWindowStart: LocalDate,
|
|
||||||
val fertileWindowEnd: LocalDate,
|
|
||||||
val confidence: Float = 0.85f // Добавлено значение достоверности прогноза в %
|
|
||||||
)
|
|
||||||
|
|
||||||
// Статистика цикла
|
|
||||||
data class CycleStatistics(
|
|
||||||
val averageCycleLength: Float = 0f,
|
|
||||||
val cycleVariation: Float = 0f,
|
|
||||||
val lastCycles: List<Int> = emptyList(),
|
|
||||||
val periodLengthAverage: Float = 0f,
|
|
||||||
val commonSymptoms: List<SymptomType> = emptyList(),
|
|
||||||
val moodPatterns: Map<MoodType, Float> = emptyMap()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Модели для API
|
// Модели для API
|
||||||
|
|
||||||
// Запрос на создание события в календаре
|
// Запрос на создание события в календаре
|
||||||
@@ -133,3 +130,86 @@ data class CalendarEntriesResponse(
|
|||||||
val entries: List<CalendarEntryResponse>,
|
val entries: List<CalendarEntryResponse>,
|
||||||
val cycle_info: CycleInfoResponse
|
val cycle_info: CycleInfoResponse
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Extension функции для преобразования API моделей в доменные
|
||||||
|
fun CalendarEventApiResponse.toDomainModel(): CalendarEvent {
|
||||||
|
println("=== Преобразование API модели в доменную ===")
|
||||||
|
println("API данные: entry_date=${this.entry_date}, entry_type=${this.entry_type}, symptoms=${this.symptoms}")
|
||||||
|
|
||||||
|
val calendarEvent = CalendarEvent(
|
||||||
|
id = this.uuid,
|
||||||
|
date = LocalDate.parse(this.entry_date),
|
||||||
|
type = mapApiTypeToCalendarEventType(this.entry_type),
|
||||||
|
isActual = !this.is_predicted,
|
||||||
|
mood = this.mood?.let { mapApiMoodToMoodType(it) },
|
||||||
|
symptoms = parseApiSymptoms(this.symptoms),
|
||||||
|
notes = this.notes ?: "",
|
||||||
|
flowIntensity = mapApiFlowIntensityToInt(this.flow_intensity),
|
||||||
|
isPredicted = this.is_predicted,
|
||||||
|
createdAt = this.created_at?.let {
|
||||||
|
try {
|
||||||
|
LocalDate.parse(it.substring(0, 10))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LocalDate.now()
|
||||||
|
}
|
||||||
|
} ?: LocalDate.now(),
|
||||||
|
updatedAt = this.updated_at?.let {
|
||||||
|
try {
|
||||||
|
LocalDate.parse(it.substring(0, 10))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LocalDate.now()
|
||||||
|
}
|
||||||
|
} ?: LocalDate.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
println("Доменная модель: id=${calendarEvent.id}, date=${calendarEvent.date}, type=${calendarEvent.type}")
|
||||||
|
println("=============================================")
|
||||||
|
|
||||||
|
return calendarEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательные функции для маппинга
|
||||||
|
private fun mapApiTypeToCalendarEventType(apiType: String): CalendarEventType {
|
||||||
|
return when (apiType.lowercase()) {
|
||||||
|
"period" -> CalendarEventType.MENSTRUATION
|
||||||
|
"ovulation" -> CalendarEventType.OVULATION
|
||||||
|
"fertile_window" -> CalendarEventType.FERTILE_WINDOW
|
||||||
|
"predicted_period" -> CalendarEventType.PREDICTED_MENSTRUATION
|
||||||
|
"predicted_ovulation" -> CalendarEventType.PREDICTED_OVULATION
|
||||||
|
else -> CalendarEventType.MENSTRUATION // по умолчанию
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapApiMoodToMoodType(apiMood: String): MoodType {
|
||||||
|
return when (apiMood.uppercase()) {
|
||||||
|
"EXCELLENT" -> MoodType.EXCELLENT
|
||||||
|
"GOOD" -> MoodType.GOOD
|
||||||
|
"NORMAL" -> MoodType.NORMAL
|
||||||
|
"BAD" -> MoodType.BAD
|
||||||
|
"TERRIBLE" -> MoodType.TERRIBLE
|
||||||
|
else -> MoodType.NORMAL // по умолчанию
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseApiSymptoms(symptomsString: String): List<SymptomType> {
|
||||||
|
if (symptomsString.isBlank()) return emptyList()
|
||||||
|
|
||||||
|
return symptomsString.split(",")
|
||||||
|
.map { it.trim() }
|
||||||
|
.mapNotNull { symptom ->
|
||||||
|
try {
|
||||||
|
SymptomType.valueOf(symptom.uppercase())
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
null // Игнорируем неизвестные симптомы
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapApiFlowIntensityToInt(apiIntensity: String?): Int? {
|
||||||
|
return when (apiIntensity?.lowercase()) {
|
||||||
|
"light" -> 2
|
||||||
|
"medium" -> 3
|
||||||
|
"heavy" -> 5
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.example.womansafe.data.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.example.womansafe.data.model.calendar.EntryType
|
||||||
|
import com.example.womansafe.data.model.calendar.FlowIntensity
|
||||||
|
import com.example.womansafe.data.model.calendar.Symptom
|
||||||
|
import com.example.womansafe.data.model.calendar.Mood
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель данных цикла
|
||||||
|
*/
|
||||||
|
data class CycleData(
|
||||||
|
val user_id: Int,
|
||||||
|
val cycle_start_date: LocalDate,
|
||||||
|
val cycle_length: Int,
|
||||||
|
val period_length: Int,
|
||||||
|
val ovulation_date: LocalDate,
|
||||||
|
val fertile_window_start: LocalDate,
|
||||||
|
val fertile_window_end: LocalDate,
|
||||||
|
val next_period_predicted: LocalDate,
|
||||||
|
val cycle_regularity_score: Int, // 1-100
|
||||||
|
val avg_cycle_length: Float,
|
||||||
|
val avg_period_length: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для аналитики здоровья и инсайтов
|
||||||
|
*/
|
||||||
|
data class HealthInsight(
|
||||||
|
val id: Int,
|
||||||
|
val user_id: Int,
|
||||||
|
val insight_type: InsightType,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val recommendation: String,
|
||||||
|
val confidence_level: ConfidenceLevel,
|
||||||
|
val data_points_used: Int,
|
||||||
|
val is_dismissed: Boolean = false,
|
||||||
|
val created_at: LocalDateTime? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типы записей в календаре
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интенсивность менструации
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Настроение
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Симптомы
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типы инсайтов
|
||||||
|
*/
|
||||||
|
enum class InsightType {
|
||||||
|
CYCLE_IRREGULARITY,
|
||||||
|
PERIOD_LENGTH_CHANGE,
|
||||||
|
SYMPTOM_PATTERN,
|
||||||
|
HEALTH_RECOMMENDATION,
|
||||||
|
OVULATION_PREDICTION,
|
||||||
|
LIFESTYLE_IMPACT
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уровни достоверности инсайтов
|
||||||
|
*/
|
||||||
|
enum class ConfidenceLevel {
|
||||||
|
LOW,
|
||||||
|
MEDIUM,
|
||||||
|
HIGH
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для запроса на создание записи в календаре
|
||||||
|
*/
|
||||||
|
data class CalendarEntryRequest(
|
||||||
|
val entry_date: String,
|
||||||
|
val entry_type: String,
|
||||||
|
val flow_intensity: String? = null,
|
||||||
|
val period_symptoms: List<String>? = null,
|
||||||
|
val mood: String? = null,
|
||||||
|
val energy_level: Int? = null,
|
||||||
|
val sleep_hours: Float? = null,
|
||||||
|
val symptoms: List<String>? = null,
|
||||||
|
val medications: List<String>? = null,
|
||||||
|
val notes: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для статистики цикла
|
||||||
|
*/
|
||||||
|
data class CycleStatistics(
|
||||||
|
val average_cycle_length: Float,
|
||||||
|
val cycle_length_variation: Float,
|
||||||
|
val average_period_length: Float,
|
||||||
|
val cycle_regularity: Int,
|
||||||
|
val cycle_history: List<CycleHistoryItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Элемент истории циклов для отображения графика
|
||||||
|
*/
|
||||||
|
data class CycleHistoryItem(
|
||||||
|
val start_date: LocalDate,
|
||||||
|
val end_date: LocalDate,
|
||||||
|
val cycle_length: Int,
|
||||||
|
val period_length: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель для прогноза цикла
|
||||||
|
*/
|
||||||
|
data class CyclePrediction(
|
||||||
|
val next_period_start: LocalDate,
|
||||||
|
val next_period_end: LocalDate,
|
||||||
|
val next_ovulation: LocalDate,
|
||||||
|
val fertile_window_start: LocalDate,
|
||||||
|
val fertile_window_end: LocalDate,
|
||||||
|
val confidence: Float = 0.85f
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@Entity(tableName = "calendar_entries")
|
||||||
|
data class CalendarEntry(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
val userId: String,
|
||||||
|
val entryDate: LocalDate,
|
||||||
|
val entryType: EntryType,
|
||||||
|
val flowIntensity: FlowIntensity? = null,
|
||||||
|
val periodSymptoms: List<Symptom>? = null,
|
||||||
|
val mood: Mood? = null,
|
||||||
|
val energyLevel: Int? = null, // 1-5
|
||||||
|
val sleepHours: Float? = null,
|
||||||
|
val symptoms: List<Symptom>? = null,
|
||||||
|
val medications: List<String>? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val isPredicted: Boolean = false,
|
||||||
|
val confidenceScore: Int? = null, // 1-100
|
||||||
|
val syncTimestamp: Long = System.currentTimeMillis(),
|
||||||
|
val entryId: Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запрос на создание/обновление записи календаря
|
||||||
|
*/
|
||||||
|
data class CalendarEntryRequest(
|
||||||
|
val entry_date: String,
|
||||||
|
val entry_type: String,
|
||||||
|
val flow_intensity: String? = null,
|
||||||
|
val mood: String? = null,
|
||||||
|
val symptoms: List<String>? = null,
|
||||||
|
val period_symptoms: List<String>? = null,
|
||||||
|
val medications: List<String>? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val energy_level: Int? = null,
|
||||||
|
val sleep_hours: Float? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель события календаря, используемая в улучшенном календарном интерфейсе
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "calendar_events")
|
||||||
|
data class CalendarEvent(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String,
|
||||||
|
val userId: String,
|
||||||
|
val date: LocalDate,
|
||||||
|
val type: String, // period, ovulation, symptoms, medication, note, appointment
|
||||||
|
val flowIntensity: String? = null,
|
||||||
|
val mood: String? = null,
|
||||||
|
val energyLevel: Int? = null,
|
||||||
|
val sleepHours: Float? = null,
|
||||||
|
val symptoms: List<String>? = null,
|
||||||
|
val medications: List<String>? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val isDismissed: Boolean = false,
|
||||||
|
val syncTimestamp: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уровень уверенности в прогнозе или инсайте
|
||||||
|
*/
|
||||||
|
enum class ConfidenceLevel {
|
||||||
|
LOW, // Низкая уверенность
|
||||||
|
MEDIUM, // Средняя уверенность
|
||||||
|
HIGH // Высокая уверенность
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные о текущем менструальном цикле
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "cycle_data")
|
||||||
|
data class CycleData(
|
||||||
|
@PrimaryKey
|
||||||
|
val userId: String,
|
||||||
|
val periodStart: LocalDate?,
|
||||||
|
val periodEnd: LocalDate?,
|
||||||
|
val ovulationDate: LocalDate?,
|
||||||
|
val fertileWindowStart: LocalDate?,
|
||||||
|
val fertileWindowEnd: LocalDate?,
|
||||||
|
val cycleLength: Int = 28,
|
||||||
|
val periodLength: Int = 5,
|
||||||
|
val nextPeriodPredicted: LocalDate? = null,
|
||||||
|
val lastUpdated: Long = System.currentTimeMillis(),
|
||||||
|
// Добавленные поля для совместимости с кодом
|
||||||
|
val lastPeriodStartDate: LocalDate? = periodStart,
|
||||||
|
val averageCycleLength: Int = cycleLength,
|
||||||
|
val averagePeriodLength: Int = periodLength,
|
||||||
|
val regularityScore: Int = 0, // 0-100
|
||||||
|
val cycleStartDate: LocalDate? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель прогноза менструального цикла
|
||||||
|
*/
|
||||||
|
data class CyclePrediction(
|
||||||
|
val userId: String,
|
||||||
|
val nextPeriodStart: LocalDate,
|
||||||
|
val nextPeriodEnd: LocalDate,
|
||||||
|
val nextOvulation: LocalDate,
|
||||||
|
val fertileWindowStart: LocalDate,
|
||||||
|
val fertileWindowEnd: LocalDate,
|
||||||
|
val confidenceScore: Int, // 1-100
|
||||||
|
val createdAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель статистики менструального цикла
|
||||||
|
*/
|
||||||
|
data class CycleStatistics(
|
||||||
|
val userId: String,
|
||||||
|
val averageCycleLength: Int,
|
||||||
|
val averagePeriodLength: Int,
|
||||||
|
val shortestCycle: Int,
|
||||||
|
val longestCycle: Int,
|
||||||
|
val cycleLengthVariation: Int,
|
||||||
|
val regularityScore: Int, // 1-100
|
||||||
|
val dataPointsCount: Int,
|
||||||
|
val lastUpdated: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типы записей в календаре женского здоровья
|
||||||
|
*/
|
||||||
|
enum class EntryType {
|
||||||
|
PERIOD, // Менструация
|
||||||
|
OVULATION, // Овуляция
|
||||||
|
SYMPTOMS, // Симптомы
|
||||||
|
MEDICATION, // Лекарства
|
||||||
|
NOTE, // Заметка
|
||||||
|
APPOINTMENT // Приём у врача
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интенсивность менструального кровотечения
|
||||||
|
*/
|
||||||
|
enum class FlowIntensity {
|
||||||
|
SPOTTING, // Мажущие выделения
|
||||||
|
LIGHT, // Легкие
|
||||||
|
MEDIUM, // Средние
|
||||||
|
HEAVY, // Сильные
|
||||||
|
VERY_HEAVY // Очень сильные
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель медицинских инсайтов на основе данных календаря
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "health_insights")
|
||||||
|
data class HealthInsight(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: Long,
|
||||||
|
val userId: String,
|
||||||
|
val insightType: InsightType,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val confidenceLevel: ConfidenceLevel,
|
||||||
|
val recommendation: String,
|
||||||
|
val dataPointsUsed: Int,
|
||||||
|
val isDismissed: Boolean = false,
|
||||||
|
val createdAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типы инсайтов о женском здоровье
|
||||||
|
*/
|
||||||
|
enum class InsightType {
|
||||||
|
CYCLE_REGULARITY, // Регулярность цикла
|
||||||
|
PERIOD_LENGTH, // Продолжительность менструации
|
||||||
|
SYMPTOM_PATTERN, // Закономерности в симптомах
|
||||||
|
LIFESTYLE_IMPACT, // Влияние образа жизни
|
||||||
|
MEDICATION_EFFECTIVENESS, // Эффективность лекарств
|
||||||
|
MOOD_CORRELATION, // Корреляции настроения
|
||||||
|
PERIOD_PREDICTION, // Прогноз менструации
|
||||||
|
HEALTH_TIP, // Совет по здоровью
|
||||||
|
MEDICATION_REMINDER, // Напоминание о приеме лекарств
|
||||||
|
EXERCISE_SUGGESTION // Рекомендации по физическим упражнениям
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расширенный набор настроений для дополненного календаря
|
||||||
|
*/
|
||||||
|
enum class Mood {
|
||||||
|
VERY_HAPPY, // Очень счастливое
|
||||||
|
HAPPY, // Счастливое
|
||||||
|
NEUTRAL, // Нейтральное
|
||||||
|
NORMAL, // Обычное (для совместимости)
|
||||||
|
SAD, // Грустное
|
||||||
|
VERY_SAD, // Очень грустное
|
||||||
|
ANXIOUS, // Тревожное
|
||||||
|
IRRITATED, // Раздраженное (для совместимости)
|
||||||
|
IRRITABLE, // Раздражительное
|
||||||
|
SENSITIVE, // Чувствительное
|
||||||
|
CALM, // Спокойное
|
||||||
|
ENERGETIC, // Энергичное
|
||||||
|
TIRED // Усталое
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние кожи в менструальном календаре
|
||||||
|
*/
|
||||||
|
enum class SkinCondition {
|
||||||
|
NORMAL, // Нормальное состояние кожи
|
||||||
|
IRRITATED, // Раздраженная кожа
|
||||||
|
SENSITIVE, // Чувствительная кожа
|
||||||
|
DRY, // Сухая кожа
|
||||||
|
OILY, // Жирная кожа
|
||||||
|
ACNE, // Высыпания/акне
|
||||||
|
REDNESS // Покраснения
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.womansafe.data.model.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расширенный набор симптомов для женского здоровья
|
||||||
|
*/
|
||||||
|
enum class Symptom {
|
||||||
|
CRAMPS, // Спазмы/боли
|
||||||
|
HEADACHE, // Головная боль
|
||||||
|
BACKACHE, // Боли в спине
|
||||||
|
NAUSEA, // Тошнота
|
||||||
|
FATIGUE, // Усталость
|
||||||
|
BLOATING, // Вздутие
|
||||||
|
BREAST_TENDERNESS, // Чувствительность груди
|
||||||
|
ACNE, // Высыпания
|
||||||
|
MOOD_SWINGS, // Перепады настроения
|
||||||
|
CRAVINGS, // Тяга к еде
|
||||||
|
INSOMNIA, // Бессонница
|
||||||
|
DIZZINESS, // Головокружение
|
||||||
|
CONSTIPATION, // Запоры
|
||||||
|
DIARRHEA, // Диарея
|
||||||
|
HOT_FLASHES, // Приливы
|
||||||
|
SPOTTING // Мажущие выделения
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.example.womansafe.data.network
|
package com.example.womansafe.data.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import com.example.womansafe.data.api.WomanSafeApi
|
import com.example.womansafe.data.api.WomanSafeApi
|
||||||
|
import com.example.womansafe.util.PreferenceManager
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
@@ -11,6 +13,30 @@ import java.util.concurrent.TimeUnit
|
|||||||
object NetworkClient {
|
object NetworkClient {
|
||||||
private var BASE_URL = "http://192.168.0.112:8000/"
|
private var BASE_URL = "http://192.168.0.112:8000/"
|
||||||
private var authToken: String? = null
|
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 ->
|
private val authInterceptor = Interceptor { chain ->
|
||||||
val requestBuilder = chain.request().newBuilder()
|
val requestBuilder = chain.request().newBuilder()
|
||||||
@@ -23,7 +49,14 @@ object NetworkClient {
|
|||||||
println("=== API Request Debug ===")
|
println("=== API Request Debug ===")
|
||||||
println("URL: ${request.url}")
|
println("URL: ${request.url}")
|
||||||
println("Method: ${request.method}")
|
println("Method: ${request.method}")
|
||||||
println("Headers: ${request.headers}")
|
print("Headers: ")
|
||||||
|
request.headers.forEach { (name, value) ->
|
||||||
|
if (name.equals("Authorization", ignoreCase = true)) {
|
||||||
|
println("$name: ██")
|
||||||
|
} else {
|
||||||
|
println("$name: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val response = chain.proceed(request)
|
val response = chain.proceed(request)
|
||||||
println("Response Code: ${response.code}")
|
println("Response Code: ${response.code}")
|
||||||
@@ -56,9 +89,32 @@ object NetworkClient {
|
|||||||
|
|
||||||
fun setAuthToken(token: String?) {
|
fun setAuthToken(token: String?) {
|
||||||
authToken = token
|
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) {
|
fun updateBaseUrl(newUrl: String) {
|
||||||
BASE_URL = if (!newUrl.endsWith("/")) "$newUrl/" else newUrl
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.womansafe.data.repository
|
package com.example.womansafe.data.repository
|
||||||
|
|
||||||
import com.example.womansafe.data.model.*
|
import com.example.womansafe.data.model.*
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
import com.example.womansafe.data.network.NetworkClient
|
import com.example.womansafe.data.network.NetworkClient
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
|
|
||||||
@@ -191,44 +192,57 @@ class ApiRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calendar methods
|
// Calendar methods
|
||||||
suspend fun getCalendarEntries(): Response<CalendarEntriesResponse> {
|
suspend fun getCalendarEntries(
|
||||||
return apiService.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: CalendarEntryCreate): Response<CalendarEvent> {
|
suspend fun createCalendarEntry(entry: CalendarEntry): Response<CalendarEntry> {
|
||||||
return apiService.createCalendarEntry(entry)
|
// В WomanSafeApi нет метода createCalendarEntry, нужно использовать другой API
|
||||||
|
// Здесь должна быть интеграция с CalendarApi
|
||||||
|
throw NotImplementedError("Method createCalendarEntry not implemented in WomanSafeApi")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateCalendarEntry(id: String, entry: CalendarEntryUpdate): Response<CalendarEvent> {
|
suspend fun updateCalendarEntry(id: String, entry: CalendarEntry): Response<CalendarEntry> {
|
||||||
return apiService.updateCalendarEntry(id, entry)
|
// В WomanSafeApi нет метода updateCalendarEntry, нужно использовать другой API
|
||||||
|
// Здесь должна быть интеграция с CalendarApi
|
||||||
|
throw NotImplementedError("Method updateCalendarEntry not implemented in WomanSafeApi")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteCalendarEntry(id: String): Response<Any> {
|
suspend fun deleteCalendarEntry(id: String): Response<Unit> {
|
||||||
return apiService.deleteCalendarEntry(id)
|
// В WomanSafeApi нет метода deleteCalendarEntry, нужно использовать другой API
|
||||||
|
// Здесь должна быть интеграция с CalendarApi
|
||||||
|
throw NotImplementedError("Method deleteCalendarEntry not implemented in WomanSafeApi")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCycleOverview(): Response<Any> {
|
suspend fun getCycleOverview(): Response<Any> {
|
||||||
return apiService.getCycleOverview()
|
return apiService.getHealth() // Временная заглушка
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCalendarInsights(): Response<Any> {
|
suspend fun getCalendarInsights(): Response<Any> {
|
||||||
return apiService.getCalendarInsights()
|
return apiService.getHealth() // Временная заглушка
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCalendarReminders(): Response<Any> {
|
suspend fun getCalendarReminders(): Response<Any> {
|
||||||
return apiService.getCalendarReminders()
|
return apiService.getHealth() // Временная заглушка
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createCalendarReminder(): Response<Any> {
|
suspend fun createCalendarReminder(): Response<Any> {
|
||||||
return apiService.createCalendarReminder()
|
return apiService.getHealth() // Временная заглушка
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCalendarSettings(): Response<Any> {
|
suspend fun getCalendarSettings(): Response<Any> {
|
||||||
return apiService.getCalendarSettings()
|
return apiService.getHealth() // Временная заглушка
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateCalendarSettings(): Response<Any> {
|
suspend fun updateCalendarSettings(): Response<Any> {
|
||||||
return apiService.updateCalendarSettings()
|
return apiService.getHealth() // Временная заглушка
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification methods
|
// Notification methods
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package com.example.womansafe.data.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.womansafe.data.api.CalendarApi
|
||||||
|
import com.example.womansafe.data.local.CalendarDao
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleData
|
||||||
|
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||||
|
import retrofit2.Response
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleStatistics
|
||||||
|
import com.example.womansafe.data.model.calendar.CyclePrediction
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий для работы с данными менструального календаря
|
||||||
|
*/
|
||||||
|
class CalendarRepository(
|
||||||
|
private val calendarDao: CalendarDao,
|
||||||
|
private val calendarApi: CalendarApi
|
||||||
|
) {
|
||||||
|
private val TAG = "CalendarRepository"
|
||||||
|
private val dateFormatter = DateTimeFormatter.ISO_LOCAL_DATE
|
||||||
|
|
||||||
|
// CycleData operations
|
||||||
|
fun getCycleDataFlow(userId: String): Flow<CycleData?> {
|
||||||
|
return calendarDao.getCycleDataFlowByUserId(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshCycleData(userId: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.getCycleData()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { cycleData ->
|
||||||
|
calendarDao.insertCycleData(cycleData)
|
||||||
|
Log.d(TAG, "Cycle data refreshed from server")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Failed to refresh cycle data: ${response.code()}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error refreshing cycle data", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateCycleData(cycleData: CycleData): Result<CycleData> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.updateCycleData(cycleData)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { updatedData ->
|
||||||
|
calendarDao.insertCycleData(updatedData)
|
||||||
|
Result.success(updatedData)
|
||||||
|
} ?: Result.failure(Exception("Empty response body"))
|
||||||
|
} else {
|
||||||
|
// Сохраняем данные локально даже при ошибке сети
|
||||||
|
calendarDao.insertCycleData(cycleData)
|
||||||
|
Result.failure(Exception("API error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// При ошибке сети сохраняем данные локально
|
||||||
|
calendarDao.insertCycleData(cycleData)
|
||||||
|
Log.e(TAG, "Error updating cycle data", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarEntry operations
|
||||||
|
fun getCalendarEntriesFlow(userId: String): Flow<List<CalendarEntry>> {
|
||||||
|
return calendarDao.getAllCalendarEntriesFlow(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalendarEntriesBetweenDatesFlow(
|
||||||
|
userId: String,
|
||||||
|
startDate: LocalDate,
|
||||||
|
endDate: LocalDate
|
||||||
|
): Flow<List<CalendarEntry>> {
|
||||||
|
return calendarDao.getCalendarEntriesBetweenDatesFlow(userId, startDate, endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshCalendarEntries(
|
||||||
|
userId: String,
|
||||||
|
startDate: LocalDate? = null,
|
||||||
|
endDate: LocalDate? = null
|
||||||
|
) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.getCalendarEntries(
|
||||||
|
startDate?.format(dateFormatter),
|
||||||
|
endDate?.format(dateFormatter)
|
||||||
|
)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { entries ->
|
||||||
|
calendarDao.insertCalendarEntries(entries)
|
||||||
|
Log.d(TAG, "Calendar entries refreshed from server: ${entries.size}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Failed to refresh calendar entries: ${response.code()}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error refreshing calendar entries", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCalendarEntryByDate(userId: String, date: LocalDate): CalendarEntry? {
|
||||||
|
return calendarDao.getCalendarEntryByDate(userId, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addCalendarEntry(entry: CalendarEntry): Result<CalendarEntry> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.addCalendarEntry(entry)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { serverEntry ->
|
||||||
|
calendarDao.insertCalendarEntry(serverEntry)
|
||||||
|
Result.success(serverEntry)
|
||||||
|
} ?: Result.failure(Exception("Empty response body"))
|
||||||
|
} else {
|
||||||
|
// Сохраняем данные локально даже при ошибке сети
|
||||||
|
val localId = calendarDao.insertCalendarEntry(entry)
|
||||||
|
Result.failure(Exception("API error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// При ошибке сети сохраняем данные локально
|
||||||
|
val localId = calendarDao.insertCalendarEntry(entry)
|
||||||
|
Log.e(TAG, "Error adding calendar entry", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateCalendarEntry(entry: CalendarEntry): Result<CalendarEntry> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.updateCalendarEntry(entry.id, entry)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { updatedEntry ->
|
||||||
|
calendarDao.updateCalendarEntry(updatedEntry)
|
||||||
|
Result.success(updatedEntry)
|
||||||
|
} ?: Result.failure(Exception("Empty response body"))
|
||||||
|
} else {
|
||||||
|
// Обновляем данные локально даже при ошибке сети
|
||||||
|
calendarDao.updateCalendarEntry(entry)
|
||||||
|
Result.failure(Exception("API error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// При ошибке сети обновляем данные локально
|
||||||
|
calendarDao.updateCalendarEntry(entry)
|
||||||
|
Log.e(TAG, "Error updating calendar entry", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteCalendarEntry(entryId: Long): Result<Unit> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val entry = calendarDao.getCalendarEntryById(entryId)
|
||||||
|
if (entry == null) {
|
||||||
|
return@withContext Result.failure(Exception("Entry not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = calendarApi.deleteCalendarEntry(entryId)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
calendarDao.deleteCalendarEntry(entry)
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("API error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error deleting calendar entry", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthInsight operations
|
||||||
|
fun getActiveHealthInsightsFlow(userId: String): Flow<List<HealthInsight>> {
|
||||||
|
return calendarDao.getActiveHealthInsightsFlow(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllHealthInsightsFlow(userId: String): Flow<List<HealthInsight>> {
|
||||||
|
return calendarDao.getAllHealthInsightsFlow(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshHealthInsights(userId: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.getHealthInsights()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { insights ->
|
||||||
|
calendarDao.insertHealthInsights(insights)
|
||||||
|
Log.d(TAG, "Health insights refreshed from server: ${insights.size}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Failed to refresh health insights: ${response.code()}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error refreshing health insights", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun dismissInsight(insightId: Long): Result<Unit> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = calendarApi.dismissInsight(insightId)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
calendarDao.dismissHealthInsight(insightId)
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("API error: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error dismissing insight", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заглушка: Получение статистики цикла
|
||||||
|
suspend fun getCycleStatistics(userId: String): Response<CycleStatistics> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return Response.success(
|
||||||
|
CycleStatistics(
|
||||||
|
userId = userId,
|
||||||
|
averageCycleLength = 28,
|
||||||
|
averagePeriodLength = 5,
|
||||||
|
shortestCycle = 27,
|
||||||
|
longestCycle = 30,
|
||||||
|
cycleLengthVariation = 3,
|
||||||
|
regularityScore = 90,
|
||||||
|
dataPointsCount = 12,
|
||||||
|
lastUpdated = now
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заглушка: Получение прогнозов цикла
|
||||||
|
suspend fun getCyclePredictions(userId: String): Response<CyclePrediction> {
|
||||||
|
val today = java.time.LocalDate.now()
|
||||||
|
return Response.success(
|
||||||
|
CyclePrediction(
|
||||||
|
userId = userId,
|
||||||
|
nextPeriodStart = today.plusDays(10),
|
||||||
|
nextPeriodEnd = today.plusDays(15),
|
||||||
|
nextOvulation = today.plusDays(20),
|
||||||
|
fertileWindowStart = today.plusDays(18),
|
||||||
|
fertileWindowEnd = today.plusDays(22),
|
||||||
|
confidenceScore = 85,
|
||||||
|
createdAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заглушка: Получение кэшированных прогнозов
|
||||||
|
fun getCachedPredictions(userId: String): CyclePrediction {
|
||||||
|
val today = java.time.LocalDate.now()
|
||||||
|
return CyclePrediction(
|
||||||
|
userId = userId,
|
||||||
|
nextPeriodStart = today.plusDays(10),
|
||||||
|
nextPeriodEnd = today.plusDays(15),
|
||||||
|
nextOvulation = today.plusDays(20),
|
||||||
|
fertileWindowStart = today.plusDays(18),
|
||||||
|
fertileWindowEnd = today.plusDays(22),
|
||||||
|
confidenceScore = 80,
|
||||||
|
createdAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заглушка: Получение инсайтов о здоровье
|
||||||
|
suspend fun getHealthInsights(): Response<List<HealthInsight>> {
|
||||||
|
// TODO: Реализовать реальный запрос
|
||||||
|
return Response.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up operations
|
||||||
|
suspend fun cleanupOldInsights(userId: String, olderThan: Long) {
|
||||||
|
calendarDao.deleteOldInsights(userId, olderThan)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCalendarEntries(): List<CalendarEntry> {
|
||||||
|
// Пример: получение из DAO или API
|
||||||
|
return calendarDao.getAllCalendarEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCycleData(): CycleData? {
|
||||||
|
// Пример: получение из DAO или API
|
||||||
|
return calendarDao.getLatestCycleData()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package com.example.womansafe.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import com.example.womansafe.data.model.calendar.CalendarEvent
|
||||||
|
import com.example.womansafe.utils.DateUtils
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarEventDetailDialog(
|
||||||
|
event: CalendarEvent,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onEdit: (CalendarEvent) -> Unit,
|
||||||
|
onDelete: (CalendarEvent) -> Unit
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
// Заголовок с датой
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Иконка типа события
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(getEventIconBackgroundColor(event.type)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = getEventIcon(event.type),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = getEventTitle(event.type),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = event.date.format(DateTimeFormatter.ofPattern("d MMMM yyyy", Locale("ru"))),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 16.dp))
|
||||||
|
|
||||||
|
// Детали события
|
||||||
|
event.flowIntensity?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Интенсивность",
|
||||||
|
value = getFlowIntensityLabel(it),
|
||||||
|
icon = Icons.Default.Opacity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.mood?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Настроение",
|
||||||
|
value = getMoodLabel(it),
|
||||||
|
icon = Icons.Default.Face
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.symptoms?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Симптомы",
|
||||||
|
value = it.joinToString(", ") { symptom -> getSymptomLabel(symptom) },
|
||||||
|
icon = Icons.Default.HealthAndSafety
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.medications?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Лекарства",
|
||||||
|
value = it.joinToString(", "),
|
||||||
|
icon = Icons.Default.Medication
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.energyLevel?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Уровень энергии",
|
||||||
|
value = "$it/5",
|
||||||
|
icon = Icons.Default.BatteryChargingFull
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.sleepHours?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Часы сна",
|
||||||
|
value = "$it ч",
|
||||||
|
icon = Icons.Default.Bedtime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.notes?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
DetailItem(
|
||||||
|
title = "Заметки",
|
||||||
|
value = it,
|
||||||
|
icon = Icons.Default.Notes,
|
||||||
|
maxLines = 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Кнопки действий
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Закрыть")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = { onEdit(event) }) {
|
||||||
|
Text("Редактировать")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = { onDelete(event) },
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Удалить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DetailItem(
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
maxLines: Int = 2
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 16.dp, top = 2.dp)
|
||||||
|
.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = maxLines,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательные функции для получения информации о событии
|
||||||
|
private fun getEventIcon(type: String): androidx.compose.ui.graphics.vector.ImageVector {
|
||||||
|
return when (type.lowercase()) {
|
||||||
|
"period" -> Icons.Default.Opacity
|
||||||
|
"ovulation" -> Icons.Default.Star
|
||||||
|
"symptoms" -> Icons.Default.Healing
|
||||||
|
"medication" -> Icons.Default.LocalPharmacy
|
||||||
|
"note" -> Icons.Default.Notes
|
||||||
|
"appointment" -> Icons.Default.Event
|
||||||
|
else -> Icons.Default.Check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEventIconBackgroundColor(type: String): Color {
|
||||||
|
return when (type.lowercase()) {
|
||||||
|
"period" -> Color(0xFFE91E63) // Розовый
|
||||||
|
"ovulation" -> Color(0xFF2196F3) // Голубой
|
||||||
|
"symptoms" -> Color(0xFFFFC107) // Желтый
|
||||||
|
"medication" -> Color(0xFF9C27B0) // Фиолетовый
|
||||||
|
"note" -> Color(0xFF607D8B) // Серо-синий
|
||||||
|
"appointment" -> Color(0xFF4CAF50) // Зеленый
|
||||||
|
else -> Color.Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEventTitle(type: String): String {
|
||||||
|
return when (type.lowercase()) {
|
||||||
|
"period" -> "Менструация"
|
||||||
|
"ovulation" -> "Овуляция"
|
||||||
|
"symptoms" -> "Симптомы"
|
||||||
|
"medication" -> "Лекарства"
|
||||||
|
"note" -> "Заметка"
|
||||||
|
"appointment" -> "Приём врача"
|
||||||
|
else -> type.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFlowIntensityLabel(intensity: String): String {
|
||||||
|
return when (intensity.uppercase()) {
|
||||||
|
"SPOTTING" -> "Мажущие"
|
||||||
|
"LIGHT" -> "Легкие"
|
||||||
|
"MEDIUM" -> "Средние"
|
||||||
|
"HEAVY" -> "Сильные"
|
||||||
|
"VERY_HEAVY" -> "Очень сильные"
|
||||||
|
else -> intensity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMoodLabel(mood: String): String {
|
||||||
|
return when (mood.uppercase()) {
|
||||||
|
"VERY_HAPPY" -> "Отлично"
|
||||||
|
"HAPPY" -> "Хорошо"
|
||||||
|
"NEUTRAL" -> "Нормально"
|
||||||
|
"NORMAL" -> "Нормально"
|
||||||
|
"SAD" -> "Грустно"
|
||||||
|
"VERY_SAD" -> "Очень грустно"
|
||||||
|
"ANXIOUS" -> "Тревожно"
|
||||||
|
"IRRITATED" -> "Раздражение"
|
||||||
|
"IRRITABLE" -> "Раздражительность"
|
||||||
|
"SENSITIVE" -> "Чувствительность"
|
||||||
|
"CALM" -> "Спокойно"
|
||||||
|
"ENERGETIC" -> "Энергично"
|
||||||
|
"TIRED" -> "Устало"
|
||||||
|
else -> mood
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSymptomLabel(symptom: String): String {
|
||||||
|
return when (symptom.uppercase()) {
|
||||||
|
"CRAMPS" -> "Спазмы"
|
||||||
|
"HEADACHE" -> "Головная боль"
|
||||||
|
"BACKACHE" -> "Боль в спине"
|
||||||
|
"NAUSEA" -> "Тошнота"
|
||||||
|
"FATIGUE" -> "Усталость"
|
||||||
|
"BLOATING" -> "Вздутие"
|
||||||
|
"BREAST_TENDERNESS" -> "Болезненность груди"
|
||||||
|
"ACNE" -> "Высыпания"
|
||||||
|
"MOOD_SWINGS" -> "Перепады настроения"
|
||||||
|
"CRAVINGS" -> "Тяга к еде"
|
||||||
|
"INSOMNIA" -> "Бессонница"
|
||||||
|
"DIZZINESS" -> "Головокружение"
|
||||||
|
"CONSTIPATION" -> "Запор"
|
||||||
|
"DIARRHEA" -> "Диарея"
|
||||||
|
"HOT_FLASHES" -> "Приливы"
|
||||||
|
"SPOTTING" -> "Мажущие выделения"
|
||||||
|
else -> symptom
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.womansafe.data.model.UserResponse
|
import com.example.womansafe.data.model.UserResponse
|
||||||
import com.example.womansafe.ui.viewmodel.AuthViewModel
|
import com.example.womansafe.ui.viewmodel.AuthViewModel
|
||||||
|
import com.example.womansafe.util.fixTouchEvents
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -318,7 +319,8 @@ fun UserProfileScreen(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp),
|
.padding(16.dp)
|
||||||
|
.fixTouchEvents(),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
|
|||||||
@@ -0,0 +1,487 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.womansafe.data.model.calendar.*
|
||||||
|
import com.example.womansafe.ui.icons.CustomIcons
|
||||||
|
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран для добавления/редактирования записей в календаре
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarEntryScreen(
|
||||||
|
viewModel: CalendarViewModel,
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
existingEntry: CalendarEntry? = null,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
// Форматтер для отображения даты
|
||||||
|
val dateFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale("ru"))
|
||||||
|
|
||||||
|
// Состояние для полей формы
|
||||||
|
var entryType by remember { mutableStateOf(existingEntry?.entryType ?: EntryType.PERIOD) }
|
||||||
|
var flowIntensity by remember { mutableStateOf(existingEntry?.flowIntensity ?: FlowIntensity.MEDIUM) }
|
||||||
|
var mood by remember { mutableStateOf(existingEntry?.mood ?: Mood.NORMAL) }
|
||||||
|
var energyLevel by remember { mutableStateOf(existingEntry?.energyLevel ?: 3) }
|
||||||
|
var sleepHours by remember { mutableStateOf(existingEntry?.sleepHours?.toString() ?: "8.0") }
|
||||||
|
var selectedSymptoms by remember { mutableStateOf(existingEntry?.symptoms?.toMutableList() ?: mutableListOf()) }
|
||||||
|
var medications by remember { mutableStateOf(existingEntry?.medications?.joinToString(", ") ?: "") }
|
||||||
|
var notes by remember { mutableStateOf(existingEntry?.notes ?: "") }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = if (existingEntry != null) "Редактировать запись" else "Новая запись"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Закрыть")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
// Создаем или обновляем запись
|
||||||
|
val entry = existingEntry?.copy(
|
||||||
|
entryDate = selectedDate,
|
||||||
|
entryType = entryType,
|
||||||
|
flowIntensity = if (entryType == EntryType.PERIOD) flowIntensity else null,
|
||||||
|
mood = mood,
|
||||||
|
energyLevel = energyLevel,
|
||||||
|
sleepHours = sleepHours.toFloatOrNull(),
|
||||||
|
symptoms = selectedSymptoms,
|
||||||
|
medications = if (medications.isBlank()) null else medications.split(",").map { it.trim() },
|
||||||
|
notes = notes.ifBlank { null }
|
||||||
|
) ?: CalendarEntry(
|
||||||
|
userId = "", // будет заменено в репозитории
|
||||||
|
entryDate = selectedDate,
|
||||||
|
entryType = entryType,
|
||||||
|
flowIntensity = if (entryType == EntryType.PERIOD) flowIntensity else null,
|
||||||
|
mood = mood,
|
||||||
|
energyLevel = energyLevel,
|
||||||
|
sleepHours = sleepHours.toFloatOrNull(),
|
||||||
|
symptoms = if (selectedSymptoms.isEmpty()) null else selectedSymptoms,
|
||||||
|
medications = if (medications.isBlank()) null else medications.split(",").map { it.trim() },
|
||||||
|
notes = notes.ifBlank { null },
|
||||||
|
entryId = existingEntry?.entryId ?: 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingEntry != null) {
|
||||||
|
viewModel.updateCalendarEntry(entry)
|
||||||
|
} else {
|
||||||
|
viewModel.addCalendarEntry(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Check, contentDescription = "Сохранить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
// Заголовок с датой
|
||||||
|
Text(
|
||||||
|
text = selectedDate.format(dateFormatter),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Выбор типа записи
|
||||||
|
Text(
|
||||||
|
text = "Тип записи",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
EntryTypeSelector(
|
||||||
|
selectedType = entryType,
|
||||||
|
onTypeSelected = { entryType = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Показываем дополнительные поля в зависимости от типа записи
|
||||||
|
when (entryType) {
|
||||||
|
EntryType.PERIOD -> {
|
||||||
|
FlowIntensitySelector(
|
||||||
|
selectedIntensity = flowIntensity,
|
||||||
|
onIntensitySelected = { flowIntensity = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
else -> { /* Другие поля будут отображаться всегда */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настроение
|
||||||
|
Text(
|
||||||
|
text = "Настроение",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
MoodSelector(
|
||||||
|
selectedMood = mood,
|
||||||
|
onMoodSelected = { mood = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Уровень энергии
|
||||||
|
Text(
|
||||||
|
text = "Уровень энергии: $energyLevel",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = energyLevel.toFloat(),
|
||||||
|
onValueChange = { energyLevel = it.toInt() },
|
||||||
|
valueRange = 1f..5f,
|
||||||
|
steps = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Часы сна
|
||||||
|
OutlinedTextField(
|
||||||
|
value = sleepHours,
|
||||||
|
onValueChange = { sleepHours = it },
|
||||||
|
label = { Text("Часы сна") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Number
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Симптомы
|
||||||
|
Text(
|
||||||
|
text = "Симптомы",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
SymptomsSelector(
|
||||||
|
selectedSymptoms = selectedSymptoms,
|
||||||
|
onSymptomsChanged = { selectedSymptoms = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Лекарства
|
||||||
|
OutlinedTextField(
|
||||||
|
value = medications,
|
||||||
|
onValueChange = { medications = it },
|
||||||
|
label = { Text("Лекарства (через запятую)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Заметки
|
||||||
|
OutlinedTextField(
|
||||||
|
value = notes,
|
||||||
|
onValueChange = { notes = it },
|
||||||
|
label = { Text("Заметки") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 120.dp),
|
||||||
|
maxLines = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Кнопка удаления (только для существующих записей)
|
||||||
|
existingEntry?.let {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.deleteCalendarEntry(existingEntry.id)
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Удалить запись")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Селектор типа записи
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EntryTypeSelector(
|
||||||
|
selectedType: EntryType,
|
||||||
|
onTypeSelected: (EntryType) -> Unit
|
||||||
|
) {
|
||||||
|
val entryTypes = listOf(
|
||||||
|
EntryType.PERIOD to "Менструация",
|
||||||
|
EntryType.OVULATION to "Овуляция",
|
||||||
|
EntryType.SYMPTOMS to "Симптомы",
|
||||||
|
EntryType.MEDICATION to "Лекарства",
|
||||||
|
EntryType.NOTE to "Заметка"
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectableGroup()
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
entryTypes.forEach { (type, label) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.selectable(
|
||||||
|
selected = type == selectedType,
|
||||||
|
onClick = { onTypeSelected(type) },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = type == selectedType,
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Селектор интенсивности менструации
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FlowIntensitySelector(
|
||||||
|
selectedIntensity: FlowIntensity,
|
||||||
|
onIntensitySelected: (FlowIntensity) -> Unit
|
||||||
|
) {
|
||||||
|
val intensities = listOf(
|
||||||
|
FlowIntensity.LIGHT to "Легкая",
|
||||||
|
FlowIntensity.MEDIUM to "Средняя",
|
||||||
|
FlowIntensity.HEAVY to "Сильная"
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = "Интенсивность",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectableGroup()
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
intensities.forEach { (intensity, label) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.selectable(
|
||||||
|
selected = intensity == selectedIntensity,
|
||||||
|
onClick = { onIntensitySelected(intensity) },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = intensity == selectedIntensity,
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Селектор настроения
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MoodSelector(
|
||||||
|
selectedMood: Mood,
|
||||||
|
onMoodSelected: (Mood) -> Unit
|
||||||
|
) {
|
||||||
|
// Создаем список троек (настроение, метка, иконка) вместо использования вложенных Pair
|
||||||
|
val moods = listOf(
|
||||||
|
Triple(Mood.HAPPY, "Счастливое", Icons.Default.Mood),
|
||||||
|
Triple(Mood.NORMAL, "Обычное", Icons.Default.Face),
|
||||||
|
Triple(Mood.SAD, "Грустное", CustomIcons.SentimentDissatisfied),
|
||||||
|
Triple(Mood.IRRITATED, "Раздражение", CustomIcons.Dangerous),
|
||||||
|
Triple(Mood.ANXIOUS, "Тревожное", Icons.Default.Warning),
|
||||||
|
Triple(Mood.SENSITIVE, "Чувствительное", Icons.Default.Favorite),
|
||||||
|
Triple(Mood.CALM, "Спокойное", CustomIcons.Spa)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
LazyRow {
|
||||||
|
items(moods.size) { index ->
|
||||||
|
val (mood, label, icon) = moods[index]
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.selectable(
|
||||||
|
selected = mood == selectedMood,
|
||||||
|
onClick = { onMoodSelected(mood) },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = label,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.padding(8.dp),
|
||||||
|
tint = if (mood == selectedMood)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (mood == selectedMood)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Селектор симптомов
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SymptomsSelector(
|
||||||
|
selectedSymptoms: List<Symptom>,
|
||||||
|
onSymptomsChanged: (MutableList<Symptom>) -> Unit
|
||||||
|
) {
|
||||||
|
val symptoms = listOf(
|
||||||
|
Symptom.CRAMPS to "Спазмы",
|
||||||
|
Symptom.HEADACHE to "Головная боль",
|
||||||
|
Symptom.BLOATING to "Вздутие",
|
||||||
|
Symptom.BACKACHE to "Боль в спине",
|
||||||
|
Symptom.FATIGUE to "Усталость",
|
||||||
|
Symptom.NAUSEA to "Тошнота",
|
||||||
|
Symptom.BREAST_TENDERNESS to "Боль в груди",
|
||||||
|
Symptom.ACNE to "Акне",
|
||||||
|
Symptom.CRAVINGS to "Тяга к еде",
|
||||||
|
Symptom.INSOMNIA to "Бессонница",
|
||||||
|
Symptom.DIZZINESS to "Головокружение",
|
||||||
|
Symptom.DIARRHEA to "Диарея",
|
||||||
|
Symptom.CONSTIPATION to "Запор"
|
||||||
|
)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
symptoms.chunked(2).forEach { row ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
row.forEach { (symptom, label) ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = selectedSymptoms.contains(symptom),
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
val newList = selectedSymptoms.toMutableList()
|
||||||
|
if (checked) {
|
||||||
|
newList.add(symptom)
|
||||||
|
} else {
|
||||||
|
newList.remove(symptom)
|
||||||
|
}
|
||||||
|
onSymptomsChanged(newList)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
package com.example.womansafe.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
|
import androidx.compose.material.icons.filled.FitnessCenter
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Insights
|
||||||
|
import androidx.compose.material.icons.filled.Medication
|
||||||
|
import androidx.compose.material.icons.filled.Mood
|
||||||
|
import androidx.compose.material.icons.filled.TipsAndUpdates
|
||||||
|
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.outlined.Favorite
|
||||||
|
import androidx.compose.material.icons.outlined.LocalHospital
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.example.womansafe.data.model.calendar.CycleData
|
||||||
|
import com.example.womansafe.data.model.calendar.HealthInsight
|
||||||
|
import com.example.womansafe.data.model.calendar.InsightType
|
||||||
|
import com.example.womansafe.data.model.calendar.ConfidenceLevel
|
||||||
|
import com.example.womansafe.data.model.calendar.Mood
|
||||||
|
import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран аналитики менструального цикла и инсайтов
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarInsightsScreen(
|
||||||
|
viewModel: CalendarViewModel = viewModel(),
|
||||||
|
onBackClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val calendarUiState by viewModel.calendarUiState.observeAsState()
|
||||||
|
val isLoading by viewModel.isLoading.observeAsState(false)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Аналитика и рекомендации") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBackClick) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Назад")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
CycleStatistics(cycleData = calendarUiState?.cycleData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разделитель
|
||||||
|
item {
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Персонализированные рекомендации",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список инсайтов
|
||||||
|
calendarUiState?.insights?.let { insights ->
|
||||||
|
if (insights.isNotEmpty()) {
|
||||||
|
items(insights) { insight ->
|
||||||
|
InsightItem(
|
||||||
|
insight = insight,
|
||||||
|
onDismiss = { viewModel.dismissInsight(insight.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item {
|
||||||
|
EmptyInsightsMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: item {
|
||||||
|
EmptyInsightsMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем пространство внизу
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статистика цикла
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CycleStatistics(cycleData: CycleData?) {
|
||||||
|
val dateFormatter = DateTimeFormatter.ofPattern("d MMMM", Locale("ru"))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Статистика цикла",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (cycleData != null) {
|
||||||
|
// Основные показатели
|
||||||
|
StatisticRow(
|
||||||
|
label = "Средняя длина цикла:",
|
||||||
|
value = "${cycleData.averageCycleLength} дней"
|
||||||
|
)
|
||||||
|
|
||||||
|
StatisticRow(
|
||||||
|
label = "Средняя продолжительность менструации:",
|
||||||
|
value = "${cycleData.averagePeriodLength} дней"
|
||||||
|
)
|
||||||
|
|
||||||
|
StatisticRow(
|
||||||
|
label = "Регулярность цикла:",
|
||||||
|
value = "${cycleData.regularityScore}/100"
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Прогнозы
|
||||||
|
cycleData.nextPeriodPredicted?.let { nextPeriod ->
|
||||||
|
Text(
|
||||||
|
text = "Прогноз следующего цикла",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
StatisticRow(
|
||||||
|
label = "Начало следующего цикла:",
|
||||||
|
value = nextPeriod.format(dateFormatter)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Прогноз овуляции
|
||||||
|
cycleData.ovulationDate?.let { ovulationDate ->
|
||||||
|
StatisticRow(
|
||||||
|
label = "Ожидаемая овуляция:",
|
||||||
|
value = ovulationDate.format(dateFormatter)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фертильное окно
|
||||||
|
if (cycleData.fertileWindowStart != null && cycleData.fertileWindowEnd != null) {
|
||||||
|
StatisticRow(
|
||||||
|
label = "Фертильное окно:",
|
||||||
|
value = "${cycleData.fertileWindowStart.format(dateFormatter)} - ${cycleData.fertileWindowEnd.format(dateFormatter)}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Диаграмма цикла
|
||||||
|
CycleGraph(cycleData = cycleData)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Пустое состояние
|
||||||
|
Text(
|
||||||
|
text = "Нет данных о цикле. Добавьте информацию о своем цикле для отображения статистики.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строка со статистическим показателем
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun StatisticRow(label: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Упрощенная диаграмма цикла
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CycleGraph(cycleData: CycleData) {
|
||||||
|
val totalDays = cycleData.cycleLength
|
||||||
|
val periodDays = cycleData.periodLength
|
||||||
|
val ovulationDay = cycleData.ovulationDate?.let {
|
||||||
|
val startDate = cycleData.periodStart ?: cycleData.lastPeriodStartDate
|
||||||
|
if (startDate != null) {
|
||||||
|
it.toEpochDay() - startDate.toEpochDay()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}?.toInt() ?: (totalDays / 2)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = "Текущий цикл",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(24.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
// Отображение фазы менструации
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(((periodDays.toFloat() / totalDays) * 100).dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color(0xFFE57373)) // Красный для менструации
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Отображение овуляции
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.offset(x = ((ovulationDay.toFloat() / totalDays) * 100).dp - 12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF64B5F6)) // Синий для овуляции
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Легенда
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFFE57373))
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Менструация",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(start = 4.dp, end = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF64B5F6))
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Овуляция",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Элемент списка инсайтов
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InsightItem(
|
||||||
|
insight: HealthInsight,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val (icon, color) = when (insight.insightType) {
|
||||||
|
InsightType.CYCLE_REGULARITY -> Pair(
|
||||||
|
Icons.Outlined.CalendarMonth,
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
InsightType.PERIOD_LENGTH -> Pair(
|
||||||
|
Icons.Outlined.Favorite,
|
||||||
|
Color(0xFFE57373)
|
||||||
|
)
|
||||||
|
InsightType.SYMPTOM_PATTERN -> Pair(
|
||||||
|
Icons.Default.Insights,
|
||||||
|
MaterialTheme.colorScheme.tertiary
|
||||||
|
)
|
||||||
|
InsightType.MOOD_CORRELATION -> Pair(
|
||||||
|
Icons.Filled.Mood,
|
||||||
|
Color(0xFF81C784)
|
||||||
|
)
|
||||||
|
InsightType.PERIOD_PREDICTION -> Pair(
|
||||||
|
Icons.Default.DateRange,
|
||||||
|
Color(0xFF64B5F6)
|
||||||
|
)
|
||||||
|
InsightType.HEALTH_TIP -> Pair(
|
||||||
|
Icons.Default.TipsAndUpdates,
|
||||||
|
Color(0xFFFFC107)
|
||||||
|
)
|
||||||
|
InsightType.MEDICATION_REMINDER -> Pair(
|
||||||
|
Icons.Default.Medication,
|
||||||
|
Color(0xFF9C27B0)
|
||||||
|
)
|
||||||
|
InsightType.EXERCISE_SUGGESTION -> Pair(
|
||||||
|
Icons.Default.FitnessCenter,
|
||||||
|
Color(0xFF607D8B)
|
||||||
|
)
|
||||||
|
else -> Pair(Icons.Default.Info, MaterialTheme.colorScheme.outline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Заголовок и иконка
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = insight.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Индикатор достоверности
|
||||||
|
ConfidenceBadge(confidence = insight.confidenceLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Описание инсайта
|
||||||
|
Text(
|
||||||
|
text = insight.description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Рекомендация
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Рекомендация:",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = insight.recommendation,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Информация об источниках данных
|
||||||
|
Text(
|
||||||
|
text = "На основе ${insight.dataPointsUsed} записей",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp)
|
||||||
|
.align(Alignment.End)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Кнопка "скрыть"
|
||||||
|
TextButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Text("Скрыть рекомендацию")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индикатор достоверности инсайта
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConfidenceBadge(confidence: ConfidenceLevel) {
|
||||||
|
val (color, text) = when (confidence) {
|
||||||
|
ConfidenceLevel.HIGH -> Pair(
|
||||||
|
Color(0xFF4CAF50),
|
||||||
|
"Высокая точность"
|
||||||
|
)
|
||||||
|
ConfidenceLevel.MEDIUM -> Pair(
|
||||||
|
Color(0xFFFFC107),
|
||||||
|
"Средняя точность"
|
||||||
|
)
|
||||||
|
ConfidenceLevel.LOW -> Pair(
|
||||||
|
Color(0xFFFF5722),
|
||||||
|
"Низкая точность"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(color.copy(alpha = 0.2f))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сообщение при отсутствии инсайтов
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EmptyInsightsMessage() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Insights,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Пока нет персональных рекомендаций",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Продолжайте вести календарь, и мы сможем предоставить вам полезные советы на основе ваших данных",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,20 +10,25 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.womansafe.data.model.EmergencyContactCreate
|
import com.example.womansafe.data.model.EmergencyContactCreate
|
||||||
import com.example.womansafe.data.model.EmergencyContactResponse
|
import com.example.womansafe.data.model.EmergencyContactResponse
|
||||||
import com.example.womansafe.ui.viewmodel.EmergencyContactsViewModel
|
import com.example.womansafe.ui.viewmodel.EmergencyContactsViewModel
|
||||||
|
import com.example.womansafe.util.ContactsHelper
|
||||||
|
import com.example.womansafe.util.fixTouchEvents
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EmergencyContactsScreen(
|
fun EmergencyContactsScreen(
|
||||||
emergencyContactsViewModel: EmergencyContactsViewModel,
|
emergencyContactsViewModel: EmergencyContactsViewModel,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
onNavigateToContactPicker: () -> Unit
|
||||||
) {
|
) {
|
||||||
val uiState = emergencyContactsViewModel.uiState
|
val uiState = emergencyContactsViewModel.uiState
|
||||||
var showAddDialog by remember { mutableStateOf(false) }
|
var showAddDialog by remember { mutableStateOf(false) }
|
||||||
|
var showContactPickerDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
emergencyContactsViewModel.loadContacts()
|
emergencyContactsViewModel.loadContacts()
|
||||||
@@ -47,7 +52,7 @@ fun EmergencyContactsScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { showAddDialog = true },
|
onClick = { showContactPickerDialog = true },
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(48.dp)
|
modifier = Modifier.size(48.dp)
|
||||||
) {
|
) {
|
||||||
@@ -97,7 +102,7 @@ fun EmergencyContactsScreen(
|
|||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
} else if (uiState.contacts.isEmpty()) {
|
} else if (uiState.contacts.isEmpty()) {
|
||||||
EmptyContactsCard(onAddContact = { showAddDialog = true })
|
EmptyContactsCard(onAddContact = { showContactPickerDialog = true })
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
@@ -125,6 +130,17 @@ fun EmergencyContactsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Диалог выбора контакта
|
||||||
|
if (showContactPickerDialog) {
|
||||||
|
ContactPickerDialog(
|
||||||
|
onDismiss = { showContactPickerDialog = false },
|
||||||
|
onContactSelected = { contact ->
|
||||||
|
emergencyContactsViewModel.addContact(contact)
|
||||||
|
showContactPickerDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Обработка ошибок
|
// Обработка ошибок
|
||||||
uiState.error?.let { error ->
|
uiState.error?.let { error ->
|
||||||
LaunchedEffect(error) {
|
LaunchedEffect(error) {
|
||||||
@@ -367,3 +383,76 @@ private fun AddContactDialog(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,9 @@ package com.example.womansafe.ui.screens
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
@@ -15,6 +17,7 @@ import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
|||||||
import com.example.womansafe.ui.viewmodel.EmergencyContactsViewModel
|
import com.example.womansafe.ui.viewmodel.EmergencyContactsViewModel
|
||||||
import com.example.womansafe.ui.viewmodel.EmergencyViewModel
|
import com.example.womansafe.ui.viewmodel.EmergencyViewModel
|
||||||
import com.example.womansafe.ui.viewmodel.ProfileSettingsViewModel
|
import com.example.womansafe.ui.viewmodel.ProfileSettingsViewModel
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -51,6 +54,9 @@ private fun MainNavHost(
|
|||||||
authViewModel: AuthViewModel,
|
authViewModel: AuthViewModel,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
// Создаем ViewModel для календаря здесь для общего доступа
|
||||||
|
val calendarViewModel: CalendarViewModel = viewModel()
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = BottomNavItem.Home.route,
|
startDestination = BottomNavItem.Home.route,
|
||||||
@@ -64,8 +70,12 @@ private fun MainNavHost(
|
|||||||
EmergencyScreen(emergencyViewModel = EmergencyViewModel())
|
EmergencyScreen(emergencyViewModel = EmergencyViewModel())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Заменяем прямой вызов CalendarScreen на вложенную навигацию
|
||||||
composable(BottomNavItem.Calendar.route) {
|
composable(BottomNavItem.Calendar.route) {
|
||||||
CalendarScreen(calendarViewModel = CalendarViewModel())
|
CalendarNavigation(
|
||||||
|
calendarViewModel = calendarViewModel,
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(BottomNavItem.Profile.route) {
|
composable(BottomNavItem.Profile.route) {
|
||||||
@@ -78,7 +88,10 @@ private fun MainNavHost(
|
|||||||
|
|
||||||
// Дополнительные экраны
|
// Дополнительные экраны
|
||||||
composable("emergency_contacts") {
|
composable("emergency_contacts") {
|
||||||
EmergencyContactsScreen(emergencyContactsViewModel = EmergencyContactsViewModel())
|
EmergencyContactsScreen(
|
||||||
|
emergencyContactsViewModel = EmergencyContactsViewModel(),
|
||||||
|
onNavigateToContactPicker = { navController.navigate("contact_picker") }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable("profile_settings") {
|
composable("profile_settings") {
|
||||||
@@ -87,5 +100,45 @@ private fun MainNavHost(
|
|||||||
onNavigateBack = { navController.popBackStack() }
|
onNavigateBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Экраны календаря
|
||||||
|
composable("calendar_entry/{date}") { backStackEntry ->
|
||||||
|
val dateString = backStackEntry.arguments?.getString("date") ?: LocalDate.now().toString()
|
||||||
|
val date = LocalDate.parse(dateString)
|
||||||
|
|
||||||
|
CalendarEntryScreen(
|
||||||
|
viewModel = calendarViewModel,
|
||||||
|
selectedDate = date,
|
||||||
|
onClose = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable("calendar_insights") {
|
||||||
|
CalendarInsightsScreen(
|
||||||
|
viewModel = calendarViewModel,
|
||||||
|
onBackClick = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вложенная навигация для календаря
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarNavigation(
|
||||||
|
calendarViewModel: CalendarViewModel,
|
||||||
|
navController: NavHostController
|
||||||
|
) {
|
||||||
|
val selectedDate by calendarViewModel.selectedDate.observeAsState(LocalDate.now())
|
||||||
|
|
||||||
|
CalendarScreen(
|
||||||
|
viewModel = calendarViewModel,
|
||||||
|
onAddEntryClick = { date ->
|
||||||
|
navController.navigate("calendar_entry/${date}")
|
||||||
|
},
|
||||||
|
onViewInsightsClick = {
|
||||||
|
navController.navigate("calendar_insights")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
@@ -23,15 +23,24 @@ class AuthViewModel : ViewModel() {
|
|||||||
try {
|
try {
|
||||||
// Определяем, что введено - email или username
|
// Определяем, что введено - email или username
|
||||||
val isEmail = usernameOrEmail.contains("@")
|
val isEmail = usernameOrEmail.contains("@")
|
||||||
|
println("=== LOGIN ATTEMPT ===")
|
||||||
|
println("Input: $usernameOrEmail, isEmail: $isEmail")
|
||||||
|
|
||||||
val response = repository.login(
|
val response = repository.login(
|
||||||
email = if (isEmail) usernameOrEmail else null,
|
email = if (isEmail) usernameOrEmail else null,
|
||||||
username = if (!isEmail) usernameOrEmail else null,
|
username = if (!isEmail) usernameOrEmail else null,
|
||||||
password = password
|
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) {
|
if (response.isSuccessful) {
|
||||||
val token = response.body()
|
val token = response.body()
|
||||||
token?.let {
|
token?.let {
|
||||||
|
println("Login Success: Token received - ${it.accessToken.take(10)}...")
|
||||||
NetworkClient.setAuthToken(it.accessToken)
|
NetworkClient.setAuthToken(it.accessToken)
|
||||||
uiState = uiState.copy(
|
uiState = uiState.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -41,14 +50,33 @@ class AuthViewModel : ViewModel() {
|
|||||||
)
|
)
|
||||||
// Получаем профиль пользователя сразу после успешного входа
|
// Получаем профиль пользователя сразу после успешного входа
|
||||||
getCurrentUser()
|
getCurrentUser()
|
||||||
}
|
} ?: run {
|
||||||
} else {
|
println("Login Error: Token is null in successful response")
|
||||||
uiState = uiState.copy(
|
uiState = uiState.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = "Ошибка авторизации: ${response.code()} - ${response.message()}"
|
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) {
|
} catch (e: Exception) {
|
||||||
|
println("Login Exception: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
uiState = uiState.copy(
|
uiState = uiState.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = "Ошибка сети: ${e.message}"
|
error = "Ошибка сети: ${e.message}"
|
||||||
@@ -61,6 +89,10 @@ class AuthViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
uiState = uiState.copy(isLoading = true, error = null)
|
uiState = uiState.copy(isLoading = true, error = null)
|
||||||
try {
|
try {
|
||||||
|
println("=== REGISTER ATTEMPT ===")
|
||||||
|
println("Username: $username, Email: $email, Full Name: $fullName, Phone Number: $phoneNumber")
|
||||||
|
println("Password length: ${password.length}")
|
||||||
|
|
||||||
val response = repository.register(
|
val response = repository.register(
|
||||||
email = email,
|
email = email,
|
||||||
username = username,
|
username = username,
|
||||||
@@ -69,26 +101,68 @@ class AuthViewModel : ViewModel() {
|
|||||||
phoneNumber = phoneNumber
|
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) {
|
if (response.isSuccessful) {
|
||||||
val userResponse = response.body()
|
val userResponse = response.body()
|
||||||
userResponse?.let {
|
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(
|
uiState = uiState.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isLoggedIn = true,
|
user = it
|
||||||
user = it,
|
)
|
||||||
registrationSuccess = true
|
|
||||||
|
// Выполняем автоматический вход
|
||||||
|
login(loginIdentifier, password)
|
||||||
|
} ?: run {
|
||||||
|
println("Register Error: User object is null in successful response")
|
||||||
|
uiState = uiState.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Ошибка регистрации: Получен пустой ответ"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} 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(
|
uiState = uiState.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = "Ошибка регистрации: ${response.code()} - ${response.message()}"
|
error = errorMessage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
println("Register Exception: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
uiState = uiState.copy(
|
uiState = uiState.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = "Ошибка сети: ${e.message}"
|
error = "Ошибка сети при регистрации: ${e.message}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,8 +252,43 @@ class AuthViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Метод для автоматического входа с использованием сохраненного токена
|
||||||
|
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() {
|
fun logout() {
|
||||||
NetworkClient.setAuthToken(null)
|
// Очищаем токен в NetworkClient
|
||||||
|
NetworkClient.clearAuthToken()
|
||||||
uiState = AuthUiState()
|
uiState = AuthUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,572 +1,255 @@
|
|||||||
package com.example.womansafe.ui.viewmodel
|
package com.example.womansafe.ui.viewmodel
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
import android.app.Application
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.womansafe.data.model.*
|
import com.example.womansafe.data.api.CalendarApi
|
||||||
import com.example.womansafe.data.repository.ApiRepository
|
import com.example.womansafe.data.local.CalendarDatabase
|
||||||
import kotlinx.coroutines.*
|
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.LocalDate
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
import kotlin.math.abs
|
|
||||||
import kotlin.math.min
|
|
||||||
import kotlin.math.pow
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
data class CalendarUiState(
|
/**
|
||||||
val isLoading: Boolean = false,
|
* ViewModel для функциональности менструального календаря
|
||||||
val currentMonth: LocalDate = LocalDate.now(),
|
*/
|
||||||
val selectedDate: LocalDate = LocalDate.now(),
|
class CalendarViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
val events: Map<LocalDate, List<CalendarEvent>> = emptyMap(),
|
|
||||||
val predictions: CyclePrediction? = null,
|
|
||||||
val settings: CycleSettings = CycleSettings(),
|
|
||||||
val statistics: CycleStatistics? = null,
|
|
||||||
val showEventDialog: Boolean = false,
|
|
||||||
val showSettingsDialog: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
val lastRefreshed: Long = 0 // Время последнего успешного обновления данных
|
|
||||||
)
|
|
||||||
|
|
||||||
class CalendarViewModel : ViewModel() {
|
// Состояние UI
|
||||||
private val repository = ApiRepository()
|
private val _calendarUiState = MutableLiveData(CalendarUiState())
|
||||||
|
val calendarUiState: LiveData<CalendarUiState> = _calendarUiState
|
||||||
|
|
||||||
var uiState by mutableStateOf(CalendarUiState())
|
// Состояние загрузки
|
||||||
private set
|
private val _isLoading = MutableLiveData(false)
|
||||||
|
val isLoading: LiveData<Boolean> = _isLoading
|
||||||
|
|
||||||
private var loadJob: Job? = null
|
// Сообщения об ошибках
|
||||||
private var retryCount = 0
|
private val _error = MutableLiveData<String?>(null)
|
||||||
private val maxRetryCount = 5
|
val error: LiveData<String?> = _error
|
||||||
private val cacheValidityDuration = 30 * 60 * 1000 // 30 минут
|
|
||||||
private var debounceJob: Job? = null
|
|
||||||
|
|
||||||
// Для определения, инициализирован ли ViewModel уже
|
// Дата, выбранная пользователем
|
||||||
private var initialized = false
|
private val _selectedDate = MutableLiveData(LocalDate.now())
|
||||||
|
val selectedDate: LiveData<LocalDate> = _selectedDate
|
||||||
|
|
||||||
companion object {
|
// Репозиторий для работы с данными календаря
|
||||||
// Статические переменные для предотвращения одновременных запросов из разных экземпляров
|
private val repository: CalendarRepository
|
||||||
@Volatile
|
|
||||||
private var isRequestInProgress = false
|
// Месяц, просматриваемый пользователем
|
||||||
private var lastRefreshTimestamp = 0L
|
private var viewingMonth: LocalDate = LocalDate.now()
|
||||||
private const val GLOBAL_COOLDOWN = 2000L // Минимальный интервал между запросами (2 секунды)
|
|
||||||
// Максимальное количество неудачных запросов перед временным отключением
|
|
||||||
private const val MAX_ERROR_COUNT = 3
|
|
||||||
// Счетчик неудачных запросов
|
|
||||||
private var errorCount = 0
|
|
||||||
// Время последней ошибки
|
|
||||||
private var lastErrorTimestamp = 0L
|
|
||||||
// Интервал охлаждения при ошибках (увеличивается экспоненциально)
|
|
||||||
private var errorCooldownInterval = 5000L // Начинаем с 5 секунд
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
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 {
|
viewModelScope.launch {
|
||||||
delay(100L * (0..5).random()) // Случайная задержка до 500 мс
|
_isLoading.value = true
|
||||||
if (!initialized) {
|
|
||||||
initialized = true
|
|
||||||
loadInitialData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadInitialData() {
|
|
||||||
debounceJob?.cancel()
|
|
||||||
debounceJob = viewModelScope.launch {
|
|
||||||
delay(300) // Добавляем задержку для дебаунсинга
|
|
||||||
loadCalendarData()
|
|
||||||
loadCycleSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadCalendarData() {
|
|
||||||
// Если запрос уже выполняется глобально, не начинаем новый
|
|
||||||
if (isRequestInProgress) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Глобальная проверка интервала между запросами
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (now - lastRefreshTimestamp < GLOBAL_COOLDOWN) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка интервала охлаждения при ошибках
|
|
||||||
if (errorCount >= MAX_ERROR_COUNT && now - lastErrorTimestamp < errorCooldownInterval) {
|
|
||||||
uiState = uiState.copy(
|
|
||||||
error = "Временное ограничение запросов из-за ошибок. Повторите через ${(errorCooldownInterval / 1000).toInt()} секунд."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если данные были обновлены недавно и это не первая загрузка, не делаем запрос
|
|
||||||
if (uiState.events.isNotEmpty() &&
|
|
||||||
now - uiState.lastRefreshed < cacheValidityDuration) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отменяем предыдущий запрос и дебаунс, если они выполняются
|
|
||||||
loadJob?.cancel()
|
|
||||||
debounceJob?.cancel()
|
|
||||||
|
|
||||||
// Используем дебаунсинг для предотвращения частых запросов
|
|
||||||
debounceJob = viewModelScope.launch {
|
|
||||||
delay(300) // Задержка в 300 мс для дебаунсинга
|
|
||||||
|
|
||||||
loadJob = launch {
|
|
||||||
try {
|
try {
|
||||||
isRequestInProgress = true // Устанавливаем глобальный флаг запроса
|
// Получение записей из репозитория как Flow
|
||||||
lastRefreshTimestamp = System.currentTimeMillis()
|
repository.getCalendarEntriesFlow("userId").collect { entries: List<CalendarEntry> ->
|
||||||
|
updateCalendarStateWithEntries(entries)
|
||||||
uiState = uiState.copy(isLoading = true, error = null)
|
|
||||||
|
|
||||||
// Загружаем события календаря
|
|
||||||
val response = repository.getCalendarEntries()
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
// Сбрасываем счетчики ошибок при успехе
|
|
||||||
errorCount = 0
|
|
||||||
errorCooldownInterval = 5000L // Сбрасываем до начального значения
|
|
||||||
retryCount = 0
|
|
||||||
|
|
||||||
// Обрабатываем ответ API
|
|
||||||
response.body()?.let { calendarResponse ->
|
|
||||||
// Преобразуем API ответ в объекты домена
|
|
||||||
val events = processCalendarEntries(calendarResponse)
|
|
||||||
|
|
||||||
// Обновляем настройки и прогнозы из данных API
|
|
||||||
updateCycleInfoFromResponse(calendarResponse.cycle_info)
|
|
||||||
|
|
||||||
// Генерируем прогнозы на основе данных
|
|
||||||
generatePredictions()
|
|
||||||
|
|
||||||
// Рассчитываем статистику
|
|
||||||
calculateStatistics()
|
|
||||||
|
|
||||||
// Обновляем состояние UI
|
|
||||||
uiState = uiState.copy(
|
|
||||||
isLoading = false,
|
|
||||||
events = events,
|
|
||||||
lastRefreshed = System.currentTimeMillis(),
|
|
||||||
error = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (response.code() == 429) {
|
|
||||||
// Обработка Too Many Requests с экспоненциальным откатом
|
|
||||||
handleRateLimitExceeded()
|
|
||||||
} else {
|
|
||||||
// Увеличиваем счетчик ошибок
|
|
||||||
handleApiError(response.code().toString())
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
_error.value = "Ошибка при загрузке записей: ${e.localizedMessage}"
|
||||||
|
|
||||||
// Увеличиваем счетчик ошибок
|
|
||||||
handleApiError(e.message ?: "Неизвестная ошибка")
|
|
||||||
} finally {
|
} finally {
|
||||||
isRequestInProgress = false // Сбрасываем флаг в любом случае
|
_isLoading.value = false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleApiError(errorMsg: String) {
|
/**
|
||||||
errorCount++
|
* Загрузка данных о цикле
|
||||||
lastErrorTimestamp = System.currentTimeMillis()
|
*/
|
||||||
|
private fun loadCycleData() {
|
||||||
// Экспоненциально увеличиваем время ожидания при повторных ошибках
|
|
||||||
if (errorCount >= MAX_ERROR_COUNT) {
|
|
||||||
errorCooldownInterval = (errorCooldownInterval * 2).coerceAtMost(60000L) // максимум 1 минута
|
|
||||||
uiState = uiState.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = "Ошибка API: $errorMsg. Повторные запросы ограничены на ${errorCooldownInterval/1000} сек."
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
uiState = uiState.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = "Ошибка API: $errorMsg"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Преобразует API-ответ в события календаря
|
|
||||||
private fun processCalendarEntries(response: CalendarEntriesResponse): Map<LocalDate, List<CalendarEvent>> {
|
|
||||||
val result = mutableMapOf<LocalDate, MutableList<CalendarEvent>>()
|
|
||||||
|
|
||||||
response.entries.forEach { entry ->
|
|
||||||
try {
|
|
||||||
val date = LocalDate.parse(entry.date)
|
|
||||||
val event = CalendarEvent(
|
|
||||||
id = entry.id,
|
|
||||||
date = date,
|
|
||||||
type = CalendarEventType.valueOf(entry.type),
|
|
||||||
isActual = !entry.is_predicted,
|
|
||||||
isPredicted = entry.is_predicted,
|
|
||||||
mood = entry.mood?.let { MoodType.valueOf(it) },
|
|
||||||
symptoms = entry.symptoms.mapNotNull {
|
|
||||||
try { SymptomType.valueOf(it) } catch (e: Exception) { null }
|
|
||||||
},
|
|
||||||
notes = entry.notes ?: "",
|
|
||||||
flowIntensity = entry.flow_intensity
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.containsKey(date)) {
|
|
||||||
result[date]?.add(event)
|
|
||||||
} else {
|
|
||||||
result[date] = mutableListOf(event)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Пропускаем некорректные записи
|
|
||||||
println("Ошибка обработки записи календаря: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляет информацию о цикле из API-ответа
|
|
||||||
private fun updateCycleInfoFromResponse(cycleInfo: CycleInfoResponse) {
|
|
||||||
val lastPeriodStart = try {
|
|
||||||
cycleInfo.last_period_start?.let { LocalDate.parse(it) }
|
|
||||||
} catch (e: Exception) { null }
|
|
||||||
|
|
||||||
val newSettings = uiState.settings.copy(
|
|
||||||
averageCycleLength = cycleInfo.average_cycle_length,
|
|
||||||
averagePeriodLength = cycleInfo.average_period_length,
|
|
||||||
lastPeriodStart = lastPeriodStart
|
|
||||||
)
|
|
||||||
|
|
||||||
uiState = uiState.copy(settings = newSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun handleRateLimitExceeded() {
|
|
||||||
if (retryCount < maxRetryCount) {
|
|
||||||
retryCount++
|
|
||||||
|
|
||||||
// Экспоненциальная отсрочка: 2^попытка * 1000 мс (1с, 2с, 4с, 8с, 16с)
|
|
||||||
val delayTime = (2.0.pow(retryCount.toDouble()) * 1000).toLong()
|
|
||||||
|
|
||||||
uiState = uiState.copy(
|
|
||||||
error = "Слишком много запросов. Повторная попытка через ${delayTime/1000} сек..."
|
|
||||||
)
|
|
||||||
|
|
||||||
delay(delayTime)
|
|
||||||
loadCalendarData() // Повторная попытка после задержки
|
|
||||||
} else {
|
|
||||||
uiState = uiState.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = "Превышен лимит запросов. Попробуйте позже."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectDate(date: LocalDate) {
|
|
||||||
uiState = uiState.copy(selectedDate = date)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun changeMonth(direction: Int) {
|
|
||||||
val newMonth = uiState.currentMonth.plusMonths(direction.toLong())
|
|
||||||
uiState = uiState.copy(currentMonth = newMonth)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addEvent(
|
|
||||||
date: LocalDate,
|
|
||||||
type: CalendarEventType,
|
|
||||||
mood: MoodType? = null,
|
|
||||||
symptoms: List<SymptomType> = emptyList(),
|
|
||||||
notes: String = "",
|
|
||||||
flowIntensity: Int? = null
|
|
||||||
) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val event = CalendarEvent(
|
_isLoading.value = true
|
||||||
date = date,
|
|
||||||
type = type,
|
|
||||||
mood = mood,
|
|
||||||
symptoms = symptoms,
|
|
||||||
notes = notes,
|
|
||||||
flowIntensity = flowIntensity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Добавляем событие локально
|
|
||||||
val currentEvents = uiState.events.toMutableMap()
|
|
||||||
val dateEvents = currentEvents[date]?.toMutableList() ?: mutableListOf()
|
|
||||||
dateEvents.add(event)
|
|
||||||
currentEvents[date] = dateEvents
|
|
||||||
|
|
||||||
uiState = uiState.copy(events = currentEvents)
|
|
||||||
|
|
||||||
// Если это начало менструации, обновляем настройки и прогнозы
|
|
||||||
if (type == CalendarEventType.MENSTRUATION) {
|
|
||||||
updateLastPeriodDate(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Пересчитываем прогнозы и статистику
|
|
||||||
generatePredictions()
|
|
||||||
calculateStatistics()
|
|
||||||
|
|
||||||
// Сохраняем на сервер с дебаунсингом
|
|
||||||
debounceSaveEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Дебаунсинг для сохранения событий
|
|
||||||
private fun debounceSaveEvent(event: CalendarEvent) {
|
|
||||||
debounceJob?.cancel()
|
|
||||||
debounceJob = viewModelScope.launch {
|
|
||||||
delay(300) // Дебаунс 300 мс
|
|
||||||
saveEventToServer(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeEvent(date: LocalDate, eventType: CalendarEventType) {
|
|
||||||
val currentEvents = uiState.events.toMutableMap()
|
|
||||||
val dateEvents = currentEvents[date]?.toMutableList()
|
|
||||||
dateEvents?.removeAll { it.type == eventType }
|
|
||||||
|
|
||||||
if (dateEvents.isNullOrEmpty()) {
|
|
||||||
currentEvents.remove(date)
|
|
||||||
} else {
|
|
||||||
currentEvents[date] = dateEvents
|
|
||||||
}
|
|
||||||
|
|
||||||
uiState = uiState.copy(events = currentEvents)
|
|
||||||
|
|
||||||
// Пересчитываем прогнозы
|
|
||||||
generatePredictions()
|
|
||||||
calculateStatistics()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showEventDialog() {
|
|
||||||
uiState = uiState.copy(showEventDialog = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hideEventDialog() {
|
|
||||||
uiState = uiState.copy(showEventDialog = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showSettingsDialog() {
|
|
||||||
uiState = uiState.copy(showSettingsDialog = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hideSettingsDialog() {
|
|
||||||
uiState = uiState.copy(showSettingsDialog = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateCycleSettings(settings: CycleSettings) {
|
|
||||||
uiState = uiState.copy(settings = settings)
|
|
||||||
generatePredictions()
|
|
||||||
// TODO: Сохранить настройки на сервер с дебаунсингом
|
|
||||||
debounceSaveSettings(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun debounceSaveSettings(settings: CycleSettings) {
|
|
||||||
debounceJob?.cancel()
|
|
||||||
debounceJob = viewModelScope.launch {
|
|
||||||
delay(300) // Дебаунс 300 мс
|
|
||||||
saveCycleSettings(settings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateLastPeriodDate(date: LocalDate) {
|
|
||||||
val updatedSettings = uiState.settings.copy(lastPeriodStart = date)
|
|
||||||
uiState = uiState.copy(settings = updatedSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadCycleSettings() {
|
|
||||||
// TODO: Загрузить настройки с сервера
|
|
||||||
// Пока используем настройки по умолчанию
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generatePredictions() {
|
|
||||||
val settings = uiState.settings
|
|
||||||
val lastPeriodStart = settings.lastPeriodStart ?: return
|
|
||||||
|
|
||||||
// Прогнозируем следующую менструацию
|
|
||||||
val nextPeriodStart = lastPeriodStart.plusDays(settings.averageCycleLength.toLong())
|
|
||||||
val nextPeriodEnd = nextPeriodStart.plusDays(settings.averagePeriodLength.toLong() - 1)
|
|
||||||
|
|
||||||
// Прогнозируем овуляцию (примерно за 14 дней до следующих месячных)
|
|
||||||
val nextOvulation = nextPeriodStart.minusDays(14)
|
|
||||||
|
|
||||||
// Окно фертильности (5 дней до овуляции + день овуляции)
|
|
||||||
val fertileWindowStart = nextOvulation.minusDays(4)
|
|
||||||
val fertileWindowEnd = nextOvulation.plusDays(1)
|
|
||||||
|
|
||||||
val prediction = CyclePrediction(
|
|
||||||
nextPeriodStart = nextPeriodStart,
|
|
||||||
nextPeriodEnd = nextPeriodEnd,
|
|
||||||
nextOvulation = nextOvulation,
|
|
||||||
fertileWindowStart = fertileWindowStart,
|
|
||||||
fertileWindowEnd = fertileWindowEnd
|
|
||||||
)
|
|
||||||
|
|
||||||
uiState = uiState.copy(predictions = prediction)
|
|
||||||
|
|
||||||
// Добавляем прогнозные события в календарь
|
|
||||||
addPredictedEvents(prediction)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addPredictedEvents(prediction: CyclePrediction) {
|
|
||||||
val currentEvents = uiState.events.toMutableMap()
|
|
||||||
|
|
||||||
// Удаляем старые прогнозы
|
|
||||||
currentEvents.forEach { (date, events) ->
|
|
||||||
currentEvents[date] = events.filter { it.isActual }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем новые прогнозы
|
|
||||||
val predictedEvents = mutableListOf<Pair<LocalDate, CalendarEvent>>()
|
|
||||||
|
|
||||||
// Прогноз месячных
|
|
||||||
var date = prediction.nextPeriodStart
|
|
||||||
while (!date.isAfter(prediction.nextPeriodEnd)) {
|
|
||||||
predictedEvents.add(
|
|
||||||
date to CalendarEvent(
|
|
||||||
date = date,
|
|
||||||
type = CalendarEventType.PREDICTED_MENSTRUATION,
|
|
||||||
isActual = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
date = date.plusDays(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Прогноз овуляции
|
|
||||||
predictedEvents.add(
|
|
||||||
prediction.nextOvulation to CalendarEvent(
|
|
||||||
date = prediction.nextOvulation,
|
|
||||||
type = CalendarEventType.PREDICTED_OVULATION,
|
|
||||||
isActual = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Добавляем прогнозы в календарь
|
|
||||||
predictedEvents.forEach { (eventDate, event) ->
|
|
||||||
val dateEvents = currentEvents[eventDate]?.toMutableList() ?: mutableListOf()
|
|
||||||
dateEvents.add(event)
|
|
||||||
currentEvents[eventDate] = dateEvents
|
|
||||||
}
|
|
||||||
|
|
||||||
uiState = uiState.copy(events = currentEvents)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateStatistics() {
|
|
||||||
val menstruationEvents = uiState.events.values.flatten()
|
|
||||||
.filter { it.type == CalendarEventType.MENSTRUATION && it.isActual }
|
|
||||||
.sortedBy { it.date }
|
|
||||||
|
|
||||||
if (menstruationEvents.size < 2) return
|
|
||||||
|
|
||||||
// Вычисляем длины циклов
|
|
||||||
val cycleLengths = mutableListOf<Int>()
|
|
||||||
for (i in 1 until menstruationEvents.size) {
|
|
||||||
val cycleLength = ChronoUnit.DAYS.between(
|
|
||||||
menstruationEvents[i-1].date,
|
|
||||||
menstruationEvents[i].date
|
|
||||||
).toInt()
|
|
||||||
cycleLengths.add(cycleLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cycleLengths.isEmpty()) return
|
|
||||||
|
|
||||||
val averageLength = cycleLengths.average().toFloat()
|
|
||||||
val variation = cycleLengths.map { abs(it - averageLength) }.average().toFloat()
|
|
||||||
|
|
||||||
// Собираем частые симптомы
|
|
||||||
val allSymptoms = uiState.events.values.flatten()
|
|
||||||
.flatMap { it.symptoms }
|
|
||||||
val symptomFrequency = allSymptoms.groupingBy { it }.eachCount()
|
|
||||||
val commonSymptoms = symptomFrequency.toList()
|
|
||||||
.sortedByDescending { it.second }
|
|
||||||
.take(5)
|
|
||||||
.map { it.first }
|
|
||||||
|
|
||||||
val statistics = CycleStatistics(
|
|
||||||
averageCycleLength = averageLength,
|
|
||||||
cycleVariation = variation,
|
|
||||||
lastCycles = cycleLengths.takeLast(6),
|
|
||||||
periodLengthAverage = uiState.settings.averagePeriodLength.toFloat(),
|
|
||||||
commonSymptoms = commonSymptoms,
|
|
||||||
moodPatterns = emptyMap() // TODO: Вычислить паттерны настроения
|
|
||||||
)
|
|
||||||
|
|
||||||
uiState = uiState.copy(statistics = statistics)
|
|
||||||
|
|
||||||
// Обновляем настройки на основе статистики
|
|
||||||
if (cycleLengths.size >= 3) {
|
|
||||||
val newSettings = uiState.settings.copy(
|
|
||||||
averageCycleLength = averageLength.roundToInt()
|
|
||||||
)
|
|
||||||
uiState = uiState.copy(settings = newSettings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun saveEventToServer(event: CalendarEvent) {
|
|
||||||
try {
|
try {
|
||||||
// Преобразуем CalendarEvent в CalendarEntryCreate для API
|
// Получение данных о цикле из репозитория
|
||||||
val entryCreate = CalendarEntryCreate(
|
val cycleData = repository.getCycleData()
|
||||||
date = event.date.toString(),
|
updateCalendarStateWithCycleData(cycleData)
|
||||||
type = event.type.name,
|
|
||||||
mood = event.mood?.name,
|
|
||||||
symptoms = event.symptoms.map { it.name },
|
|
||||||
notes = event.notes.ifEmpty { null },
|
|
||||||
flow_intensity = event.flowIntensity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Отправляем данные на сервер
|
|
||||||
val response = repository.createCalendarEntry(entryCreate)
|
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
// Обновляем локальное событие с ID с сервера, если такой вернулся
|
|
||||||
response.body()?.let { serverEvent ->
|
|
||||||
val currentEvents = uiState.events.toMutableMap()
|
|
||||||
val dateEvents = currentEvents[event.date]?.toMutableList() ?: return@let
|
|
||||||
|
|
||||||
// Находим и заменяем событие, добавляя ему ID с сервера
|
|
||||||
val index = dateEvents.indexOfFirst {
|
|
||||||
it.type == event.type && it.date == event.date
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index != -1) {
|
|
||||||
dateEvents[index] = serverEvent
|
|
||||||
currentEvents[event.date] = dateEvents
|
|
||||||
uiState = uiState.copy(
|
|
||||||
events = currentEvents,
|
|
||||||
error = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Обработка ошибки API
|
|
||||||
uiState = uiState.copy(error = "Ошибка сохранения на сервере: ${response.code()}")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
uiState = uiState.copy(error = "Ошибка сохранения: ${e.message}")
|
_error.value = "Ошибка при загрузке данных цикла: ${e.localizedMessage}"
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun saveCycleSettings(settings: CycleSettings) {
|
/**
|
||||||
|
* Выбор даты пользователем
|
||||||
|
*/
|
||||||
|
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 {
|
try {
|
||||||
// TODO: Реализовать сохранение настроек на сервер через API
|
// Здесь должен быть вызов метода репозитория для удаления инсайта
|
||||||
|
// repository.dismissInsight(id)
|
||||||
|
|
||||||
|
// Обновляем список инсайтов, удаляя указанный
|
||||||
|
_calendarUiState.value = _calendarUiState.value?.copy(
|
||||||
|
insights = _calendarUiState.value?.insights?.filter { it.id != id } ?: emptyList()
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
uiState = uiState.copy(error = "Ошибка сохранения настроек: ${e.message}")
|
_error.value = "Ошибка при удалении инсайта: ${e.localizedMessage}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Очистка ошибки
|
/**
|
||||||
fun clearError() {
|
* Очистка сообщения об ошибке
|
||||||
uiState = uiState.copy(error = null)
|
*/
|
||||||
|
fun clearErrorMessage() {
|
||||||
|
_error.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Принудительное обновление данных
|
/**
|
||||||
fun forceRefresh() {
|
* Добавление новой записи календаря
|
||||||
uiState = uiState.copy(lastRefreshed = 0)
|
*/
|
||||||
retryCount = 0 // Сбрасываем счетчик попыток
|
fun addCalendarEntry(entry: CalendarEntry) {
|
||||||
loadCalendarData()
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
try {
|
||||||
|
repository.addCalendarEntry(entry)
|
||||||
|
// Обновляем данные после добавления
|
||||||
|
loadCalendarEntries()
|
||||||
|
_error.value = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Ошибка при добавлении записи: ${e.localizedMessage}"
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
/**
|
||||||
super.onCleared()
|
* Обновление существующей записи
|
||||||
loadJob?.cancel()
|
*/
|
||||||
debounceJob?.cancel()
|
fun updateCalendarEntry(entry: CalendarEntry) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
try {
|
||||||
|
repository.updateCalendarEntry(entry)
|
||||||
|
// Обновляем данные после изменения
|
||||||
|
loadCalendarEntries()
|
||||||
|
_error.value = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Ошибка при обновлении записи: ${e.localizedMessage}"
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление записи календаря
|
||||||
|
*/
|
||||||
|
fun deleteCalendarEntry(entryId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
try {
|
||||||
|
repository.deleteCalendarEntry(entryId)
|
||||||
|
// Обновляем данные после удаления
|
||||||
|
loadCalendarEntries()
|
||||||
|
_error.value = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Ошибка при удалении записи: ${e.localizedMessage}"
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс, представляющий состояние UI календаря
|
||||||
|
*/
|
||||||
|
data class CalendarUiState(
|
||||||
|
val entries: List<CalendarEntry> = emptyList(),
|
||||||
|
val selectedDateEntries: List<CalendarEntry> = emptyList(),
|
||||||
|
val cycleStartDate: LocalDate? = null,
|
||||||
|
val cycleData: CycleData? = null,
|
||||||
|
val specialDays: Map<LocalDate, DayType> = emptyMap(),
|
||||||
|
val avgCycleLength: Int? = null,
|
||||||
|
val avgPeriodLength: Int? = null,
|
||||||
|
val cycleRegularityScore: Int? = null,
|
||||||
|
val insights: List<HealthInsight> = emptyList(),
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val viewingMonth: LocalDate = LocalDate.now().withDayOfMonth(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типы дней в календаре
|
||||||
|
*/
|
||||||
|
enum class DayType {
|
||||||
|
PERIOD, // День менструации
|
||||||
|
OVULATION, // День овуляции
|
||||||
|
FERTILE, // Фертильный день
|
||||||
|
PREDICTED_PERIOD, // Прогноз дня менструации
|
||||||
|
NORMAL // Обычный день
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/src/main/java/com/example/womansafe/util/ContactsHelper.kt
Normal file
107
app/src/main/java/com/example/womansafe/util/ContactsHelper.kt
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package com.example.womansafe.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для работы с контактами устройства
|
||||||
|
*/
|
||||||
|
class ContactsHelper(private val context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс модели контакта
|
||||||
|
*/
|
||||||
|
data class Contact(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val phoneNumber: String,
|
||||||
|
val photoUri: Uri? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение списка контактов устройства
|
||||||
|
*/
|
||||||
|
suspend fun getContacts(): List<Contact> = withContext(Dispatchers.IO) {
|
||||||
|
val contacts = mutableListOf<Contact>()
|
||||||
|
|
||||||
|
val projection = arrayOf(
|
||||||
|
ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
|
||||||
|
ContactsContract.Contacts.DISPLAY_NAME,
|
||||||
|
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||||
|
ContactsContract.Contacts.PHOTO_URI
|
||||||
|
)
|
||||||
|
|
||||||
|
val cursor: Cursor? = context.contentResolver.query(
|
||||||
|
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||||
|
projection,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
ContactsContract.Contacts.DISPLAY_NAME + " ASC"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor?.use {
|
||||||
|
val idColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
|
||||||
|
val nameColumn = it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
|
||||||
|
val numberColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||||
|
val photoColumn = it.getColumnIndex(ContactsContract.Contacts.PHOTO_URI)
|
||||||
|
|
||||||
|
while (it.moveToNext()) {
|
||||||
|
val id = it.getString(idColumn)
|
||||||
|
val name = it.getString(nameColumn) ?: "Неизвестно"
|
||||||
|
val number = it.getString(numberColumn) ?: "Нет номера"
|
||||||
|
val photoUri = it.getString(photoColumn)?.let { uri -> Uri.parse(uri) }
|
||||||
|
|
||||||
|
contacts.add(Contact(id, name, number, photoUri))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext contacts.distinctBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение контакта по ID
|
||||||
|
*/
|
||||||
|
suspend fun getContactById(contactId: String): Contact? = withContext(Dispatchers.IO) {
|
||||||
|
val projection = arrayOf(
|
||||||
|
ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
|
||||||
|
ContactsContract.Contacts.DISPLAY_NAME,
|
||||||
|
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||||
|
ContactsContract.Contacts.PHOTO_URI
|
||||||
|
)
|
||||||
|
|
||||||
|
val selection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?"
|
||||||
|
val selectionArgs = arrayOf(contactId)
|
||||||
|
|
||||||
|
val cursor: Cursor? = context.contentResolver.query(
|
||||||
|
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||||
|
projection,
|
||||||
|
selection,
|
||||||
|
selectionArgs,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
var contact: Contact? = null
|
||||||
|
|
||||||
|
cursor?.use {
|
||||||
|
if (it.moveToFirst()) {
|
||||||
|
val idColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
|
||||||
|
val nameColumn = it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
|
||||||
|
val numberColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||||
|
val photoColumn = it.getColumnIndex(ContactsContract.Contacts.PHOTO_URI)
|
||||||
|
|
||||||
|
val id = it.getString(idColumn)
|
||||||
|
val name = it.getString(nameColumn) ?: "Неизвестно"
|
||||||
|
val number = it.getString(numberColumn) ?: "Нет номера"
|
||||||
|
val photoUri = it.getString(photoColumn)?.let { uri -> Uri.parse(uri) }
|
||||||
|
|
||||||
|
contact = Contact(id, name, number, photoUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext contact
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.example.womansafe.util
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для управления разрешениями в приложении
|
||||||
|
*/
|
||||||
|
class PermissionManager(private val context: Context) {
|
||||||
|
|
||||||
|
private val preferenceManager = PreferenceManager.getInstance(context)
|
||||||
|
|
||||||
|
// Проверка разрешения
|
||||||
|
fun isPermissionGranted(permission: String): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
permission
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение статуса разрешения
|
||||||
|
fun savePermissionStatus(permission: String, granted: Boolean) {
|
||||||
|
preferenceManager.savePermissionGranted(permission, granted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение сохраненного статуса разрешения
|
||||||
|
fun getSavedPermissionStatus(permission: String): Boolean {
|
||||||
|
return preferenceManager.isPermissionGranted(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение списка необходимых разрешений
|
||||||
|
fun getRequiredPermissions(): List<String> {
|
||||||
|
return listOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||||
|
Manifest.permission.CAMERA,
|
||||||
|
Manifest.permission.READ_CONTACTS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение непредоставленных разрешений
|
||||||
|
fun getMissingPermissions(): List<String> {
|
||||||
|
return getRequiredPermissions().filter { !isPermissionGranted(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие настроек приложения для управления разрешениями
|
||||||
|
fun openAppSettings() {
|
||||||
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||||
|
val uri = Uri.fromParts("package", context.packageName, null)
|
||||||
|
intent.data = uri
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Разрешения
|
||||||
|
const val PERMISSION_LOCATION = Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
const val PERMISSION_CAMERA = Manifest.permission.CAMERA
|
||||||
|
const val PERMISSION_CONTACTS = Manifest.permission.READ_CONTACTS
|
||||||
|
|
||||||
|
// Синглтон для доступа к PermissionManager
|
||||||
|
@Volatile private var INSTANCE: PermissionManager? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): PermissionManager {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: PermissionManager(context).also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable для запроса разрешений
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RequestPermissions(
|
||||||
|
permissions: List<String>,
|
||||||
|
onAllPermissionsGranted: () -> Unit = {},
|
||||||
|
onPermissionDenied: (List<String>) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val permissionManager = remember { PermissionManager.getInstance(context) }
|
||||||
|
|
||||||
|
// Проверка всех разрешений
|
||||||
|
val allPermissionsGranted = permissions.all {
|
||||||
|
permissionManager.isPermissionGranted(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрашиваем разрешения, если они не предоставлены
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { permissionsMap ->
|
||||||
|
// Сохраняем статусы разрешений
|
||||||
|
permissionsMap.forEach { (permission, granted) ->
|
||||||
|
permissionManager.savePermissionStatus(permission, granted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, все ли разрешения предоставлены
|
||||||
|
val deniedPermissions = permissionsMap.filterValues { !it }.keys.toList()
|
||||||
|
if (deniedPermissions.isEmpty()) {
|
||||||
|
onAllPermissionsGranted()
|
||||||
|
} else {
|
||||||
|
onPermissionDenied(deniedPermissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрашиваем необходимые разрешения при первом запуске composable
|
||||||
|
LaunchedEffect(permissions) {
|
||||||
|
if (!allPermissionsGranted) {
|
||||||
|
launcher.launch(permissions.toTypedArray())
|
||||||
|
} else {
|
||||||
|
onAllPermissionsGranted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.example.womansafe.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс для управления безопасным хранением данных пользователя
|
||||||
|
*/
|
||||||
|
class PreferenceManager(context: Context) {
|
||||||
|
|
||||||
|
private val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val sharedPreferences: SharedPreferences = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
PREFERENCE_NAME,
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сохранение токена
|
||||||
|
fun saveAuthToken(token: String?) {
|
||||||
|
sharedPreferences.edit().putString(KEY_AUTH_TOKEN, token).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение токена
|
||||||
|
fun getAuthToken(): String? {
|
||||||
|
return sharedPreferences.getString(KEY_AUTH_TOKEN, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение разрешений
|
||||||
|
fun savePermissionGranted(permission: String, granted: Boolean) {
|
||||||
|
sharedPreferences.edit().putBoolean(permission, granted).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение статуса разрешения
|
||||||
|
fun isPermissionGranted(permission: String): Boolean {
|
||||||
|
return sharedPreferences.getBoolean(permission, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка данных при выходе
|
||||||
|
fun clearAuthData() {
|
||||||
|
sharedPreferences.edit().remove(KEY_AUTH_TOKEN).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFERENCE_NAME = "woman_safe_prefs"
|
||||||
|
private const val KEY_AUTH_TOKEN = "auth_token"
|
||||||
|
|
||||||
|
// Ключи для отслеживания разрешений
|
||||||
|
const val PERMISSION_LOCATION = "permission_location"
|
||||||
|
const val PERMISSION_CAMERA = "permission_camera"
|
||||||
|
const val PERMISSION_CONTACTS = "permission_contacts"
|
||||||
|
|
||||||
|
// Синглтон для доступа к PreferenceManager
|
||||||
|
@Volatile private var INSTANCE: PreferenceManager? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): PreferenceManager {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: PreferenceManager(context.applicationContext).also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.example.womansafe.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модификатор, который исправляет проблему с ACTION_HOVER_EXIT в Jetpack Compose
|
||||||
|
*/
|
||||||
|
fun Modifier.fixTouchEvents(): Modifier = composed {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
|
this.pointerInput(interactionSource) {
|
||||||
|
awaitEachGesture {
|
||||||
|
// Просто ждем первое нажатие и отпускание, чтобы гарантировать правильную очистку событий
|
||||||
|
awaitFirstDown(requireUnconsumed = false)
|
||||||
|
waitForUpOrCancellation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/src/main/java/com/example/womansafe/utils/DateUtils.kt
Normal file
59
app/src/main/java/com/example/womansafe/utils/DateUtils.kt
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package com.example.womansafe.utils
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вспомогательный класс для работы с датами
|
||||||
|
*/
|
||||||
|
object DateUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматтер для отображения дат в удобном формате
|
||||||
|
*/
|
||||||
|
val READABLE_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматтер для отображения месяца и года
|
||||||
|
*/
|
||||||
|
val MONTH_YEAR_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MMMM yyyy")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, находится ли дата в указанном диапазоне (включая начало и конец)
|
||||||
|
*/
|
||||||
|
fun isDateInRange(date: LocalDate, start: LocalDate?, end: LocalDate?): Boolean {
|
||||||
|
if (start == null || end == null) return false
|
||||||
|
return (date.isEqual(start) || date.isAfter(start)) &&
|
||||||
|
(date.isEqual(end) || date.isBefore(end))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вычисляет количество дней между двумя датами
|
||||||
|
*/
|
||||||
|
fun daysBetween(start: LocalDate, end: LocalDate): Long {
|
||||||
|
return ChronoUnit.DAYS.between(start, end).absoluteValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, является ли дата сегодняшней
|
||||||
|
*/
|
||||||
|
fun isToday(date: LocalDate): Boolean {
|
||||||
|
return date.isEqual(LocalDate.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует дату в читаемый формат
|
||||||
|
*/
|
||||||
|
fun formatReadableDate(date: LocalDate?): String {
|
||||||
|
return date?.format(READABLE_DATE_FORMATTER) ?: "Нет данных"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует месяц и год
|
||||||
|
*/
|
||||||
|
fun formatMonthYear(date: LocalDate): String {
|
||||||
|
return date.format(MONTH_YEAR_FORMATTER)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,4 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.13.0"
|
agp = "8.13.0"
|
||||||
kotlin = "2.0.21"
|
kotlin = "1.9.20"
|
||||||
coreKtx = "1.10.1"
|
coreKtx = "1.10.1"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.1.5"
|
junitVersion = "1.1.5"
|
||||||
espressoCore = "3.5.1"
|
espressoCore = "3.5.1"
|
||||||
lifecycleRuntimeKtx = "2.6.1"
|
lifecycleRuntimeKtx = "2.6.1"
|
||||||
activityCompose = "1.8.0"
|
activityCompose = "1.8.0"
|
||||||
composeBom = "2024.09.00"
|
composeBom = "2023.08.00"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user