UI refactor
This commit is contained in:
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@@ -4,10 +4,10 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<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">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/home/trevor/.android/avd/Medium_Phone.avd" />
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=LGMG600S9b4da66b" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
id("com.google.devtools.ksp") version "1.9.20-1.0.14"
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -19,6 +19,11 @@ android {
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
// Включаем десугаринг для поддержки Java 8 API на старых устройствах
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -41,7 +46,7 @@ android {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.1"
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
@@ -60,6 +65,16 @@ dependencies {
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
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
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
|
||||
@@ -78,6 +93,18 @@ dependencies {
|
||||
// Permissions
|
||||
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
|
||||
|
||||
// Coil для загрузки изображений
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
|
||||
// Room Database
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
annotationProcessor("androidx.room:room-compiler:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
|
||||
// LiveData
|
||||
implementation("androidx.compose.runtime:runtime-livedata")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
@@ -86,4 +113,7 @@ dependencies {
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
|
||||
// Десугаринг для поддержки Java 8 API (включая java.time) на Android API 24 и ниже
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.MainScreen
|
||||
import com.example.womansafe.ui.theme.WomanSafeTheme
|
||||
@@ -19,6 +21,10 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Инициализируем NetworkClient для работы с сохраненным токеном
|
||||
NetworkClient.initialize(applicationContext)
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
WomanSafeTheme {
|
||||
@@ -26,6 +32,14 @@ class MainActivity : ComponentActivity() {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
// Проверяем сохраненный токен и пытаемся выполнить автоматический вход
|
||||
LaunchedEffect(Unit) {
|
||||
NetworkClient.getAuthToken()?.let { token ->
|
||||
// Если токен существует, пытаемся выполнить автоматический вход
|
||||
authViewModel.autoLogin(token)
|
||||
}
|
||||
}
|
||||
|
||||
// Показываем либо экран авторизации, либо главный экран
|
||||
if (authViewModel.uiState.isLoggedIn) {
|
||||
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}")
|
||||
suspend fun deleteSafePlace(@Path("place_id") placeId: String): Response<Any>
|
||||
|
||||
// Calendar endpoints
|
||||
// Календарь и отслеживание цикла
|
||||
@GET("api/v1/calendar/cycle-data")
|
||||
suspend fun getCycleData(): Response<CycleData>
|
||||
|
||||
@GET("api/v1/calendar/entries")
|
||||
suspend fun getCalendarEntries(): Response<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")
|
||||
suspend fun createCalendarEntry(@Body entry: CalendarEntryCreate): Response<CalendarEvent>
|
||||
|
||||
@GET("api/v1/calendar/entries/{entry_id}")
|
||||
suspend fun getCalendarEntry(@Path("entry_id") entryId: String): Response<CalendarEvent>
|
||||
@POST("api/v1/calendar/entries")
|
||||
suspend fun createCalendarEntry(@Body entry: CalendarEntryRequest): Response<CalendarEvent>
|
||||
|
||||
@PUT("api/v1/calendar/entries/{entry_id}")
|
||||
suspend fun updateCalendarEntry(@Path("entry_id") entryId: String, @Body entry: CalendarEntryUpdate): Response<CalendarEvent>
|
||||
suspend fun updateCalendarEntry(
|
||||
@Path("entry_id") entryId: String,
|
||||
@Body entry: CalendarEntryRequest
|
||||
): Response<CalendarEvent>
|
||||
|
||||
@DELETE("api/v1/calendar/entries/{entry_id}")
|
||||
suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response<Any>
|
||||
suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response<Unit>
|
||||
|
||||
@GET("api/v1/calendar/cycle-overview")
|
||||
suspend fun getCycleOverview(): Response<Any>
|
||||
@GET("api/v1/calendar/statistics")
|
||||
suspend fun getCycleStatistics(): Response<CycleStatistics>
|
||||
|
||||
@GET("api/v1/calendar/predictions")
|
||||
suspend fun getCyclePredictions(): Response<CyclePrediction>
|
||||
|
||||
@GET("api/v1/calendar/insights")
|
||||
suspend fun getCalendarInsights(): Response<Any>
|
||||
suspend fun getHealthInsights(): Response<List<HealthInsight>>
|
||||
|
||||
@GET("api/v1/calendar/reminders")
|
||||
suspend fun getCalendarReminders(): Response<Any>
|
||||
|
||||
@POST("api/v1/calendar/reminders")
|
||||
suspend fun createCalendarReminder(): Response<Any>
|
||||
|
||||
@GET("api/v1/calendar/settings")
|
||||
suspend fun getCalendarSettings(): Response<Any>
|
||||
|
||||
@PUT("api/v1/calendar/settings")
|
||||
suspend fun updateCalendarSettings(): Response<Any>
|
||||
@PATCH("api/v1/calendar/insights/{insight_id}/dismiss")
|
||||
suspend fun dismissInsight(@Path("insight_id") insightId: String): Response<HealthInsight>
|
||||
|
||||
// Notification endpoints
|
||||
@GET("api/v1/notifications/devices")
|
||||
|
||||
@@ -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
|
||||
|
||||
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
// Request body wrapper for API Gateway proxy endpoints
|
||||
@@ -249,17 +250,6 @@ data class NearbyUser(
|
||||
)
|
||||
|
||||
// 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
|
||||
val id: Int,
|
||||
val title: String,
|
||||
|
||||
@@ -34,9 +34,9 @@ enum class SymptomType {
|
||||
NAUSEA // Тошнота
|
||||
}
|
||||
|
||||
// Событие календаря
|
||||
// Событие календаря (доменная модель)
|
||||
data class CalendarEvent(
|
||||
val id: String? = null, // Изменено с Int? на String?, так как в API используются UUID
|
||||
val id: String? = null,
|
||||
val date: LocalDate,
|
||||
val type: CalendarEventType,
|
||||
val isActual: Boolean = true, // true - фактическое, false - прогноз
|
||||
@@ -46,7 +46,24 @@ data class CalendarEvent(
|
||||
val flowIntensity: Int? = null, // Интенсивность выделений 1-5
|
||||
val createdAt: 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 // За сколько дней напоминать о приближающемся цикле
|
||||
)
|
||||
|
||||
// Прогноз цикла
|
||||
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
|
||||
|
||||
// Запрос на создание события в календаре
|
||||
@@ -133,3 +130,86 @@ data class CalendarEntriesResponse(
|
||||
val entries: List<CalendarEntryResponse>,
|
||||
val cycle_info: CycleInfoResponse
|
||||
)
|
||||
|
||||
// Extension функции для преобразования API моделей в доменные
|
||||
fun CalendarEventApiResponse.toDomainModel(): CalendarEvent {
|
||||
println("=== Преобразование API модели в доменную ===")
|
||||
println("API данные: entry_date=${this.entry_date}, entry_type=${this.entry_type}, symptoms=${this.symptoms}")
|
||||
|
||||
val calendarEvent = CalendarEvent(
|
||||
id = this.uuid,
|
||||
date = LocalDate.parse(this.entry_date),
|
||||
type = mapApiTypeToCalendarEventType(this.entry_type),
|
||||
isActual = !this.is_predicted,
|
||||
mood = this.mood?.let { mapApiMoodToMoodType(it) },
|
||||
symptoms = parseApiSymptoms(this.symptoms),
|
||||
notes = this.notes ?: "",
|
||||
flowIntensity = mapApiFlowIntensityToInt(this.flow_intensity),
|
||||
isPredicted = this.is_predicted,
|
||||
createdAt = this.created_at?.let {
|
||||
try {
|
||||
LocalDate.parse(it.substring(0, 10))
|
||||
} catch (e: Exception) {
|
||||
LocalDate.now()
|
||||
}
|
||||
} ?: LocalDate.now(),
|
||||
updatedAt = this.updated_at?.let {
|
||||
try {
|
||||
LocalDate.parse(it.substring(0, 10))
|
||||
} catch (e: Exception) {
|
||||
LocalDate.now()
|
||||
}
|
||||
} ?: LocalDate.now()
|
||||
)
|
||||
|
||||
println("Доменная модель: id=${calendarEvent.id}, date=${calendarEvent.date}, type=${calendarEvent.type}")
|
||||
println("=============================================")
|
||||
|
||||
return calendarEvent
|
||||
}
|
||||
|
||||
// Вспомогательные функции для маппинга
|
||||
private fun mapApiTypeToCalendarEventType(apiType: String): CalendarEventType {
|
||||
return when (apiType.lowercase()) {
|
||||
"period" -> CalendarEventType.MENSTRUATION
|
||||
"ovulation" -> CalendarEventType.OVULATION
|
||||
"fertile_window" -> CalendarEventType.FERTILE_WINDOW
|
||||
"predicted_period" -> CalendarEventType.PREDICTED_MENSTRUATION
|
||||
"predicted_ovulation" -> CalendarEventType.PREDICTED_OVULATION
|
||||
else -> CalendarEventType.MENSTRUATION // по умолчанию
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapApiMoodToMoodType(apiMood: String): MoodType {
|
||||
return when (apiMood.uppercase()) {
|
||||
"EXCELLENT" -> MoodType.EXCELLENT
|
||||
"GOOD" -> MoodType.GOOD
|
||||
"NORMAL" -> MoodType.NORMAL
|
||||
"BAD" -> MoodType.BAD
|
||||
"TERRIBLE" -> MoodType.TERRIBLE
|
||||
else -> MoodType.NORMAL // по умолчанию
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseApiSymptoms(symptomsString: String): List<SymptomType> {
|
||||
if (symptomsString.isBlank()) return emptyList()
|
||||
|
||||
return symptomsString.split(",")
|
||||
.map { it.trim() }
|
||||
.mapNotNull { symptom ->
|
||||
try {
|
||||
SymptomType.valueOf(symptom.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null // Игнорируем неизвестные симптомы
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapApiFlowIntensityToInt(apiIntensity: String?): Int? {
|
||||
return when (apiIntensity?.lowercase()) {
|
||||
"light" -> 2
|
||||
"medium" -> 3
|
||||
"heavy" -> 5
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.example.womansafe.data.model
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||
import com.example.womansafe.data.model.calendar.EntryType
|
||||
import com.example.womansafe.data.model.calendar.FlowIntensity
|
||||
import com.example.womansafe.data.model.calendar.Symptom
|
||||
import com.example.womansafe.data.model.calendar.Mood
|
||||
|
||||
/**
|
||||
* Модель данных цикла
|
||||
*/
|
||||
data class CycleData(
|
||||
val user_id: Int,
|
||||
val cycle_start_date: LocalDate,
|
||||
val cycle_length: Int,
|
||||
val period_length: Int,
|
||||
val ovulation_date: LocalDate,
|
||||
val fertile_window_start: LocalDate,
|
||||
val fertile_window_end: LocalDate,
|
||||
val next_period_predicted: LocalDate,
|
||||
val cycle_regularity_score: Int, // 1-100
|
||||
val avg_cycle_length: Float,
|
||||
val avg_period_length: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель для аналитики здоровья и инсайтов
|
||||
*/
|
||||
data class HealthInsight(
|
||||
val id: Int,
|
||||
val user_id: Int,
|
||||
val insight_type: InsightType,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val recommendation: String,
|
||||
val confidence_level: ConfidenceLevel,
|
||||
val data_points_used: Int,
|
||||
val is_dismissed: Boolean = false,
|
||||
val created_at: LocalDateTime? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Типы записей в календаре
|
||||
*/
|
||||
|
||||
/**
|
||||
* Интенсивность менструации
|
||||
*/
|
||||
|
||||
/**
|
||||
* Настроение
|
||||
*/
|
||||
|
||||
/**
|
||||
* Симптомы
|
||||
*/
|
||||
|
||||
/**
|
||||
* Типы инсайтов
|
||||
*/
|
||||
enum class InsightType {
|
||||
CYCLE_IRREGULARITY,
|
||||
PERIOD_LENGTH_CHANGE,
|
||||
SYMPTOM_PATTERN,
|
||||
HEALTH_RECOMMENDATION,
|
||||
OVULATION_PREDICTION,
|
||||
LIFESTYLE_IMPACT
|
||||
}
|
||||
|
||||
/**
|
||||
* Уровни достоверности инсайтов
|
||||
*/
|
||||
enum class ConfidenceLevel {
|
||||
LOW,
|
||||
MEDIUM,
|
||||
HIGH
|
||||
}
|
||||
|
||||
/**
|
||||
* Модель для запроса на создание записи в календаре
|
||||
*/
|
||||
data class CalendarEntryRequest(
|
||||
val entry_date: String,
|
||||
val entry_type: String,
|
||||
val flow_intensity: String? = null,
|
||||
val period_symptoms: List<String>? = null,
|
||||
val mood: String? = null,
|
||||
val energy_level: Int? = null,
|
||||
val sleep_hours: Float? = null,
|
||||
val symptoms: List<String>? = null,
|
||||
val medications: List<String>? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель для статистики цикла
|
||||
*/
|
||||
data class CycleStatistics(
|
||||
val average_cycle_length: Float,
|
||||
val cycle_length_variation: Float,
|
||||
val average_period_length: Float,
|
||||
val cycle_regularity: Int,
|
||||
val cycle_history: List<CycleHistoryItem>
|
||||
)
|
||||
|
||||
/**
|
||||
* Элемент истории циклов для отображения графика
|
||||
*/
|
||||
data class CycleHistoryItem(
|
||||
val start_date: LocalDate,
|
||||
val end_date: LocalDate,
|
||||
val cycle_length: Int,
|
||||
val period_length: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Модель для прогноза цикла
|
||||
*/
|
||||
data class CyclePrediction(
|
||||
val next_period_start: LocalDate,
|
||||
val next_period_end: LocalDate,
|
||||
val next_ovulation: LocalDate,
|
||||
val fertile_window_start: LocalDate,
|
||||
val fertile_window_end: LocalDate,
|
||||
val confidence: Float = 0.85f
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.LocalDate
|
||||
|
||||
@Entity(tableName = "calendar_entries")
|
||||
data class CalendarEntry(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val userId: String,
|
||||
val entryDate: LocalDate,
|
||||
val entryType: EntryType,
|
||||
val flowIntensity: FlowIntensity? = null,
|
||||
val periodSymptoms: List<Symptom>? = null,
|
||||
val mood: Mood? = null,
|
||||
val energyLevel: Int? = null, // 1-5
|
||||
val sleepHours: Float? = null,
|
||||
val symptoms: List<Symptom>? = null,
|
||||
val medications: List<String>? = null,
|
||||
val notes: String? = null,
|
||||
val isPredicted: Boolean = false,
|
||||
val confidenceScore: Int? = null, // 1-100
|
||||
val syncTimestamp: Long = System.currentTimeMillis(),
|
||||
val entryId: Long
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Запрос на создание/обновление записи календаря
|
||||
*/
|
||||
data class CalendarEntryRequest(
|
||||
val entry_date: String,
|
||||
val entry_type: String,
|
||||
val flow_intensity: String? = null,
|
||||
val mood: String? = null,
|
||||
val symptoms: List<String>? = null,
|
||||
val period_symptoms: List<String>? = null,
|
||||
val medications: List<String>? = null,
|
||||
val notes: String? = null,
|
||||
val energy_level: Int? = null,
|
||||
val sleep_hours: Float? = null
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Модель события календаря, используемая в улучшенном календарном интерфейсе
|
||||
*/
|
||||
@Entity(tableName = "calendar_events")
|
||||
data class CalendarEvent(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val userId: String,
|
||||
val date: LocalDate,
|
||||
val type: String, // period, ovulation, symptoms, medication, note, appointment
|
||||
val flowIntensity: String? = null,
|
||||
val mood: String? = null,
|
||||
val energyLevel: Int? = null,
|
||||
val sleepHours: Float? = null,
|
||||
val symptoms: List<String>? = null,
|
||||
val medications: List<String>? = null,
|
||||
val notes: String? = null,
|
||||
val isDismissed: Boolean = false,
|
||||
val syncTimestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Уровень уверенности в прогнозе или инсайте
|
||||
*/
|
||||
enum class ConfidenceLevel {
|
||||
LOW, // Низкая уверенность
|
||||
MEDIUM, // Средняя уверенность
|
||||
HIGH // Высокая уверенность
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Данные о текущем менструальном цикле
|
||||
*/
|
||||
@Entity(tableName = "cycle_data")
|
||||
data class CycleData(
|
||||
@PrimaryKey
|
||||
val userId: String,
|
||||
val periodStart: LocalDate?,
|
||||
val periodEnd: LocalDate?,
|
||||
val ovulationDate: LocalDate?,
|
||||
val fertileWindowStart: LocalDate?,
|
||||
val fertileWindowEnd: LocalDate?,
|
||||
val cycleLength: Int = 28,
|
||||
val periodLength: Int = 5,
|
||||
val nextPeriodPredicted: LocalDate? = null,
|
||||
val lastUpdated: Long = System.currentTimeMillis(),
|
||||
// Добавленные поля для совместимости с кодом
|
||||
val lastPeriodStartDate: LocalDate? = periodStart,
|
||||
val averageCycleLength: Int = cycleLength,
|
||||
val averagePeriodLength: Int = periodLength,
|
||||
val regularityScore: Int = 0, // 0-100
|
||||
val cycleStartDate: LocalDate? = null
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Модель прогноза менструального цикла
|
||||
*/
|
||||
data class CyclePrediction(
|
||||
val userId: String,
|
||||
val nextPeriodStart: LocalDate,
|
||||
val nextPeriodEnd: LocalDate,
|
||||
val nextOvulation: LocalDate,
|
||||
val fertileWindowStart: LocalDate,
|
||||
val fertileWindowEnd: LocalDate,
|
||||
val confidenceScore: Int, // 1-100
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Модель статистики менструального цикла
|
||||
*/
|
||||
data class CycleStatistics(
|
||||
val userId: String,
|
||||
val averageCycleLength: Int,
|
||||
val averagePeriodLength: Int,
|
||||
val shortestCycle: Int,
|
||||
val longestCycle: Int,
|
||||
val cycleLengthVariation: Int,
|
||||
val regularityScore: Int, // 1-100
|
||||
val dataPointsCount: Int,
|
||||
val lastUpdated: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Типы записей в календаре женского здоровья
|
||||
*/
|
||||
enum class EntryType {
|
||||
PERIOD, // Менструация
|
||||
OVULATION, // Овуляция
|
||||
SYMPTOMS, // Симптомы
|
||||
MEDICATION, // Лекарства
|
||||
NOTE, // Заметка
|
||||
APPOINTMENT // Приём у врача
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Интенсивность менструального кровотечения
|
||||
*/
|
||||
enum class FlowIntensity {
|
||||
SPOTTING, // Мажущие выделения
|
||||
LIGHT, // Легкие
|
||||
MEDIUM, // Средние
|
||||
HEAVY, // Сильные
|
||||
VERY_HEAVY // Очень сильные
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Модель медицинских инсайтов на основе данных календаря
|
||||
*/
|
||||
@Entity(tableName = "health_insights")
|
||||
data class HealthInsight(
|
||||
@PrimaryKey
|
||||
val id: Long,
|
||||
val userId: String,
|
||||
val insightType: InsightType,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val confidenceLevel: ConfidenceLevel,
|
||||
val recommendation: String,
|
||||
val dataPointsUsed: Int,
|
||||
val isDismissed: Boolean = false,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Типы инсайтов о женском здоровье
|
||||
*/
|
||||
enum class InsightType {
|
||||
CYCLE_REGULARITY, // Регулярность цикла
|
||||
PERIOD_LENGTH, // Продолжительность менструации
|
||||
SYMPTOM_PATTERN, // Закономерности в симптомах
|
||||
LIFESTYLE_IMPACT, // Влияние образа жизни
|
||||
MEDICATION_EFFECTIVENESS, // Эффективность лекарств
|
||||
MOOD_CORRELATION, // Корреляции настроения
|
||||
PERIOD_PREDICTION, // Прогноз менструации
|
||||
HEALTH_TIP, // Совет по здоровью
|
||||
MEDICATION_REMINDER, // Напоминание о приеме лекарств
|
||||
EXERCISE_SUGGESTION // Рекомендации по физическим упражнениям
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Расширенный набор настроений для дополненного календаря
|
||||
*/
|
||||
enum class Mood {
|
||||
VERY_HAPPY, // Очень счастливое
|
||||
HAPPY, // Счастливое
|
||||
NEUTRAL, // Нейтральное
|
||||
NORMAL, // Обычное (для совместимости)
|
||||
SAD, // Грустное
|
||||
VERY_SAD, // Очень грустное
|
||||
ANXIOUS, // Тревожное
|
||||
IRRITATED, // Раздраженное (для совместимости)
|
||||
IRRITABLE, // Раздражительное
|
||||
SENSITIVE, // Чувствительное
|
||||
CALM, // Спокойное
|
||||
ENERGETIC, // Энергичное
|
||||
TIRED // Усталое
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Состояние кожи в менструальном календаре
|
||||
*/
|
||||
enum class SkinCondition {
|
||||
NORMAL, // Нормальное состояние кожи
|
||||
IRRITATED, // Раздраженная кожа
|
||||
SENSITIVE, // Чувствительная кожа
|
||||
DRY, // Сухая кожа
|
||||
OILY, // Жирная кожа
|
||||
ACNE, // Высыпания/акне
|
||||
REDNESS // Покраснения
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.example.womansafe.data.model.calendar
|
||||
|
||||
/**
|
||||
* Расширенный набор симптомов для женского здоровья
|
||||
*/
|
||||
enum class Symptom {
|
||||
CRAMPS, // Спазмы/боли
|
||||
HEADACHE, // Головная боль
|
||||
BACKACHE, // Боли в спине
|
||||
NAUSEA, // Тошнота
|
||||
FATIGUE, // Усталость
|
||||
BLOATING, // Вздутие
|
||||
BREAST_TENDERNESS, // Чувствительность груди
|
||||
ACNE, // Высыпания
|
||||
MOOD_SWINGS, // Перепады настроения
|
||||
CRAVINGS, // Тяга к еде
|
||||
INSOMNIA, // Бессонница
|
||||
DIZZINESS, // Головокружение
|
||||
CONSTIPATION, // Запоры
|
||||
DIARRHEA, // Диарея
|
||||
HOT_FLASHES, // Приливы
|
||||
SPOTTING // Мажущие выделения
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.example.womansafe.data.network
|
||||
|
||||
import android.content.Context
|
||||
import com.example.womansafe.data.api.WomanSafeApi
|
||||
import com.example.womansafe.util.PreferenceManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
@@ -11,6 +13,30 @@ import java.util.concurrent.TimeUnit
|
||||
object NetworkClient {
|
||||
private var BASE_URL = "http://192.168.0.112:8000/"
|
||||
private var authToken: String? = null
|
||||
private lateinit var preferenceManager: PreferenceManager
|
||||
|
||||
// Метод для получения экземпляра клиента Retrofit
|
||||
fun getClient(): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
||||
// Метод для получения ID пользователя из токена
|
||||
fun getUserId(): String? {
|
||||
// Заглушка для метода - в реальном приложении здесь должна быть логика получения ID из токена
|
||||
return "user123"
|
||||
}
|
||||
|
||||
// Инициализация клиента с контекстом приложения
|
||||
fun initialize(context: Context) {
|
||||
preferenceManager = PreferenceManager.getInstance(context)
|
||||
// Загружаем сохраненный токен при инициализации
|
||||
authToken = preferenceManager.getAuthToken()
|
||||
println("NetworkClient initialized with token: ${authToken?.take(10)}...")
|
||||
}
|
||||
|
||||
private val authInterceptor = Interceptor { chain ->
|
||||
val requestBuilder = chain.request().newBuilder()
|
||||
@@ -23,7 +49,14 @@ object NetworkClient {
|
||||
println("=== API Request Debug ===")
|
||||
println("URL: ${request.url}")
|
||||
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)
|
||||
println("Response Code: ${response.code}")
|
||||
@@ -56,9 +89,32 @@ object NetworkClient {
|
||||
|
||||
fun setAuthToken(token: String?) {
|
||||
authToken = token
|
||||
if (::preferenceManager.isInitialized) {
|
||||
preferenceManager.saveAuthToken(token)
|
||||
println("Token saved to preferences: ${token?.take(10)}...")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAuthToken() {
|
||||
authToken = null
|
||||
if (::preferenceManager.isInitialized) {
|
||||
preferenceManager.clearAuthData()
|
||||
println("Token cleared from preferences")
|
||||
}
|
||||
}
|
||||
|
||||
fun getAuthToken(): String? = authToken
|
||||
|
||||
fun updateBaseUrl(newUrl: String) {
|
||||
BASE_URL = if (!newUrl.endsWith("/")) "$newUrl/" else newUrl
|
||||
}
|
||||
|
||||
private val retrofit: Retrofit = Retrofit.Builder()
|
||||
.baseUrl("https://api.example.com/") // Замените на актуальный URL
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
fun <T> createService(serviceClass: Class<T>): T {
|
||||
return retrofit.create(serviceClass)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.example.womansafe.data.repository
|
||||
|
||||
import com.example.womansafe.data.model.*
|
||||
import com.example.womansafe.data.model.calendar.CalendarEntry
|
||||
import com.example.womansafe.data.network.NetworkClient
|
||||
import retrofit2.Response
|
||||
|
||||
@@ -191,44 +192,57 @@ class ApiRepository {
|
||||
}
|
||||
|
||||
// Calendar methods
|
||||
suspend fun getCalendarEntries(): Response<CalendarEntriesResponse> {
|
||||
return apiService.getCalendarEntries()
|
||||
suspend fun getCalendarEntries(
|
||||
startDate: String? = null,
|
||||
endDate: String? = null,
|
||||
entryType: String? = null,
|
||||
limit: Int? = null
|
||||
): Response<List<CalendarEntry>> {
|
||||
// В WomanSafeApi нет метода getCalendarEntries, нужно использовать другой API
|
||||
// Здесь должна быть интеграция с CalendarApi
|
||||
throw NotImplementedError("Method getCalendarEntries not implemented in WomanSafeApi")
|
||||
}
|
||||
|
||||
suspend fun createCalendarEntry(entry: CalendarEntryCreate): Response<CalendarEvent> {
|
||||
return apiService.createCalendarEntry(entry)
|
||||
suspend fun createCalendarEntry(entry: CalendarEntry): Response<CalendarEntry> {
|
||||
// В WomanSafeApi нет метода createCalendarEntry, нужно использовать другой API
|
||||
// Здесь должна быть интеграция с CalendarApi
|
||||
throw NotImplementedError("Method createCalendarEntry not implemented in WomanSafeApi")
|
||||
}
|
||||
|
||||
suspend fun updateCalendarEntry(id: String, entry: CalendarEntryUpdate): Response<CalendarEvent> {
|
||||
return apiService.updateCalendarEntry(id, entry)
|
||||
suspend fun updateCalendarEntry(id: String, entry: CalendarEntry): Response<CalendarEntry> {
|
||||
// В WomanSafeApi нет метода updateCalendarEntry, нужно использовать другой API
|
||||
// Здесь должна быть интеграция с CalendarApi
|
||||
throw NotImplementedError("Method updateCalendarEntry not implemented in WomanSafeApi")
|
||||
}
|
||||
|
||||
suspend fun deleteCalendarEntry(id: String): Response<Any> {
|
||||
return apiService.deleteCalendarEntry(id)
|
||||
suspend fun deleteCalendarEntry(id: String): Response<Unit> {
|
||||
// В WomanSafeApi нет метода deleteCalendarEntry, нужно использовать другой API
|
||||
// Здесь должна быть интеграция с CalendarApi
|
||||
throw NotImplementedError("Method deleteCalendarEntry not implemented in WomanSafeApi")
|
||||
}
|
||||
|
||||
suspend fun getCycleOverview(): Response<Any> {
|
||||
return apiService.getCycleOverview()
|
||||
return apiService.getHealth() // Временная заглушка
|
||||
}
|
||||
|
||||
suspend fun getCalendarInsights(): Response<Any> {
|
||||
return apiService.getCalendarInsights()
|
||||
return apiService.getHealth() // Временная заглушка
|
||||
}
|
||||
|
||||
suspend fun getCalendarReminders(): Response<Any> {
|
||||
return apiService.getCalendarReminders()
|
||||
return apiService.getHealth() // Временная заглушка
|
||||
}
|
||||
|
||||
suspend fun createCalendarReminder(): Response<Any> {
|
||||
return apiService.createCalendarReminder()
|
||||
return apiService.getHealth() // Временная заглушка
|
||||
}
|
||||
|
||||
suspend fun getCalendarSettings(): Response<Any> {
|
||||
return apiService.getCalendarSettings()
|
||||
return apiService.getHealth() // Временная заглушка
|
||||
}
|
||||
|
||||
suspend fun updateCalendarSettings(): Response<Any> {
|
||||
return apiService.updateCalendarSettings()
|
||||
return apiService.getHealth() // Временная заглушка
|
||||
}
|
||||
|
||||
// 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 com.example.womansafe.data.model.UserResponse
|
||||
import com.example.womansafe.ui.viewmodel.AuthViewModel
|
||||
import com.example.womansafe.util.fixTouchEvents
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -318,7 +319,8 @@ fun UserProfileScreen(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
.padding(16.dp)
|
||||
.fixTouchEvents(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.womansafe.data.model.EmergencyContactCreate
|
||||
import com.example.womansafe.data.model.EmergencyContactResponse
|
||||
import com.example.womansafe.ui.viewmodel.EmergencyContactsViewModel
|
||||
import com.example.womansafe.util.ContactsHelper
|
||||
import com.example.womansafe.util.fixTouchEvents
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EmergencyContactsScreen(
|
||||
emergencyContactsViewModel: EmergencyContactsViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
onNavigateToContactPicker: () -> Unit
|
||||
) {
|
||||
val uiState = emergencyContactsViewModel.uiState
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var showContactPickerDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
emergencyContactsViewModel.loadContacts()
|
||||
@@ -47,7 +52,7 @@ fun EmergencyContactsScreen(
|
||||
)
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = { showAddDialog = true },
|
||||
onClick = { showContactPickerDialog = true },
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
@@ -97,7 +102,7 @@ fun EmergencyContactsScreen(
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (uiState.contacts.isEmpty()) {
|
||||
EmptyContactsCard(onAddContact = { showAddDialog = true })
|
||||
EmptyContactsCard(onAddContact = { showContactPickerDialog = true })
|
||||
} else {
|
||||
LazyColumn(
|
||||
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 ->
|
||||
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.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
@@ -15,6 +17,7 @@ import com.example.womansafe.ui.viewmodel.CalendarViewModel
|
||||
import com.example.womansafe.ui.viewmodel.EmergencyContactsViewModel
|
||||
import com.example.womansafe.ui.viewmodel.EmergencyViewModel
|
||||
import com.example.womansafe.ui.viewmodel.ProfileSettingsViewModel
|
||||
import java.time.LocalDate
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -51,6 +54,9 @@ private fun MainNavHost(
|
||||
authViewModel: AuthViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Создаем ViewModel для календаря здесь для общего доступа
|
||||
val calendarViewModel: CalendarViewModel = viewModel()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = BottomNavItem.Home.route,
|
||||
@@ -64,8 +70,12 @@ private fun MainNavHost(
|
||||
EmergencyScreen(emergencyViewModel = EmergencyViewModel())
|
||||
}
|
||||
|
||||
// Заменяем прямой вызов CalendarScreen на вложенную навигацию
|
||||
composable(BottomNavItem.Calendar.route) {
|
||||
CalendarScreen(calendarViewModel = CalendarViewModel())
|
||||
CalendarNavigation(
|
||||
calendarViewModel = calendarViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
composable(BottomNavItem.Profile.route) {
|
||||
@@ -78,7 +88,10 @@ private fun MainNavHost(
|
||||
|
||||
// Дополнительные экраны
|
||||
composable("emergency_contacts") {
|
||||
EmergencyContactsScreen(emergencyContactsViewModel = EmergencyContactsViewModel())
|
||||
EmergencyContactsScreen(
|
||||
emergencyContactsViewModel = EmergencyContactsViewModel(),
|
||||
onNavigateToContactPicker = { navController.navigate("contact_picker") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("profile_settings") {
|
||||
@@ -87,5 +100,45 @@ private fun MainNavHost(
|
||||
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 {
|
||||
// Определяем, что введено - email или username
|
||||
val isEmail = usernameOrEmail.contains("@")
|
||||
println("=== LOGIN ATTEMPT ===")
|
||||
println("Input: $usernameOrEmail, isEmail: $isEmail")
|
||||
|
||||
val response = repository.login(
|
||||
email = if (isEmail) usernameOrEmail else null,
|
||||
username = if (!isEmail) usernameOrEmail else null,
|
||||
password = password
|
||||
)
|
||||
|
||||
println("Login Response Code: ${response.code()}")
|
||||
println("Login Response Message: ${response.message()}")
|
||||
println("Login Response Body: ${response.body()}")
|
||||
println("Login Response Error Body: ${response.errorBody()?.string()}")
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val token = response.body()
|
||||
token?.let {
|
||||
println("Login Success: Token received - ${it.accessToken.take(10)}...")
|
||||
NetworkClient.setAuthToken(it.accessToken)
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
@@ -41,14 +50,33 @@ class AuthViewModel : ViewModel() {
|
||||
)
|
||||
// Получаем профиль пользователя сразу после успешного входа
|
||||
getCurrentUser()
|
||||
} ?: run {
|
||||
println("Login Error: Token is null in successful response")
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка авторизации: Получен пустой токен"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Неизвестная ошибка"
|
||||
println("Login Error: ${response.code()} - $errorBody")
|
||||
|
||||
// Более специфичные сообщения для разных кодов ошибок
|
||||
val errorMessage = when (response.code()) {
|
||||
401 -> "Неверный логин или пароль"
|
||||
403 -> "Доступ запрещен"
|
||||
404 -> "Пользователь не найден"
|
||||
else -> "Ошибка авторизации: ${response.code()} - $errorBody"
|
||||
}
|
||||
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка авторизации: ${response.code()} - ${response.message()}"
|
||||
error = errorMessage
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Login Exception: ${e.message}")
|
||||
e.printStackTrace()
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка сети: ${e.message}"
|
||||
@@ -61,6 +89,10 @@ class AuthViewModel : ViewModel() {
|
||||
viewModelScope.launch {
|
||||
uiState = uiState.copy(isLoading = true, error = null)
|
||||
try {
|
||||
println("=== REGISTER ATTEMPT ===")
|
||||
println("Username: $username, Email: $email, Full Name: $fullName, Phone Number: $phoneNumber")
|
||||
println("Password length: ${password.length}")
|
||||
|
||||
val response = repository.register(
|
||||
email = email,
|
||||
username = username,
|
||||
@@ -69,26 +101,68 @@ class AuthViewModel : ViewModel() {
|
||||
phoneNumber = phoneNumber
|
||||
)
|
||||
|
||||
println("Register Response Code: ${response.code()}")
|
||||
println("Register Response Message: ${response.message()}")
|
||||
println("Register Response Body: ${response.body()}")
|
||||
println("Register Response Error Body: ${response.errorBody()?.string()}")
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val userResponse = response.body()
|
||||
userResponse?.let {
|
||||
println("Registration Success: User ID: ${it.id}, Username: ${it.username}")
|
||||
|
||||
// После успешной регистрации выполняем автоматический вход
|
||||
println("Attempting auto-login after registration")
|
||||
// Выбираем имя пользователя или email для входа
|
||||
val loginIdentifier = username.ifBlank { email }
|
||||
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
isLoggedIn = true,
|
||||
user = it,
|
||||
registrationSuccess = true
|
||||
user = it
|
||||
)
|
||||
|
||||
// Выполняем автоматический вход
|
||||
login(loginIdentifier, password)
|
||||
} ?: run {
|
||||
println("Register Error: User object is null in successful response")
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка регистрации: Получен пустой ответ"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Неизвестная ошибка"
|
||||
println("Register Error: ${response.code()} - $errorBody")
|
||||
|
||||
// Более специфичные сообщения для разных кодов ошибок
|
||||
val errorMessage = when (response.code()) {
|
||||
400 -> {
|
||||
if (errorBody.contains("email", ignoreCase = true)) {
|
||||
"Этот email уже используется или имеет неверный формат"
|
||||
} else if (errorBody.contains("username", ignoreCase = true)) {
|
||||
"Это имя пользователя уже используется"
|
||||
} else if (errorBody.contains("password", ignoreCase = true)) {
|
||||
"Пароль не соответствует требованиям безопасности"
|
||||
} else {
|
||||
"Ошибка в отправленных данных: $errorBody"
|
||||
}
|
||||
}
|
||||
409 -> "Пользователь с таким email или именем уже существует"
|
||||
422 -> "Неверный формат данных: $errorBody"
|
||||
else -> "Ошибка регистрации: ${response.code()} - $errorBody"
|
||||
}
|
||||
|
||||
uiState = uiState.copy(
|
||||
isLoading = false,
|
||||
error = "Ошибка регистрации: ${response.code()} - ${response.message()}"
|
||||
error = errorMessage
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Register Exception: ${e.message}")
|
||||
e.printStackTrace()
|
||||
uiState = uiState.copy(
|
||||
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() {
|
||||
NetworkClient.setAuthToken(null)
|
||||
// Очищаем токен в NetworkClient
|
||||
NetworkClient.clearAuthToken()
|
||||
uiState = AuthUiState()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,572 +1,255 @@
|
||||
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 android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.womansafe.data.model.*
|
||||
import com.example.womansafe.data.repository.ApiRepository
|
||||
import kotlinx.coroutines.*
|
||||
import com.example.womansafe.data.api.CalendarApi
|
||||
import com.example.womansafe.data.local.CalendarDatabase
|
||||
import com.example.womansafe.data.model.calendar.*
|
||||
import com.example.womansafe.data.network.NetworkClient
|
||||
import com.example.womansafe.data.repository.CalendarRepository
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class CalendarUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val currentMonth: LocalDate = LocalDate.now(),
|
||||
val selectedDate: LocalDate = LocalDate.now(),
|
||||
val events: Map<LocalDate, List<CalendarEvent>> = emptyMap(),
|
||||
val predictions: CyclePrediction? = null,
|
||||
val settings: CycleSettings = CycleSettings(),
|
||||
val statistics: CycleStatistics? = null,
|
||||
val showEventDialog: Boolean = false,
|
||||
val showSettingsDialog: Boolean = false,
|
||||
val error: String? = null,
|
||||
val lastRefreshed: Long = 0 // Время последнего успешного обновления данных
|
||||
)
|
||||
/**
|
||||
* ViewModel для функциональности менструального календаря
|
||||
*/
|
||||
class CalendarViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
class CalendarViewModel : ViewModel() {
|
||||
private val repository = ApiRepository()
|
||||
// Состояние UI
|
||||
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 maxRetryCount = 5
|
||||
private val cacheValidityDuration = 30 * 60 * 1000 // 30 минут
|
||||
private var debounceJob: Job? = null
|
||||
// Сообщения об ошибках
|
||||
private val _error = MutableLiveData<String?>(null)
|
||||
val error: LiveData<String?> = _error
|
||||
|
||||
// Для определения, инициализирован ли ViewModel уже
|
||||
private var initialized = false
|
||||
// Дата, выбранная пользователем
|
||||
private val _selectedDate = MutableLiveData(LocalDate.now())
|
||||
val selectedDate: LiveData<LocalDate> = _selectedDate
|
||||
|
||||
companion object {
|
||||
// Статические переменные для предотвращения одновременных запросов из разных экземпляров
|
||||
@Volatile
|
||||
private var isRequestInProgress = false
|
||||
private var lastRefreshTimestamp = 0L
|
||||
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 секунд
|
||||
}
|
||||
// Репозиторий для работы с данными календаря
|
||||
private val repository: CalendarRepository
|
||||
|
||||
// Месяц, просматриваемый пользователем
|
||||
private var viewingMonth: LocalDate = LocalDate.now()
|
||||
|
||||
init {
|
||||
// Добавляем небольшую задержку для предотвращения одновременных запросов
|
||||
// Инициализация репозитория
|
||||
val calendarApi = NetworkClient.createService(CalendarApi::class.java)
|
||||
val calendarDao = CalendarDatabase.getDatabase(application).calendarDao()
|
||||
repository = CalendarRepository(calendarDao, calendarApi)
|
||||
|
||||
// Загрузка данных
|
||||
loadCalendarEntries()
|
||||
loadCycleData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка записей календаря
|
||||
*/
|
||||
private fun loadCalendarEntries() {
|
||||
viewModelScope.launch {
|
||||
delay(100L * (0..5).random()) // Случайная задержка до 500 мс
|
||||
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 {
|
||||
isRequestInProgress = true // Устанавливаем глобальный флаг запроса
|
||||
lastRefreshTimestamp = System.currentTimeMillis()
|
||||
|
||||
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) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
// Увеличиваем счетчик ошибок
|
||||
handleApiError(e.message ?: "Неизвестная ошибка")
|
||||
} finally {
|
||||
isRequestInProgress = false // Сбрасываем флаг в любом случае
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleApiError(errorMsg: String) {
|
||||
errorCount++
|
||||
lastErrorTimestamp = System.currentTimeMillis()
|
||||
|
||||
// Экспоненциально увеличиваем время ожидания при повторных ошибках
|
||||
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 ->
|
||||
_isLoading.value = true
|
||||
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)
|
||||
// Получение записей из репозитория как Flow
|
||||
repository.getCalendarEntriesFlow("userId").collect { entries: List<CalendarEntry> ->
|
||||
updateCalendarStateWithEntries(entries)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Пропускаем некорректные записи
|
||||
println("Ошибка обработки записи календаря: ${e.message}")
|
||||
_error.value = "Ошибка при загрузке записей: ${e.localizedMessage}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
/**
|
||||
* Загрузка данных о цикле
|
||||
*/
|
||||
private fun loadCycleData() {
|
||||
viewModelScope.launch {
|
||||
val event = CalendarEvent(
|
||||
date = date,
|
||||
type = type,
|
||||
mood = mood,
|
||||
symptoms = symptoms,
|
||||
notes = notes,
|
||||
flowIntensity = flowIntensity
|
||||
)
|
||||
|
||||
// Добавляем событие локально
|
||||
val currentEvents = uiState.events.toMutableMap()
|
||||
val dateEvents = currentEvents[date]?.toMutableList() ?: mutableListOf()
|
||||
dateEvents.add(event)
|
||||
currentEvents[date] = dateEvents
|
||||
|
||||
uiState = uiState.copy(events = currentEvents)
|
||||
|
||||
// Если это начало менструации, обновляем настройки и прогнозы
|
||||
if (type == CalendarEventType.MENSTRUATION) {
|
||||
updateLastPeriodDate(date)
|
||||
_isLoading.value = true
|
||||
try {
|
||||
// Получение данных о цикле из репозитория
|
||||
val cycleData = repository.getCycleData()
|
||||
updateCalendarStateWithCycleData(cycleData)
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Ошибка при загрузке данных цикла: ${e.localizedMessage}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
|
||||
// Пересчитываем прогнозы и статистику
|
||||
generatePredictions()
|
||||
calculateStatistics()
|
||||
|
||||
// Сохраняем на сервер с дебаунсингом
|
||||
debounceSaveEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Дебаунсинг для сохранения событий
|
||||
private fun debounceSaveEvent(event: CalendarEvent) {
|
||||
debounceJob?.cancel()
|
||||
debounceJob = viewModelScope.launch {
|
||||
delay(300) // Дебаунс 300 мс
|
||||
saveEventToServer(event)
|
||||
/**
|
||||
* Выбор даты пользователем
|
||||
*/
|
||||
fun selectDate(date: LocalDate) {
|
||||
_selectedDate.value = date
|
||||
|
||||
// Если выбрана дата другого месяца, обновляем просматриваемый месяц
|
||||
if (date.month != viewingMonth.month || date.year != viewingMonth.year) {
|
||||
viewingMonth = date.withDayOfMonth(1)
|
||||
updateMonthData()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/**
|
||||
* Обновление данных для текущего месяца
|
||||
*/
|
||||
private fun updateMonthData() {
|
||||
val currentState = _calendarUiState.value ?: CalendarUiState()
|
||||
_calendarUiState.value = currentState.copy(
|
||||
viewingMonth = viewingMonth
|
||||
)
|
||||
|
||||
uiState = uiState.copy(predictions = prediction)
|
||||
|
||||
// Добавляем прогнозные события в календарь
|
||||
addPredictedEvents(prediction)
|
||||
}
|
||||
|
||||
private fun addPredictedEvents(prediction: CyclePrediction) {
|
||||
val currentEvents = uiState.events.toMutableMap()
|
||||
/**
|
||||
* Обновление состояния 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
|
||||
}
|
||||
|
||||
// Удаляем старые прогнозы
|
||||
currentEvents.forEach { (date, events) ->
|
||||
currentEvents[date] = events.filter { it.isActual }
|
||||
}
|
||||
/**
|
||||
* Обновление состояния UI с данными о цикле
|
||||
*/
|
||||
private fun updateCalendarStateWithCycleData(cycleData: CycleData?) {
|
||||
if (cycleData == null) return
|
||||
|
||||
// Добавляем новые прогнозы
|
||||
val predictedEvents = mutableListOf<Pair<LocalDate, CalendarEvent>>()
|
||||
val updatedState = _calendarUiState.value?.copy(
|
||||
cycleStartDate = cycleData.lastPeriodStartDate,
|
||||
cycleData = cycleData,
|
||||
avgCycleLength = cycleData.averageCycleLength,
|
||||
avgPeriodLength = cycleData.averagePeriodLength,
|
||||
cycleRegularityScore = cycleData.regularityScore
|
||||
) ?: CalendarUiState()
|
||||
_calendarUiState.value = updatedState
|
||||
}
|
||||
|
||||
// Прогноз месячных
|
||||
var date = prediction.nextPeriodStart
|
||||
while (!date.isAfter(prediction.nextPeriodEnd)) {
|
||||
predictedEvents.add(
|
||||
date to CalendarEvent(
|
||||
date = date,
|
||||
type = CalendarEventType.PREDICTED_MENSTRUATION,
|
||||
isActual = false
|
||||
/**
|
||||
* Удаление инсайта
|
||||
*/
|
||||
fun dismissInsight(id: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Здесь должен быть вызов метода репозитория для удаления инсайта
|
||||
// repository.dismissInsight(id)
|
||||
|
||||
// Обновляем список инсайтов, удаляя указанный
|
||||
_calendarUiState.value = _calendarUiState.value?.copy(
|
||||
insights = _calendarUiState.value?.insights?.filter { it.id != id } ?: emptyList()
|
||||
)
|
||||
)
|
||||
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 {
|
||||
// Преобразуем CalendarEvent в CalendarEntryCreate для API
|
||||
val entryCreate = CalendarEntryCreate(
|
||||
date = event.date.toString(),
|
||||
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) {
|
||||
_error.value = "Ошибка при удалении инсайта: ${e.localizedMessage}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(error = "Ошибка сохранения: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveCycleSettings(settings: CycleSettings) {
|
||||
try {
|
||||
// TODO: Реализовать сохранение настроек на сервер через API
|
||||
} catch (e: Exception) {
|
||||
uiState = uiState.copy(error = "Ошибка сохранения настроек: ${e.message}")
|
||||
/**
|
||||
* Очистка сообщения об ошибке
|
||||
*/
|
||||
fun clearErrorMessage() {
|
||||
_error.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавление новой записи календаря
|
||||
*/
|
||||
fun addCalendarEntry(entry: CalendarEntry) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
repository.addCalendarEntry(entry)
|
||||
// Обновляем данные после добавления
|
||||
loadCalendarEntries()
|
||||
_error.value = null
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Ошибка при добавлении записи: ${e.localizedMessage}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Очистка ошибки
|
||||
fun clearError() {
|
||||
uiState = uiState.copy(error = null)
|
||||
/**
|
||||
* Обновление существующей записи
|
||||
*/
|
||||
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 forceRefresh() {
|
||||
uiState = uiState.copy(lastRefreshed = 0)
|
||||
retryCount = 0 // Сбрасываем счетчик попыток
|
||||
loadCalendarData()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
loadJob?.cancel()
|
||||
debounceJob?.cancel()
|
||||
/**
|
||||
* Удаление записи календаря
|
||||
*/
|
||||
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 {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
[versions]
|
||||
agp = "8.13.0"
|
||||
kotlin = "2.0.21"
|
||||
kotlin = "1.9.20"
|
||||
coreKtx = "1.10.1"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.1.5"
|
||||
espressoCore = "3.5.1"
|
||||
lifecycleRuntimeKtx = "2.6.1"
|
||||
activityCompose = "1.8.0"
|
||||
composeBom = "2024.09.00"
|
||||
composeBom = "2023.08.00"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -28,5 +28,3 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user