UI refactor

This commit is contained in:
2025-10-07 16:24:56 +09:00
parent 6f969dbd1a
commit 0e9ec5c187
49 changed files with 5971 additions and 1407 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
package com.example.womansafe.data.api
import com.example.womansafe.data.model.calendar.CalendarEntry
import com.example.womansafe.data.model.calendar.CycleData
import com.example.womansafe.data.model.calendar.HealthInsight
import retrofit2.Response
import retrofit2.http.*
/**
* API интерфейс для взаимодействия с серверной частью календаря
*/
interface CalendarApi {
@GET("api/v1/calendar/cycle-data")
suspend fun getCycleData(): Response<CycleData>
@POST("api/v1/calendar/cycle-data")
suspend fun updateCycleData(@Body cycleData: CycleData): Response<CycleData>
@GET("api/v1/calendar/entries")
suspend fun getCalendarEntries(
@Query("start_date") startDate: String? = null,
@Query("end_date") endDate: String? = null
): Response<List<CalendarEntry>>
@GET("api/v1/calendar/entries/{entry_id}")
suspend fun getCalendarEntry(
@Path("entry_id") entryId: Long
): Response<CalendarEntry>
@POST("api/v1/calendar/entries")
suspend fun addCalendarEntry(
@Body entry: CalendarEntry
): Response<CalendarEntry>
@PUT("api/v1/calendar/entries/{entry_id}")
suspend fun updateCalendarEntry(
@Path("entry_id") entryId: Long,
@Body entry: CalendarEntry
): Response<CalendarEntry>
@DELETE("api/v1/calendar/entries/{entry_id}")
suspend fun deleteCalendarEntry(
@Path("entry_id") entryId: Long
): Response<Unit>
@GET("api/v1/calendar/insights")
suspend fun getHealthInsights(): Response<List<HealthInsight>>
@POST("api/v1/calendar/insights/{insight_id}/dismiss")
suspend fun dismissInsight(
@Path("insight_id") insightId: Long
): Response<Unit>
}

View File

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

View File

@@ -0,0 +1,86 @@
package com.example.womansafe.data.local
import androidx.room.*
import com.example.womansafe.data.model.calendar.CycleData
import com.example.womansafe.data.model.calendar.CalendarEntry
import com.example.womansafe.data.model.calendar.HealthInsight
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
/**
* DAO для работы с данными менструального календаря
*/
@Dao
interface CalendarDao {
// CycleData operations
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCycleData(cycleData: CycleData)
@Query("SELECT * FROM cycle_data WHERE userId = :userId")
suspend fun getCycleDataByUserId(userId: String): CycleData?
@Query("SELECT * FROM cycle_data WHERE userId = :userId")
fun getCycleDataFlowByUserId(userId: String): Flow<CycleData?>
@Delete
suspend fun deleteCycleData(cycleData: CycleData)
// CalendarEntry operations
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCalendarEntry(entry: CalendarEntry): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCalendarEntries(entries: List<CalendarEntry>)
@Update
suspend fun updateCalendarEntry(entry: CalendarEntry)
@Query("SELECT * FROM calendar_entries WHERE userId = :userId ORDER BY entryDate DESC")
fun getAllCalendarEntriesFlow(userId: String): Flow<List<CalendarEntry>>
@Query("SELECT * FROM calendar_entries WHERE userId = :userId AND entryDate = :date")
suspend fun getCalendarEntryByDate(userId: String, date: LocalDate): CalendarEntry?
@Query("SELECT * FROM calendar_entries WHERE userId = :userId AND entryDate BETWEEN :startDate AND :endDate ORDER BY entryDate")
suspend fun getCalendarEntriesBetweenDates(userId: String, startDate: LocalDate, endDate: LocalDate): List<CalendarEntry>
@Query("SELECT * FROM calendar_entries WHERE userId = :userId AND entryDate BETWEEN :startDate AND :endDate ORDER BY entryDate")
fun getCalendarEntriesBetweenDatesFlow(userId: String, startDate: LocalDate, endDate: LocalDate): Flow<List<CalendarEntry>>
@Query("SELECT * FROM calendar_entries WHERE id = :entryId")
suspend fun getCalendarEntryById(entryId: Long): CalendarEntry?
@Delete
suspend fun deleteCalendarEntry(entry: CalendarEntry)
@Query("DELETE FROM calendar_entries WHERE userId = :userId AND entryDate = :date")
suspend fun deleteCalendarEntryByDate(userId: String, date: LocalDate)
@Query("SELECT * FROM calendar_entries ORDER BY entryDate DESC")
suspend fun getAllCalendarEntries(): List<CalendarEntry>
// HealthInsight operations
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertHealthInsight(insight: HealthInsight): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertHealthInsights(insights: List<HealthInsight>)
@Update
suspend fun updateHealthInsight(insight: HealthInsight)
@Query("SELECT * FROM health_insights WHERE userId = :userId AND isDismissed = 0 ORDER BY createdAt DESC")
fun getActiveHealthInsightsFlow(userId: String): Flow<List<HealthInsight>>
@Query("SELECT * FROM health_insights WHERE userId = :userId ORDER BY createdAt DESC")
fun getAllHealthInsightsFlow(userId: String): Flow<List<HealthInsight>>
@Query("UPDATE health_insights SET isDismissed = 1 WHERE id = :insightId")
suspend fun dismissHealthInsight(insightId: Long)
@Query("DELETE FROM health_insights WHERE userId = :userId AND createdAt < :timestamp")
suspend fun deleteOldInsights(userId: String, timestamp: Long)
@Query("SELECT * FROM cycle_data ORDER BY lastUpdated DESC LIMIT 1")
suspend fun getLatestCycleData(): CycleData?
}

View File

@@ -0,0 +1,48 @@
package com.example.womansafe.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.example.womansafe.data.model.calendar.CalendarEntry
import com.example.womansafe.data.model.calendar.CycleData
import com.example.womansafe.data.model.calendar.HealthInsight
/**
* База данных Room для хранения всех данных менструального календаря
*/
@Database(
entities = [
CycleData::class,
CalendarEntry::class,
HealthInsight::class
],
version = 1,
exportSchema = false
)
@TypeConverters(CalendarTypeConverters::class)
abstract class CalendarDatabase : RoomDatabase() {
abstract fun calendarDao(): CalendarDao
companion object {
@Volatile
private var INSTANCE: CalendarDatabase? = null
fun getDatabase(context: Context): CalendarDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
CalendarDatabase::class.java,
"calendar_database"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -0,0 +1,112 @@
package com.example.womansafe.data.local
import androidx.room.TypeConverter
import com.example.womansafe.data.model.calendar.*
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.time.LocalDate
/**
* Конвертеры типов для Room базы данных для работы с нестандартными типами
*/
class CalendarTypeConverters {
private val gson = Gson()
@TypeConverter
fun fromLocalDate(value: LocalDate?): String? {
return value?.toString()
}
@TypeConverter
fun toLocalDate(value: String?): LocalDate? {
return value?.let { LocalDate.parse(it) }
}
@TypeConverter
fun fromSymptomList(value: List<Symptom>?): String? {
return value?.let { gson.toJson(it) }
}
@TypeConverter
fun toSymptomList(value: String?): List<Symptom>? {
if (value == null) return null
val listType = object : TypeToken<List<Symptom>>() {}.type
return gson.fromJson(value, listType)
}
@TypeConverter
fun fromStringList(value: List<String>?): String? {
return value?.let { gson.toJson(it) }
}
@TypeConverter
fun toStringList(value: String?): List<String>? {
if (value == null) return null
val listType = object : TypeToken<List<String>>() {}.type
return gson.fromJson(value, listType)
}
@TypeConverter
fun fromEntryType(value: EntryType): String {
return value.name
}
@TypeConverter
fun toEntryType(value: String): EntryType {
return try {
EntryType.valueOf(value)
} catch (e: IllegalArgumentException) {
EntryType.NOTE // Дефолтное значение
}
}
@TypeConverter
fun fromFlowIntensity(value: FlowIntensity?): String? {
return value?.name
}
@TypeConverter
fun toFlowIntensity(value: String?): FlowIntensity? {
if (value == null) return null
return try {
FlowIntensity.valueOf(value)
} catch (e: IllegalArgumentException) {
null
}
}
@TypeConverter
fun fromMood(value: Mood?): String? {
return value?.name
}
@TypeConverter
fun toMood(value: String?): Mood? {
if (value == null) return null
return try {
Mood.valueOf(value)
} catch (e: IllegalArgumentException) {
null
}
}
@TypeConverter
fun fromInsightType(value: InsightType): String {
return value.name
}
@TypeConverter
fun toInsightType(value: String): InsightType {
return InsightType.valueOf(value)
}
@TypeConverter
fun fromConfidenceLevel(value: ConfidenceLevel): String {
return value.name
}
@TypeConverter
fun toConfidenceLevel(value: String): ConfidenceLevel {
return ConfidenceLevel.valueOf(value)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,128 @@
package com.example.womansafe.data.model
import java.time.LocalDate
import java.time.LocalDateTime
import com.example.womansafe.data.model.calendar.CalendarEntry
import com.example.womansafe.data.model.calendar.EntryType
import com.example.womansafe.data.model.calendar.FlowIntensity
import com.example.womansafe.data.model.calendar.Symptom
import com.example.womansafe.data.model.calendar.Mood
/**
* Модель данных цикла
*/
data class CycleData(
val user_id: Int,
val cycle_start_date: LocalDate,
val cycle_length: Int,
val period_length: Int,
val ovulation_date: LocalDate,
val fertile_window_start: LocalDate,
val fertile_window_end: LocalDate,
val next_period_predicted: LocalDate,
val cycle_regularity_score: Int, // 1-100
val avg_cycle_length: Float,
val avg_period_length: Float
)
/**
* Модель для аналитики здоровья и инсайтов
*/
data class HealthInsight(
val id: Int,
val user_id: Int,
val insight_type: InsightType,
val title: String,
val description: String,
val recommendation: String,
val confidence_level: ConfidenceLevel,
val data_points_used: Int,
val is_dismissed: Boolean = false,
val created_at: LocalDateTime? = null
)
/**
* Типы записей в календаре
*/
/**
* Интенсивность менструации
*/
/**
* Настроение
*/
/**
* Симптомы
*/
/**
* Типы инсайтов
*/
enum class InsightType {
CYCLE_IRREGULARITY,
PERIOD_LENGTH_CHANGE,
SYMPTOM_PATTERN,
HEALTH_RECOMMENDATION,
OVULATION_PREDICTION,
LIFESTYLE_IMPACT
}
/**
* Уровни достоверности инсайтов
*/
enum class ConfidenceLevel {
LOW,
MEDIUM,
HIGH
}
/**
* Модель для запроса на создание записи в календаре
*/
data class CalendarEntryRequest(
val entry_date: String,
val entry_type: String,
val flow_intensity: String? = null,
val period_symptoms: List<String>? = null,
val mood: String? = null,
val energy_level: Int? = null,
val sleep_hours: Float? = null,
val symptoms: List<String>? = null,
val medications: List<String>? = null,
val notes: String? = null
)
/**
* Модель для статистики цикла
*/
data class CycleStatistics(
val average_cycle_length: Float,
val cycle_length_variation: Float,
val average_period_length: Float,
val cycle_regularity: Int,
val cycle_history: List<CycleHistoryItem>
)
/**
* Элемент истории циклов для отображения графика
*/
data class CycleHistoryItem(
val start_date: LocalDate,
val end_date: LocalDate,
val cycle_length: Int,
val period_length: Int
)
/**
* Модель для прогноза цикла
*/
data class CyclePrediction(
val next_period_start: LocalDate,
val next_period_end: LocalDate,
val next_ovulation: LocalDate,
val fertile_window_start: LocalDate,
val fertile_window_end: LocalDate,
val confidence: Float = 0.85f
)

View File

@@ -0,0 +1,26 @@
package com.example.womansafe.data.model.calendar
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDate
@Entity(tableName = "calendar_entries")
data class CalendarEntry(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val userId: String,
val entryDate: LocalDate,
val entryType: EntryType,
val flowIntensity: FlowIntensity? = null,
val periodSymptoms: List<Symptom>? = null,
val mood: Mood? = null,
val energyLevel: Int? = null, // 1-5
val sleepHours: Float? = null,
val symptoms: List<Symptom>? = null,
val medications: List<String>? = null,
val notes: String? = null,
val isPredicted: Boolean = false,
val confidenceScore: Int? = null, // 1-100
val syncTimestamp: Long = System.currentTimeMillis(),
val entryId: Long
)

View File

@@ -0,0 +1,17 @@
package com.example.womansafe.data.model.calendar
/**
* Запрос на создание/обновление записи календаря
*/
data class CalendarEntryRequest(
val entry_date: String,
val entry_type: String,
val flow_intensity: String? = null,
val mood: String? = null,
val symptoms: List<String>? = null,
val period_symptoms: List<String>? = null,
val medications: List<String>? = null,
val notes: String? = null,
val energy_level: Int? = null,
val sleep_hours: Float? = null
)

View File

@@ -0,0 +1,26 @@
package com.example.womansafe.data.model.calendar
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDate
/**
* Модель события календаря, используемая в улучшенном календарном интерфейсе
*/
@Entity(tableName = "calendar_events")
data class CalendarEvent(
@PrimaryKey
val id: String,
val userId: String,
val date: LocalDate,
val type: String, // period, ovulation, symptoms, medication, note, appointment
val flowIntensity: String? = null,
val mood: String? = null,
val energyLevel: Int? = null,
val sleepHours: Float? = null,
val symptoms: List<String>? = null,
val medications: List<String>? = null,
val notes: String? = null,
val isDismissed: Boolean = false,
val syncTimestamp: Long = System.currentTimeMillis()
)

View File

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

View File

@@ -0,0 +1,29 @@
package com.example.womansafe.data.model.calendar
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDate
/**
* Данные о текущем менструальном цикле
*/
@Entity(tableName = "cycle_data")
data class CycleData(
@PrimaryKey
val userId: String,
val periodStart: LocalDate?,
val periodEnd: LocalDate?,
val ovulationDate: LocalDate?,
val fertileWindowStart: LocalDate?,
val fertileWindowEnd: LocalDate?,
val cycleLength: Int = 28,
val periodLength: Int = 5,
val nextPeriodPredicted: LocalDate? = null,
val lastUpdated: Long = System.currentTimeMillis(),
// Добавленные поля для совместимости с кодом
val lastPeriodStartDate: LocalDate? = periodStart,
val averageCycleLength: Int = cycleLength,
val averagePeriodLength: Int = periodLength,
val regularityScore: Int = 0, // 0-100
val cycleStartDate: LocalDate? = null
)

View File

@@ -0,0 +1,17 @@
package com.example.womansafe.data.model.calendar
import java.time.LocalDate
/**
* Модель прогноза менструального цикла
*/
data class CyclePrediction(
val userId: String,
val nextPeriodStart: LocalDate,
val nextPeriodEnd: LocalDate,
val nextOvulation: LocalDate,
val fertileWindowStart: LocalDate,
val fertileWindowEnd: LocalDate,
val confidenceScore: Int, // 1-100
val createdAt: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,16 @@
package com.example.womansafe.data.model.calendar
/**
* Модель статистики менструального цикла
*/
data class CycleStatistics(
val userId: String,
val averageCycleLength: Int,
val averagePeriodLength: Int,
val shortestCycle: Int,
val longestCycle: Int,
val cycleLengthVariation: Int,
val regularityScore: Int, // 1-100
val dataPointsCount: Int,
val lastUpdated: Long = System.currentTimeMillis()
)

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
package com.example.womansafe.data.model.calendar
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Модель медицинских инсайтов на основе данных календаря
*/
@Entity(tableName = "health_insights")
data class HealthInsight(
@PrimaryKey
val id: Long,
val userId: String,
val insightType: InsightType,
val title: String,
val description: String,
val confidenceLevel: ConfidenceLevel,
val recommendation: String,
val dataPointsUsed: Int,
val isDismissed: Boolean = false,
val createdAt: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,17 @@
package com.example.womansafe.data.model.calendar
/**
* Типы инсайтов о женском здоровье
*/
enum class InsightType {
CYCLE_REGULARITY, // Регулярность цикла
PERIOD_LENGTH, // Продолжительность менструации
SYMPTOM_PATTERN, // Закономерности в симптомах
LIFESTYLE_IMPACT, // Влияние образа жизни
MEDICATION_EFFECTIVENESS, // Эффективность лекарств
MOOD_CORRELATION, // Корреляции настроения
PERIOD_PREDICTION, // Прогноз менструации
HEALTH_TIP, // Совет по здоровью
MEDICATION_REMINDER, // Напоминание о приеме лекарств
EXERCISE_SUGGESTION // Рекомендации по физическим упражнениям
}

View File

@@ -0,0 +1,20 @@
package com.example.womansafe.data.model.calendar
/**
* Расширенный набор настроений для дополненного календаря
*/
enum class Mood {
VERY_HAPPY, // Очень счастливое
HAPPY, // Счастливое
NEUTRAL, // Нейтральное
NORMAL, // Обычное (для совместимости)
SAD, // Грустное
VERY_SAD, // Очень грустное
ANXIOUS, // Тревожное
IRRITATED, // Раздраженное (для совместимости)
IRRITABLE, // Раздражительное
SENSITIVE, // Чувствительное
CALM, // Спокойное
ENERGETIC, // Энергичное
TIRED // Усталое
}

View File

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

View File

@@ -0,0 +1,23 @@
package com.example.womansafe.data.model.calendar
/**
* Расширенный набор симптомов для женского здоровья
*/
enum class Symptom {
CRAMPS, // Спазмы/боли
HEADACHE, // Головная боль
BACKACHE, // Боли в спине
NAUSEA, // Тошнота
FATIGUE, // Усталость
BLOATING, // Вздутие
BREAST_TENDERNESS, // Чувствительность груди
ACNE, // Высыпания
MOOD_SWINGS, // Перепады настроения
CRAVINGS, // Тяга к еде
INSOMNIA, // Бессонница
DIZZINESS, // Головокружение
CONSTIPATION, // Запоры
DIARRHEA, // Диарея
HOT_FLASHES, // Приливы
SPOTTING // Мажущие выделения
}

View File

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

View File

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

View File

@@ -0,0 +1,298 @@
package com.example.womansafe.data.repository
import android.util.Log
import com.example.womansafe.data.api.CalendarApi
import com.example.womansafe.data.local.CalendarDao
import com.example.womansafe.data.model.calendar.CalendarEntry
import com.example.womansafe.data.model.calendar.CycleData
import com.example.womansafe.data.model.calendar.HealthInsight
import retrofit2.Response
import com.example.womansafe.data.model.calendar.CycleStatistics
import com.example.womansafe.data.model.calendar.CyclePrediction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.time.format.DateTimeFormatter
/**
* Репозиторий для работы с данными менструального календаря
*/
class CalendarRepository(
private val calendarDao: CalendarDao,
private val calendarApi: CalendarApi
) {
private val TAG = "CalendarRepository"
private val dateFormatter = DateTimeFormatter.ISO_LOCAL_DATE
// CycleData operations
fun getCycleDataFlow(userId: String): Flow<CycleData?> {
return calendarDao.getCycleDataFlowByUserId(userId)
}
suspend fun refreshCycleData(userId: String) {
withContext(Dispatchers.IO) {
try {
val response = calendarApi.getCycleData()
if (response.isSuccessful) {
response.body()?.let { cycleData ->
calendarDao.insertCycleData(cycleData)
Log.d(TAG, "Cycle data refreshed from server")
}
} else {
Log.e(TAG, "Failed to refresh cycle data: ${response.code()}")
}
} catch (e: Exception) {
Log.e(TAG, "Error refreshing cycle data", e)
}
}
}
suspend fun updateCycleData(cycleData: CycleData): Result<CycleData> {
return withContext(Dispatchers.IO) {
try {
val response = calendarApi.updateCycleData(cycleData)
if (response.isSuccessful) {
response.body()?.let { updatedData ->
calendarDao.insertCycleData(updatedData)
Result.success(updatedData)
} ?: Result.failure(Exception("Empty response body"))
} else {
// Сохраняем данные локально даже при ошибке сети
calendarDao.insertCycleData(cycleData)
Result.failure(Exception("API error: ${response.code()}"))
}
} catch (e: Exception) {
// При ошибке сети сохраняем данные локально
calendarDao.insertCycleData(cycleData)
Log.e(TAG, "Error updating cycle data", e)
Result.failure(e)
}
}
}
// CalendarEntry operations
fun getCalendarEntriesFlow(userId: String): Flow<List<CalendarEntry>> {
return calendarDao.getAllCalendarEntriesFlow(userId)
}
fun getCalendarEntriesBetweenDatesFlow(
userId: String,
startDate: LocalDate,
endDate: LocalDate
): Flow<List<CalendarEntry>> {
return calendarDao.getCalendarEntriesBetweenDatesFlow(userId, startDate, endDate)
}
suspend fun refreshCalendarEntries(
userId: String,
startDate: LocalDate? = null,
endDate: LocalDate? = null
) {
withContext(Dispatchers.IO) {
try {
val response = calendarApi.getCalendarEntries(
startDate?.format(dateFormatter),
endDate?.format(dateFormatter)
)
if (response.isSuccessful) {
response.body()?.let { entries ->
calendarDao.insertCalendarEntries(entries)
Log.d(TAG, "Calendar entries refreshed from server: ${entries.size}")
}
} else {
Log.e(TAG, "Failed to refresh calendar entries: ${response.code()}")
}
} catch (e: Exception) {
Log.e(TAG, "Error refreshing calendar entries", e)
}
}
}
suspend fun getCalendarEntryByDate(userId: String, date: LocalDate): CalendarEntry? {
return calendarDao.getCalendarEntryByDate(userId, date)
}
suspend fun addCalendarEntry(entry: CalendarEntry): Result<CalendarEntry> {
return withContext(Dispatchers.IO) {
try {
val response = calendarApi.addCalendarEntry(entry)
if (response.isSuccessful) {
response.body()?.let { serverEntry ->
calendarDao.insertCalendarEntry(serverEntry)
Result.success(serverEntry)
} ?: Result.failure(Exception("Empty response body"))
} else {
// Сохраняем данные локально даже при ошибке сети
val localId = calendarDao.insertCalendarEntry(entry)
Result.failure(Exception("API error: ${response.code()}"))
}
} catch (e: Exception) {
// При ошибке сети сохраняем данные локально
val localId = calendarDao.insertCalendarEntry(entry)
Log.e(TAG, "Error adding calendar entry", e)
Result.failure(e)
}
}
}
suspend fun updateCalendarEntry(entry: CalendarEntry): Result<CalendarEntry> {
return withContext(Dispatchers.IO) {
try {
val response = calendarApi.updateCalendarEntry(entry.id, entry)
if (response.isSuccessful) {
response.body()?.let { updatedEntry ->
calendarDao.updateCalendarEntry(updatedEntry)
Result.success(updatedEntry)
} ?: Result.failure(Exception("Empty response body"))
} else {
// Обновляем данные локально даже при ошибке сети
calendarDao.updateCalendarEntry(entry)
Result.failure(Exception("API error: ${response.code()}"))
}
} catch (e: Exception) {
// При ошибке сети обновляем данные локально
calendarDao.updateCalendarEntry(entry)
Log.e(TAG, "Error updating calendar entry", e)
Result.failure(e)
}
}
}
suspend fun deleteCalendarEntry(entryId: Long): Result<Unit> {
return withContext(Dispatchers.IO) {
try {
val entry = calendarDao.getCalendarEntryById(entryId)
if (entry == null) {
return@withContext Result.failure(Exception("Entry not found"))
}
val response = calendarApi.deleteCalendarEntry(entryId)
if (response.isSuccessful) {
calendarDao.deleteCalendarEntry(entry)
Result.success(Unit)
} else {
Result.failure(Exception("API error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e(TAG, "Error deleting calendar entry", e)
Result.failure(e)
}
}
}
// HealthInsight operations
fun getActiveHealthInsightsFlow(userId: String): Flow<List<HealthInsight>> {
return calendarDao.getActiveHealthInsightsFlow(userId)
}
fun getAllHealthInsightsFlow(userId: String): Flow<List<HealthInsight>> {
return calendarDao.getAllHealthInsightsFlow(userId)
}
suspend fun refreshHealthInsights(userId: String) {
withContext(Dispatchers.IO) {
try {
val response = calendarApi.getHealthInsights()
if (response.isSuccessful) {
response.body()?.let { insights ->
calendarDao.insertHealthInsights(insights)
Log.d(TAG, "Health insights refreshed from server: ${insights.size}")
}
} else {
Log.e(TAG, "Failed to refresh health insights: ${response.code()}")
}
} catch (e: Exception) {
Log.e(TAG, "Error refreshing health insights", e)
}
}
}
suspend fun dismissInsight(insightId: Long): Result<Unit> {
return withContext(Dispatchers.IO) {
try {
val response = calendarApi.dismissInsight(insightId)
if (response.isSuccessful) {
calendarDao.dismissHealthInsight(insightId)
Result.success(Unit)
} else {
Result.failure(Exception("API error: ${response.code()}"))
}
} catch (e: Exception) {
Log.e(TAG, "Error dismissing insight", e)
Result.failure(e)
}
}
}
// Заглушка: Получение статистики цикла
suspend fun getCycleStatistics(userId: String): Response<CycleStatistics> {
val now = System.currentTimeMillis()
return Response.success(
CycleStatistics(
userId = userId,
averageCycleLength = 28,
averagePeriodLength = 5,
shortestCycle = 27,
longestCycle = 30,
cycleLengthVariation = 3,
regularityScore = 90,
dataPointsCount = 12,
lastUpdated = now
)
)
}
// Заглушка: Получение прогнозов цикла
suspend fun getCyclePredictions(userId: String): Response<CyclePrediction> {
val today = java.time.LocalDate.now()
return Response.success(
CyclePrediction(
userId = userId,
nextPeriodStart = today.plusDays(10),
nextPeriodEnd = today.plusDays(15),
nextOvulation = today.plusDays(20),
fertileWindowStart = today.plusDays(18),
fertileWindowEnd = today.plusDays(22),
confidenceScore = 85,
createdAt = System.currentTimeMillis()
)
)
}
// Заглушка: Получение кэшированных прогнозов
fun getCachedPredictions(userId: String): CyclePrediction {
val today = java.time.LocalDate.now()
return CyclePrediction(
userId = userId,
nextPeriodStart = today.plusDays(10),
nextPeriodEnd = today.plusDays(15),
nextOvulation = today.plusDays(20),
fertileWindowStart = today.plusDays(18),
fertileWindowEnd = today.plusDays(22),
confidenceScore = 80,
createdAt = System.currentTimeMillis()
)
}
// Заглушка: Получение инсайтов о здоровье
suspend fun getHealthInsights(): Response<List<HealthInsight>> {
// TODO: Реализовать реальный запрос
return Response.success(emptyList())
}
// Clean up operations
suspend fun cleanupOldInsights(userId: String, olderThan: Long) {
calendarDao.deleteOldInsights(userId, olderThan)
}
suspend fun getCalendarEntries(): List<CalendarEntry> {
// Пример: получение из DAO или API
return calendarDao.getAllCalendarEntries()
}
suspend fun getCycleData(): CycleData? {
// Пример: получение из DAO или API
return calendarDao.getLatestCycleData()
}
}

View File

@@ -0,0 +1,303 @@
package com.example.womansafe.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.example.womansafe.data.model.calendar.CalendarEvent
import com.example.womansafe.utils.DateUtils
import java.time.format.DateTimeFormatter
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarEventDetailDialog(
event: CalendarEvent,
onDismiss: () -> Unit,
onEdit: (CalendarEvent) -> Unit,
onDelete: (CalendarEvent) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
.verticalScroll(rememberScrollState())
) {
// Заголовок с датой
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Иконка типа события
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(getEventIconBackgroundColor(event.type)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = getEventIcon(event.type),
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = getEventTitle(event.type),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = event.date.format(DateTimeFormatter.ofPattern("d MMMM yyyy", Locale("ru"))),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Детали события
event.flowIntensity?.let {
DetailItem(
title = "Интенсивность",
value = getFlowIntensityLabel(it),
icon = Icons.Default.Opacity
)
}
event.mood?.let {
DetailItem(
title = "Настроение",
value = getMoodLabel(it),
icon = Icons.Default.Face
)
}
event.symptoms?.takeIf { it.isNotEmpty() }?.let {
DetailItem(
title = "Симптомы",
value = it.joinToString(", ") { symptom -> getSymptomLabel(symptom) },
icon = Icons.Default.HealthAndSafety
)
}
event.medications?.takeIf { it.isNotEmpty() }?.let {
DetailItem(
title = "Лекарства",
value = it.joinToString(", "),
icon = Icons.Default.Medication
)
}
event.energyLevel?.let {
DetailItem(
title = "Уровень энергии",
value = "$it/5",
icon = Icons.Default.BatteryChargingFull
)
}
event.sleepHours?.let {
DetailItem(
title = "Часы сна",
value = "$it ч",
icon = Icons.Default.Bedtime
)
}
event.notes?.takeIf { it.isNotBlank() }?.let {
DetailItem(
title = "Заметки",
value = it,
icon = Icons.Default.Notes,
maxLines = 5
)
}
Spacer(modifier = Modifier.height(24.dp))
// Кнопки действий
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text("Закрыть")
}
TextButton(onClick = { onEdit(event) }) {
Text("Редактировать")
}
TextButton(
onClick = { onDelete(event) },
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Удалить")
}
}
}
}
}
}
@Composable
private fun DetailItem(
title: String,
value: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
maxLines: Int = 2
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(end = 16.dp, top = 2.dp)
.size(24.dp)
)
Column {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis
)
}
}
}
// Вспомогательные функции для получения информации о событии
private fun getEventIcon(type: String): androidx.compose.ui.graphics.vector.ImageVector {
return when (type.lowercase()) {
"period" -> Icons.Default.Opacity
"ovulation" -> Icons.Default.Star
"symptoms" -> Icons.Default.Healing
"medication" -> Icons.Default.LocalPharmacy
"note" -> Icons.Default.Notes
"appointment" -> Icons.Default.Event
else -> Icons.Default.Check
}
}
private fun getEventIconBackgroundColor(type: String): Color {
return when (type.lowercase()) {
"period" -> Color(0xFFE91E63) // Розовый
"ovulation" -> Color(0xFF2196F3) // Голубой
"symptoms" -> Color(0xFFFFC107) // Желтый
"medication" -> Color(0xFF9C27B0) // Фиолетовый
"note" -> Color(0xFF607D8B) // Серо-синий
"appointment" -> Color(0xFF4CAF50) // Зеленый
else -> Color.Gray
}
}
private fun getEventTitle(type: String): String {
return when (type.lowercase()) {
"period" -> "Менструация"
"ovulation" -> "Овуляция"
"symptoms" -> "Симптомы"
"medication" -> "Лекарства"
"note" -> "Заметка"
"appointment" -> "Приём врача"
else -> type.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
}
}
private fun getFlowIntensityLabel(intensity: String): String {
return when (intensity.uppercase()) {
"SPOTTING" -> "Мажущие"
"LIGHT" -> "Легкие"
"MEDIUM" -> "Средние"
"HEAVY" -> "Сильные"
"VERY_HEAVY" -> "Очень сильные"
else -> intensity
}
}
private fun getMoodLabel(mood: String): String {
return when (mood.uppercase()) {
"VERY_HAPPY" -> "Отлично"
"HAPPY" -> "Хорошо"
"NEUTRAL" -> "Нормально"
"NORMAL" -> "Нормально"
"SAD" -> "Грустно"
"VERY_SAD" -> "Очень грустно"
"ANXIOUS" -> "Тревожно"
"IRRITATED" -> "Раздражение"
"IRRITABLE" -> "Раздражительность"
"SENSITIVE" -> "Чувствительность"
"CALM" -> "Спокойно"
"ENERGETIC" -> "Энергично"
"TIRED" -> "Устало"
else -> mood
}
}
private fun getSymptomLabel(symptom: String): String {
return when (symptom.uppercase()) {
"CRAMPS" -> "Спазмы"
"HEADACHE" -> "Головная боль"
"BACKACHE" -> "Боль в спине"
"NAUSEA" -> "Тошнота"
"FATIGUE" -> "Усталость"
"BLOATING" -> "Вздутие"
"BREAST_TENDERNESS" -> "Болезненность груди"
"ACNE" -> "Высыпания"
"MOOD_SWINGS" -> "Перепады настроения"
"CRAVINGS" -> "Тяга к еде"
"INSOMNIA" -> "Бессонница"
"DIZZINESS" -> "Головокружение"
"CONSTIPATION" -> "Запор"
"DIARRHEA" -> "Диарея"
"HOT_FLASHES" -> "Приливы"
"SPOTTING" -> "Мажущие выделения"
else -> symptom
}
}

View File

@@ -0,0 +1,38 @@
package com.example.womansafe.ui.icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Spa
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.LocalHospital
import androidx.compose.material.icons.outlined.Opacity
import androidx.compose.material.icons.rounded.BarChart
import androidx.compose.material.icons.rounded.CalendarMonth
import androidx.compose.material.icons.rounded.Event
import androidx.compose.material.icons.rounded.Lightbulb
import androidx.compose.material.icons.rounded.NavigateNext
import androidx.compose.material.icons.rounded.SentimentDissatisfied
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Расширенные иконки для использования в приложении
*/
object CustomIcons {
// Иконки для календаря
val Healing: ImageVector = Icons.Outlined.LocalHospital
val Notes: ImageVector = Icons.Outlined.Description
val WaterDrop: ImageVector = Icons.Default.WaterDrop
val Contacts: ImageVector = Icons.Default.Create
val BarChart: ImageVector = Icons.Rounded.BarChart
val Lightbulb: ImageVector = Icons.Rounded.Lightbulb
val CalendarMonth: ImageVector = Icons.Rounded.CalendarMonth
val LocalHospital: ImageVector = Icons.Outlined.LocalHospital
val Dangerous: ImageVector = Icons.Rounded.Warning
val Spa: ImageVector = Icons.Default.Spa
val SentimentDissatisfied: ImageVector = Icons.Rounded.SentimentDissatisfied
val Opacity: ImageVector = Icons.Outlined.Opacity
val Event: ImageVector = Icons.Rounded.Event
val ChevronRight: ImageVector = Icons.Rounded.NavigateNext
}

View File

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

View File

@@ -0,0 +1,487 @@
package com.example.womansafe.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.example.womansafe.data.model.calendar.*
import com.example.womansafe.ui.icons.CustomIcons
import com.example.womansafe.ui.viewmodel.CalendarViewModel
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.*
/**
* Экран для добавления/редактирования записей в календаре
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarEntryScreen(
viewModel: CalendarViewModel,
selectedDate: LocalDate,
existingEntry: CalendarEntry? = null,
onClose: () -> Unit
) {
// Форматтер для отображения даты
val dateFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale("ru"))
// Состояние для полей формы
var entryType by remember { mutableStateOf(existingEntry?.entryType ?: EntryType.PERIOD) }
var flowIntensity by remember { mutableStateOf(existingEntry?.flowIntensity ?: FlowIntensity.MEDIUM) }
var mood by remember { mutableStateOf(existingEntry?.mood ?: Mood.NORMAL) }
var energyLevel by remember { mutableStateOf(existingEntry?.energyLevel ?: 3) }
var sleepHours by remember { mutableStateOf(existingEntry?.sleepHours?.toString() ?: "8.0") }
var selectedSymptoms by remember { mutableStateOf(existingEntry?.symptoms?.toMutableList() ?: mutableListOf()) }
var medications by remember { mutableStateOf(existingEntry?.medications?.joinToString(", ") ?: "") }
var notes by remember { mutableStateOf(existingEntry?.notes ?: "") }
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = if (existingEntry != null) "Редактировать запись" else "Новая запись"
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Закрыть")
}
},
actions = {
IconButton(
onClick = {
// Создаем или обновляем запись
val entry = existingEntry?.copy(
entryDate = selectedDate,
entryType = entryType,
flowIntensity = if (entryType == EntryType.PERIOD) flowIntensity else null,
mood = mood,
energyLevel = energyLevel,
sleepHours = sleepHours.toFloatOrNull(),
symptoms = selectedSymptoms,
medications = if (medications.isBlank()) null else medications.split(",").map { it.trim() },
notes = notes.ifBlank { null }
) ?: CalendarEntry(
userId = "", // будет заменено в репозитории
entryDate = selectedDate,
entryType = entryType,
flowIntensity = if (entryType == EntryType.PERIOD) flowIntensity else null,
mood = mood,
energyLevel = energyLevel,
sleepHours = sleepHours.toFloatOrNull(),
symptoms = if (selectedSymptoms.isEmpty()) null else selectedSymptoms,
medications = if (medications.isBlank()) null else medications.split(",").map { it.trim() },
notes = notes.ifBlank { null },
entryId = existingEntry?.entryId ?: 0L
)
if (existingEntry != null) {
viewModel.updateCalendarEntry(entry)
} else {
viewModel.addCalendarEntry(entry)
}
onClose()
}
) {
Icon(Icons.Default.Check, contentDescription = "Сохранить")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
) {
// Заголовок с датой
Text(
text = selectedDate.format(dateFormatter),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 16.dp)
)
// Выбор типа записи
Text(
text = "Тип записи",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
EntryTypeSelector(
selectedType = entryType,
onTypeSelected = { entryType = it }
)
Spacer(modifier = Modifier.height(16.dp))
// Показываем дополнительные поля в зависимости от типа записи
when (entryType) {
EntryType.PERIOD -> {
FlowIntensitySelector(
selectedIntensity = flowIntensity,
onIntensitySelected = { flowIntensity = it }
)
Spacer(modifier = Modifier.height(16.dp))
}
else -> { /* Другие поля будут отображаться всегда */ }
}
// Настроение
Text(
text = "Настроение",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
MoodSelector(
selectedMood = mood,
onMoodSelected = { mood = it }
)
Spacer(modifier = Modifier.height(16.dp))
// Уровень энергии
Text(
text = "Уровень энергии: $energyLevel",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
Slider(
value = energyLevel.toFloat(),
onValueChange = { energyLevel = it.toInt() },
valueRange = 1f..5f,
steps = 3
)
Spacer(modifier = Modifier.height(16.dp))
// Часы сна
OutlinedTextField(
value = sleepHours,
onValueChange = { sleepHours = it },
label = { Text("Часы сна") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
)
)
Spacer(modifier = Modifier.height(16.dp))
// Симптомы
Text(
text = "Симптомы",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
SymptomsSelector(
selectedSymptoms = selectedSymptoms,
onSymptomsChanged = { selectedSymptoms = it }
)
Spacer(modifier = Modifier.height(16.dp))
// Лекарства
OutlinedTextField(
value = medications,
onValueChange = { medications = it },
label = { Text("Лекарства (через запятую)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Заметки
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Заметки") },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp),
maxLines = 5
)
Spacer(modifier = Modifier.height(32.dp))
// Кнопка удаления (только для существующих записей)
existingEntry?.let {
OutlinedButton(
onClick = {
viewModel.deleteCalendarEntry(existingEntry.id)
onClose()
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Удалить запись")
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
/**
* Селектор типа записи
*/
@Composable
fun EntryTypeSelector(
selectedType: EntryType,
onTypeSelected: (EntryType) -> Unit
) {
val entryTypes = listOf(
EntryType.PERIOD to "Менструация",
EntryType.OVULATION to "Овуляция",
EntryType.SYMPTOMS to "Симптомы",
EntryType.MEDICATION to "Лекарства",
EntryType.NOTE to "Заметка"
)
Row(
modifier = Modifier
.fillMaxWidth()
.selectableGroup()
.padding(vertical = 8.dp)
) {
entryTypes.forEach { (type, label) ->
Row(
modifier = Modifier
.weight(1f)
.selectable(
selected = type == selectedType,
onClick = { onTypeSelected(type) },
role = Role.RadioButton
)
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
RadioButton(
selected = type == selectedType,
onClick = null
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
}
/**
* Селектор интенсивности менструации
*/
@Composable
fun FlowIntensitySelector(
selectedIntensity: FlowIntensity,
onIntensitySelected: (FlowIntensity) -> Unit
) {
val intensities = listOf(
FlowIntensity.LIGHT to "Легкая",
FlowIntensity.MEDIUM to "Средняя",
FlowIntensity.HEAVY to "Сильная"
)
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Интенсивность",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.selectableGroup()
.padding(vertical = 8.dp)
) {
intensities.forEach { (intensity, label) ->
Row(
modifier = Modifier
.weight(1f)
.selectable(
selected = intensity == selectedIntensity,
onClick = { onIntensitySelected(intensity) },
role = Role.RadioButton
)
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
RadioButton(
selected = intensity == selectedIntensity,
onClick = null
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
}
}
/**
* Селектор настроения
*/
@Composable
fun MoodSelector(
selectedMood: Mood,
onMoodSelected: (Mood) -> Unit
) {
// Создаем список троек (настроение, метка, иконка) вместо использования вложенных Pair
val moods = listOf(
Triple(Mood.HAPPY, "Счастливое", Icons.Default.Mood),
Triple(Mood.NORMAL, "Обычное", Icons.Default.Face),
Triple(Mood.SAD, "Грустное", CustomIcons.SentimentDissatisfied),
Triple(Mood.IRRITATED, "Раздражение", CustomIcons.Dangerous),
Triple(Mood.ANXIOUS, "Тревожное", Icons.Default.Warning),
Triple(Mood.SENSITIVE, "Чувствительное", Icons.Default.Favorite),
Triple(Mood.CALM, "Спокойное", CustomIcons.Spa)
)
Column(modifier = Modifier.fillMaxWidth()) {
LazyRow {
items(moods.size) { index ->
val (mood, label, icon) = moods[index]
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(horizontal = 12.dp)
.selectable(
selected = mood == selectedMood,
onClick = { onMoodSelected(mood) },
role = Role.RadioButton
)
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier
.size(48.dp)
.padding(8.dp),
tint = if (mood == selectedMood)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = if (mood == selectedMood)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
/**
* Селектор симптомов
*/
@Composable
fun SymptomsSelector(
selectedSymptoms: List<Symptom>,
onSymptomsChanged: (MutableList<Symptom>) -> Unit
) {
val symptoms = listOf(
Symptom.CRAMPS to "Спазмы",
Symptom.HEADACHE to "Головная боль",
Symptom.BLOATING to "Вздутие",
Symptom.BACKACHE to "Боль в спине",
Symptom.FATIGUE to "Усталость",
Symptom.NAUSEA to "Тошнота",
Symptom.BREAST_TENDERNESS to "Боль в груди",
Symptom.ACNE to "Акне",
Symptom.CRAVINGS to "Тяга к еде",
Symptom.INSOMNIA to "Бессонница",
Symptom.DIZZINESS to "Головокружение",
Symptom.DIARRHEA to "Диарея",
Symptom.CONSTIPATION to "Запор"
)
Column {
symptoms.chunked(2).forEach { row ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
row.forEach { (symptom, label) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
) {
Checkbox(
checked = selectedSymptoms.contains(symptom),
onCheckedChange = { checked ->
val newList = selectedSymptoms.toMutableList()
if (checked) {
newList.add(symptom)
} else {
newList.remove(symptom)
}
onSymptomsChanged(newList)
}
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}

View File

@@ -0,0 +1,542 @@
package com.example.womansafe.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Insights
import androidx.compose.material.icons.filled.Medication
import androidx.compose.material.icons.filled.Mood
import androidx.compose.material.icons.filled.TipsAndUpdates
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.Favorite
import androidx.compose.material.icons.outlined.LocalHospital
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.womansafe.data.model.calendar.CycleData
import com.example.womansafe.data.model.calendar.HealthInsight
import com.example.womansafe.data.model.calendar.InsightType
import com.example.womansafe.data.model.calendar.ConfidenceLevel
import com.example.womansafe.data.model.calendar.Mood
import com.example.womansafe.ui.viewmodel.CalendarViewModel
import java.time.format.DateTimeFormatter
import java.util.*
/**
* Экран аналитики менструального цикла и инсайтов
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarInsightsScreen(
viewModel: CalendarViewModel = viewModel(),
onBackClick: () -> Unit
) {
val calendarUiState by viewModel.calendarUiState.observeAsState()
val isLoading by viewModel.isLoading.observeAsState(false)
Scaffold(
topBar = {
TopAppBar(
title = { Text("Аналитика и рекомендации") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.Default.ArrowBack, contentDescription = "Назад")
}
}
)
}
) { paddingValues ->
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
CycleStatistics(cycleData = calendarUiState?.cycleData)
}
// Разделитель
item {
Divider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Персонализированные рекомендации",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp)
)
}
// Список инсайтов
calendarUiState?.insights?.let { insights ->
if (insights.isNotEmpty()) {
items(insights) { insight ->
InsightItem(
insight = insight,
onDismiss = { viewModel.dismissInsight(insight.id) }
)
}
} else {
item {
EmptyInsightsMessage()
}
}
} ?: item {
EmptyInsightsMessage()
}
// Добавляем пространство внизу
item {
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
}
/**
* Статистика цикла
*/
@Composable
fun CycleStatistics(cycleData: CycleData?) {
val dateFormatter = DateTimeFormatter.ofPattern("d MMMM", Locale("ru"))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Статистика цикла",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
if (cycleData != null) {
// Основные показатели
StatisticRow(
label = "Средняя длина цикла:",
value = "${cycleData.averageCycleLength} дней"
)
StatisticRow(
label = "Средняя продолжительность менструации:",
value = "${cycleData.averagePeriodLength} дней"
)
StatisticRow(
label = "Регулярность цикла:",
value = "${cycleData.regularityScore}/100"
)
Spacer(modifier = Modifier.height(16.dp))
// Прогнозы
cycleData.nextPeriodPredicted?.let { nextPeriod ->
Text(
text = "Прогноз следующего цикла",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
StatisticRow(
label = "Начало следующего цикла:",
value = nextPeriod.format(dateFormatter)
)
// Прогноз овуляции
cycleData.ovulationDate?.let { ovulationDate ->
StatisticRow(
label = "Ожидаемая овуляция:",
value = ovulationDate.format(dateFormatter)
)
}
// Фертильное окно
if (cycleData.fertileWindowStart != null && cycleData.fertileWindowEnd != null) {
StatisticRow(
label = "Фертильное окно:",
value = "${cycleData.fertileWindowStart.format(dateFormatter)} - ${cycleData.fertileWindowEnd.format(dateFormatter)}"
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Диаграмма цикла
CycleGraph(cycleData = cycleData)
} else {
// Пустое состояние
Text(
text = "Нет данных о цикле. Добавьте информацию о своем цикле для отображения статистики.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/**
* Строка со статистическим показателем
*/
@Composable
fun StatisticRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
}
}
/**
* Упрощенная диаграмма цикла
*/
@Composable
fun CycleGraph(cycleData: CycleData) {
val totalDays = cycleData.cycleLength
val periodDays = cycleData.periodLength
val ovulationDay = cycleData.ovulationDate?.let {
val startDate = cycleData.periodStart ?: cycleData.lastPeriodStartDate
if (startDate != null) {
it.toEpochDay() - startDate.toEpochDay()
} else {
null
}
}?.toInt() ?: (totalDays / 2)
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Текущий цикл",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(24.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
// Отображение фазы менструации
Box(
modifier = Modifier
.fillMaxHeight()
.width(((periodDays.toFloat() / totalDays) * 100).dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFFE57373)) // Красный для менструации
.align(Alignment.CenterStart)
)
// Отображение овуляции
Box(
modifier = Modifier
.size(24.dp)
.offset(x = ((ovulationDay.toFloat() / totalDays) * 100).dp - 12.dp)
.clip(CircleShape)
.background(Color(0xFF64B5F6)) // Синий для овуляции
.align(Alignment.CenterStart)
)
}
Spacer(modifier = Modifier.height(8.dp))
// Легенда
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(Color(0xFFE57373))
)
Text(
text = "Менструация",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(start = 4.dp, end = 16.dp)
)
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(Color(0xFF64B5F6))
)
Text(
text = "Овуляция",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
/**
* Элемент списка инсайтов
*/
@Composable
fun InsightItem(
insight: HealthInsight,
onDismiss: () -> Unit
) {
val (icon, color) = when (insight.insightType) {
InsightType.CYCLE_REGULARITY -> Pair(
Icons.Outlined.CalendarMonth,
MaterialTheme.colorScheme.primary
)
InsightType.PERIOD_LENGTH -> Pair(
Icons.Outlined.Favorite,
Color(0xFFE57373)
)
InsightType.SYMPTOM_PATTERN -> Pair(
Icons.Default.Insights,
MaterialTheme.colorScheme.tertiary
)
InsightType.MOOD_CORRELATION -> Pair(
Icons.Filled.Mood,
Color(0xFF81C784)
)
InsightType.PERIOD_PREDICTION -> Pair(
Icons.Default.DateRange,
Color(0xFF64B5F6)
)
InsightType.HEALTH_TIP -> Pair(
Icons.Default.TipsAndUpdates,
Color(0xFFFFC107)
)
InsightType.MEDICATION_REMINDER -> Pair(
Icons.Default.Medication,
Color(0xFF9C27B0)
)
InsightType.EXERCISE_SUGGESTION -> Pair(
Icons.Default.FitnessCenter,
Color(0xFF607D8B)
)
else -> Pair(Icons.Default.Info, MaterialTheme.colorScheme.outline)
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Заголовок и иконка
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = insight.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
// Индикатор достоверности
ConfidenceBadge(confidence = insight.confidenceLevel)
}
Spacer(modifier = Modifier.height(8.dp))
// Описание инсайта
Text(
text = insight.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
// Рекомендация
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Text(
text = "Рекомендация:",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = insight.recommendation,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
// Информация об источниках данных
Text(
text = "На основе ${insight.dataPointsUsed} записей",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.align(Alignment.End)
)
// Кнопка "скрыть"
TextButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.End)
) {
Text("Скрыть рекомендацию")
}
}
}
}
/**
* Индикатор достоверности инсайта
*/
@Composable
fun ConfidenceBadge(confidence: ConfidenceLevel) {
val (color, text) = when (confidence) {
ConfidenceLevel.HIGH -> Pair(
Color(0xFF4CAF50),
"Высокая точность"
)
ConfidenceLevel.MEDIUM -> Pair(
Color(0xFFFFC107),
"Средняя точность"
)
ConfidenceLevel.LOW -> Pair(
Color(0xFFFF5722),
"Низкая точность"
)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(color.copy(alpha = 0.2f))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = color
)
}
}
/**
* Сообщение при отсутствии инсайтов
*/
@Composable
fun EmptyInsightsMessage() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Insights,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Пока нет персональных рекомендаций",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Продолжайте вести календарь, и мы сможем предоставить вам полезные советы на основе ваших данных",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}

View File

@@ -0,0 +1,224 @@
package com.example.womansafe.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import com.example.womansafe.util.ContactsHelper
import com.example.womansafe.util.PermissionManager
import com.example.womansafe.util.RequestPermissions
import com.example.womansafe.util.fixTouchEvents
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactPickerScreen(
onContactSelected: (ContactsHelper.Contact, String) -> Unit,
onBackPressed: () -> Unit
) {
val context = LocalContext.current
val contactsHelper = remember { ContactsHelper(context) }
val coroutineScope = rememberCoroutineScope()
var contacts by remember { mutableStateOf<List<ContactsHelper.Contact>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var searchQuery by remember { mutableStateOf("") }
var showRelationshipDialog by remember { mutableStateOf<ContactsHelper.Contact?>(null) }
// Фильтрованные контакты на основе поискового запроса
val filteredContacts = remember(contacts, searchQuery) {
if (searchQuery.isBlank()) contacts
else contacts.filter {
it.name.contains(searchQuery, ignoreCase = true) ||
it.phoneNumber.contains(searchQuery)
}
}
// Запрос разрешения на чтение контактов
RequestPermissions(
permissions = listOf(PermissionManager.PERMISSION_CONTACTS),
onAllPermissionsGranted = {
// Загружаем контакты после получения разрешения
coroutineScope.launch {
isLoading = true
contacts = contactsHelper.getContacts()
isLoading = false
}
},
onPermissionDenied = { deniedPermissions ->
// Показываем сообщение, если пользователь отклонил разрешение
isLoading = false
}
)
Scaffold(
topBar = {
TopAppBar(
title = { Text("Выбрать контакт") },
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(Icons.Default.ArrowBack, contentDescription = "Назад")
}
},
actions = {
// Строка поиска
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text("Поиск") },
singleLine = true,
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
modifier = Modifier
.fillMaxWidth(0.7f)
.padding(end = 8.dp)
)
}
)
}
) { paddingValues ->
if (isLoading) {
// Показываем индикатор загрузки
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (contacts.isEmpty()) {
// Показываем сообщение, если контакты не найдены
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text("Контакты не найдены или нет разрешения на доступ к контактам")
}
} else {
// Показываем список контактов
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.fixTouchEvents()
) {
items(filteredContacts) { contact ->
ContactItem(
contact = contact,
onClick = { showRelationshipDialog = contact }
)
}
}
}
}
// Диалог выбора отношения к контакту
showRelationshipDialog?.let { contact ->
var relationship by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showRelationshipDialog = null },
title = { Text("Укажите отношение") },
text = {
Column {
Text("Контакт: ${contact.name}")
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = relationship,
onValueChange = { relationship = it },
label = { Text("Отношение (например: Мама, Папа, Друг)") },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
onContactSelected(contact, relationship)
showRelationshipDialog = null
},
enabled = relationship.isNotBlank()
) {
Text("Добавить")
}
},
dismissButton = {
TextButton(onClick = { showRelationshipDialog = null }) {
Text("Отмена")
}
}
)
}
}
@Composable
fun ContactItem(
contact: ContactsHelper.Contact,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable(onClick = onClick)
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Аватар контакта
val painter = if (contact.photoUri != null) {
rememberAsyncImagePainter(contact.photoUri)
} else {
rememberVectorPainter(Icons.Default.Person)
}
Image(
painter = painter,
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = contact.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = contact.phoneNumber,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
package com.example.womansafe.ui.theme
import androidx.compose.ui.graphics.Color
/**
* Цвета приложения для использования в EnhancedCalendarScreen и других компонентах
*/
object AppColors {
// Основные цвета приложения
val PrimaryPink = Color(0xFFE91E63)
val SecondaryBlue = Color(0xFF2196F3)
val TertiaryGreen = Color(0xFF4CAF50)
// Цвета для индикаторов менструального цикла
val PeriodColor = Color(0xFFE91E63)
val PeriodLightColor = Color(0xFFF8BBD0)
val OvulationColor = Color(0xFF2196F3)
val OvulationLightColor = Color(0xFFBBDEFB)
val FertileColor = Color(0xFF00BCD4)
val FertileLightColor = Color(0xFFB2EBF2)
// Цвета настроений
val HappyColor = Color(0xFF4CAF50)
val NeutralColor = Color(0xFF9E9E9E)
val SadColor = Color(0xFF9C27B0)
val IrritatedColor = Color(0xFFFF5722)
val AnxiousColor = Color(0xFFFFEB3B)
// Цвета для симптомов
val SymptomColor = Color(0xFFFFC107)
val MedicationColor = Color(0xFF673AB7)
val NoteColor = Color(0xFF607D8B)
}

View File

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

View File

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

View File

@@ -0,0 +1,348 @@
package com.example.womansafe.ui.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.womansafe.data.model.calendar.*
import com.example.womansafe.data.repository.CalendarRepository
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import retrofit2.Response
/**
* Состояние UI для экрана календаря
*/
data class EnhancedCalendarUiState(
val isLoading: Boolean = false,
val error: String? = null,
val currentMonth: LocalDate = LocalDate.now(),
val selectedDate: LocalDate = LocalDate.now(),
val events: Map<LocalDate, List<CalendarEvent>> = emptyMap(),
val selectedEvent: CalendarEvent? = null,
val cycleData: CycleData? = null,
val predictions: CyclePrediction? = null,
val statistics: CycleStatistics? = null,
val insights: List<HealthInsight> = emptyList(),
val showEventDialog: Boolean = false,
val showInsightsDialog: Boolean = false,
val showSettingsDialog: Boolean = false,
val editingEvent: CalendarEvent? = null
)
class EnhancedCalendarViewModel(
private val repository: CalendarRepository
) : ViewModel() {
var uiState by mutableStateOf(EnhancedCalendarUiState())
private set
private val userId: String = "userId" // TODO: заменить на реальный userId
init {
loadInitialData()
}
/**
* Загрузка всех необходимых данных при запуске
*/
fun loadInitialData() {
loadCalendarData(LocalDate.now())
loadCycleData()
loadHealthInsights()
}
/**
* Загрузка данных календаря для выбранного месяца
*/
fun loadCalendarData(selectedDate: LocalDate) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, error = null)
try {
val entries = repository.getCalendarEntries()
// Преобразуем CalendarEntry в CalendarEvent
val events = entries.map { convertToCalendarEvent(it) }
// Группируем события по дате
val groupedEvents = events.groupBy { it.date }
uiState = uiState.copy(
events = groupedEvents,
selectedDate = selectedDate,
currentMonth = selectedDate.withDayOfMonth(1),
isLoading = false
)
} catch (e: Exception) {
uiState = uiState.copy(
error = "Ошибка: ${e.message}",
isLoading = false
)
}
}
}
/**
* Конвертирует CalendarEntry в CalendarEvent для совместимости в UI
*/
private fun convertToCalendarEvent(entry: CalendarEntry): CalendarEvent {
return CalendarEvent(
id = entry.id.toString(),
userId = entry.userId,
date = entry.entryDate,
type = entry.entryType.name.lowercase(),
flowIntensity = entry.flowIntensity?.name?.lowercase(),
mood = entry.mood?.name?.lowercase(),
energyLevel = entry.energyLevel,
sleepHours = entry.sleepHours,
symptoms = entry.symptoms?.map { it.name.lowercase() },
medications = entry.medications,
notes = entry.notes,
isDismissed = false
)
}
/**
* Загрузка данных о цикле
*/
private fun loadCycleData() {
viewModelScope.launch {
try {
val cycleData = repository.getCycleData()
uiState = uiState.copy(cycleData = cycleData)
} catch (e: Exception) {
// Ошибка загрузки данных цикла
}
}
}
/**
* Загрузка статистики цикла
*/
private fun loadCycleStatistics() {
viewModelScope.launch {
try {
val response = repository.getCycleStatistics(userId)
if (response.isSuccessful) {
val statistics = response.body()
uiState = uiState.copy(statistics = statistics)
}
} catch (e: Exception) {
// Ошибка загрузки статистики
}
}
}
/**
* Загрузка прогнозов цикла
*/
private fun loadCyclePredictions() {
viewModelScope.launch {
try {
val response = repository.getCyclePredictions(userId)
if (response.isSuccessful) {
val predictions = response.body()
uiState = uiState.copy(predictions = predictions)
} else {
val cachedPredictions = repository.getCachedPredictions(userId)
uiState = uiState.copy(predictions = cachedPredictions)
}
} catch (e: Exception) {
val cachedPredictions = repository.getCachedPredictions(userId)
uiState = uiState.copy(predictions = cachedPredictions)
}
}
}
/**
* Загрузка инсайтов о здоровье
*/
private fun loadHealthInsights() {
viewModelScope.launch {
try {
val response = repository.getHealthInsights()
if (response.isSuccessful) {
val insights = response.body() ?: emptyList()
uiState = uiState.copy(insights = insights)
}
} catch (e: Exception) {
// Ошибка загрузки инсайтов
}
}
}
/**
* Выбор даты
*/
fun selectDate(date: LocalDate) {
uiState = uiState.copy(selectedDate = date)
}
/**
* Изменение отображаемого месяца
*/
fun changeMonth(offset: Int) {
val newCurrentMonth = uiState.currentMonth.plusMonths(offset.toLong())
loadCalendarData(newCurrentMonth)
}
/**
* Выбор события для просмотра деталей
*/
fun selectEvent(event: CalendarEvent) {
uiState = uiState.copy(selectedEvent = event)
}
/**
* Очистка выбранного события
*/
fun clearSelectedEvent() {
uiState = uiState.copy(selectedEvent = null)
}
/**
* Показать диалог добавления/редактирования события
*/
fun showEventDialog(event: CalendarEvent? = null) {
uiState = uiState.copy(
showEventDialog = true,
editingEvent = event
)
}
/**
* Скрыть диалог добавления/редактирования события
*/
fun hideEventDialog() {
uiState = uiState.copy(
showEventDialog = false,
editingEvent = null
)
}
/**
* Показать диалог инсайтов
*/
fun showInsightsDialog() {
uiState = uiState.copy(showInsightsDialog = true)
}
/**
* Скрыть диалог инсайтов
*/
fun hideInsightsDialog() {
uiState = uiState.copy(showInsightsDialog = false)
}
/**
* Показать диалог настроек
*/
fun showSettingsDialog() {
uiState = uiState.copy(showSettingsDialog = true)
}
/**
* Скрыть диалог настроек
*/
fun hideSettingsDialog() {
uiState = uiState.copy(showSettingsDialog = false)
}
/**
* Добавление нового события
*/
fun addCalendarEntry(entry: CalendarEntry) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true)
try {
val result = repository.addCalendarEntry(entry)
if (result.isSuccess) {
loadCalendarData(entry.entryDate)
} else {
uiState = uiState.copy(
error = "Ошибка при добавлении события",
isLoading = false
)
}
} catch (e: Exception) {
uiState = uiState.copy(
error = "Ошибка: ${e.message}",
isLoading = false
)
}
}
}
/**
* Обновление существующего события
*/
fun updateCalendarEntry(entry: CalendarEntry) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true)
try {
val result = repository.updateCalendarEntry(entry)
if (result.isSuccess) {
loadCalendarData(entry.entryDate)
} else {
uiState = uiState.copy(
error = "Ошибка при обновлении события",
isLoading = false
)
}
} catch (e: Exception) {
uiState = uiState.copy(
error = "Ошибка: ${e.message}",
isLoading = false
)
}
}
}
/**
* Удаление события
*/
fun deleteCalendarEntry(entryId: Long) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true)
try {
val result = repository.deleteCalendarEntry(entryId)
if (result.isSuccess) {
loadCalendarData(uiState.selectedDate)
} else {
uiState = uiState.copy(
error = "Ошибка при удалении события",
isLoading = false
)
}
} catch (e: Exception) {
uiState = uiState.copy(
error = "Ошибка: ${e.message}",
isLoading = false
)
}
}
}
/**
* Отметка инсайта как прочитанного/отклоненного
*/
fun dismissInsight(insightId: Long) {
viewModelScope.launch {
try {
repository.dismissInsight(insightId)
loadHealthInsights()
} catch (e: Exception) {
// Обработка ошибки без блокировки интерфейса
}
}
}
/**
* Очистка сообщения об ошибке
*/
fun clearError() {
uiState = uiState.copy(error = null)
}
}

View File

@@ -0,0 +1,107 @@
package com.example.womansafe.util
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.ContactsContract
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Класс для работы с контактами устройства
*/
class ContactsHelper(private val context: Context) {
/**
* Класс модели контакта
*/
data class Contact(
val id: String,
val name: String,
val phoneNumber: String,
val photoUri: Uri? = null
)
/**
* Получение списка контактов устройства
*/
suspend fun getContacts(): List<Contact> = withContext(Dispatchers.IO) {
val contacts = mutableListOf<Contact>()
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
ContactsContract.Contacts.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.Contacts.PHOTO_URI
)
val cursor: Cursor? = context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
projection,
null,
null,
ContactsContract.Contacts.DISPLAY_NAME + " ASC"
)
cursor?.use {
val idColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
val nameColumn = it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
val numberColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
val photoColumn = it.getColumnIndex(ContactsContract.Contacts.PHOTO_URI)
while (it.moveToNext()) {
val id = it.getString(idColumn)
val name = it.getString(nameColumn) ?: "Неизвестно"
val number = it.getString(numberColumn) ?: "Нет номера"
val photoUri = it.getString(photoColumn)?.let { uri -> Uri.parse(uri) }
contacts.add(Contact(id, name, number, photoUri))
}
}
return@withContext contacts.distinctBy { it.id }
}
/**
* Получение контакта по ID
*/
suspend fun getContactById(contactId: String): Contact? = withContext(Dispatchers.IO) {
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
ContactsContract.Contacts.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.Contacts.PHOTO_URI
)
val selection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?"
val selectionArgs = arrayOf(contactId)
val cursor: Cursor? = context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
projection,
selection,
selectionArgs,
null
)
var contact: Contact? = null
cursor?.use {
if (it.moveToFirst()) {
val idColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
val nameColumn = it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
val numberColumn = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
val photoColumn = it.getColumnIndex(ContactsContract.Contacts.PHOTO_URI)
val id = it.getString(idColumn)
val name = it.getString(nameColumn) ?: "Неизвестно"
val number = it.getString(numberColumn) ?: "Нет номера"
val photoUri = it.getString(photoColumn)?.let { uri -> Uri.parse(uri) }
contact = Contact(id, name, number, photoUri)
}
}
return@withContext contact
}
}

View File

@@ -0,0 +1,124 @@
package com.example.womansafe.util
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
/**
* Класс для управления разрешениями в приложении
*/
class PermissionManager(private val context: Context) {
private val preferenceManager = PreferenceManager.getInstance(context)
// Проверка разрешения
fun isPermissionGranted(permission: String): Boolean {
return ContextCompat.checkSelfPermission(
context,
permission
) == PackageManager.PERMISSION_GRANTED
}
// Сохранение статуса разрешения
fun savePermissionStatus(permission: String, granted: Boolean) {
preferenceManager.savePermissionGranted(permission, granted)
}
// Получение сохраненного статуса разрешения
fun getSavedPermissionStatus(permission: String): Boolean {
return preferenceManager.isPermissionGranted(permission)
}
// Получение списка необходимых разрешений
fun getRequiredPermissions(): List<String> {
return listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.CAMERA,
Manifest.permission.READ_CONTACTS
)
}
// Получение непредоставленных разрешений
fun getMissingPermissions(): List<String> {
return getRequiredPermissions().filter { !isPermissionGranted(it) }
}
// Открытие настроек приложения для управления разрешениями
fun openAppSettings() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
val uri = Uri.fromParts("package", context.packageName, null)
intent.data = uri
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
companion object {
// Разрешения
const val PERMISSION_LOCATION = Manifest.permission.ACCESS_FINE_LOCATION
const val PERMISSION_CAMERA = Manifest.permission.CAMERA
const val PERMISSION_CONTACTS = Manifest.permission.READ_CONTACTS
// Синглтон для доступа к PermissionManager
@Volatile private var INSTANCE: PermissionManager? = null
fun getInstance(context: Context): PermissionManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: PermissionManager(context).also { INSTANCE = it }
}
}
}
}
/**
* Composable для запроса разрешений
*/
@Composable
fun RequestPermissions(
permissions: List<String>,
onAllPermissionsGranted: () -> Unit = {},
onPermissionDenied: (List<String>) -> Unit = {}
) {
val context = LocalContext.current
val permissionManager = remember { PermissionManager.getInstance(context) }
// Проверка всех разрешений
val allPermissionsGranted = permissions.all {
permissionManager.isPermissionGranted(it)
}
// Запрашиваем разрешения, если они не предоставлены
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissionsMap ->
// Сохраняем статусы разрешений
permissionsMap.forEach { (permission, granted) ->
permissionManager.savePermissionStatus(permission, granted)
}
// Проверяем, все ли разрешения предоставлены
val deniedPermissions = permissionsMap.filterValues { !it }.keys.toList()
if (deniedPermissions.isEmpty()) {
onAllPermissionsGranted()
} else {
onPermissionDenied(deniedPermissions)
}
}
// Запрашиваем необходимые разрешения при первом запуске composable
LaunchedEffect(permissions) {
if (!allPermissionsGranted) {
launcher.launch(permissions.toTypedArray())
} else {
onAllPermissionsGranted()
}
}
}

View File

@@ -0,0 +1,68 @@
package com.example.womansafe.util
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Класс для управления безопасным хранением данных пользователя
*/
class PreferenceManager(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val sharedPreferences: SharedPreferences = EncryptedSharedPreferences.create(
context,
PREFERENCE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// Сохранение токена
fun saveAuthToken(token: String?) {
sharedPreferences.edit().putString(KEY_AUTH_TOKEN, token).apply()
}
// Получение токена
fun getAuthToken(): String? {
return sharedPreferences.getString(KEY_AUTH_TOKEN, null)
}
// Сохранение разрешений
fun savePermissionGranted(permission: String, granted: Boolean) {
sharedPreferences.edit().putBoolean(permission, granted).apply()
}
// Получение статуса разрешения
fun isPermissionGranted(permission: String): Boolean {
return sharedPreferences.getBoolean(permission, false)
}
// Очистка данных при выходе
fun clearAuthData() {
sharedPreferences.edit().remove(KEY_AUTH_TOKEN).apply()
}
companion object {
private const val PREFERENCE_NAME = "woman_safe_prefs"
private const val KEY_AUTH_TOKEN = "auth_token"
// Ключи для отслеживания разрешений
const val PERMISSION_LOCATION = "permission_location"
const val PERMISSION_CAMERA = "permission_camera"
const val PERMISSION_CONTACTS = "permission_contacts"
// Синглтон для доступа к PreferenceManager
@Volatile private var INSTANCE: PreferenceManager? = null
fun getInstance(context: Context): PreferenceManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: PreferenceManager(context.applicationContext).also { INSTANCE = it }
}
}
}
}

View File

@@ -0,0 +1,25 @@
package com.example.womansafe.util
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.pointer.pointerInput
/**
* Модификатор, который исправляет проблему с ACTION_HOVER_EXIT в Jetpack Compose
*/
fun Modifier.fixTouchEvents(): Modifier = composed {
val interactionSource = remember { MutableInteractionSource() }
this.pointerInput(interactionSource) {
awaitEachGesture {
// Просто ждем первое нажатие и отпускание, чтобы гарантировать правильную очистку событий
awaitFirstDown(requireUnconsumed = false)
waitForUpOrCancellation()
}
}
}

View File

@@ -0,0 +1,59 @@
package com.example.womansafe.utils
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue
/**
* Вспомогательный класс для работы с датами
*/
object DateUtils {
/**
* Форматтер для отображения дат в удобном формате
*/
val READABLE_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
/**
* Форматтер для отображения месяца и года
*/
val MONTH_YEAR_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MMMM yyyy")
/**
* Проверяет, находится ли дата в указанном диапазоне (включая начало и конец)
*/
fun isDateInRange(date: LocalDate, start: LocalDate?, end: LocalDate?): Boolean {
if (start == null || end == null) return false
return (date.isEqual(start) || date.isAfter(start)) &&
(date.isEqual(end) || date.isBefore(end))
}
/**
* Вычисляет количество дней между двумя датами
*/
fun daysBetween(start: LocalDate, end: LocalDate): Long {
return ChronoUnit.DAYS.between(start, end).absoluteValue
}
/**
* Проверяет, является ли дата сегодняшней
*/
fun isToday(date: LocalDate): Boolean {
return date.isEqual(LocalDate.now())
}
/**
* Форматирует дату в читаемый формат
*/
fun formatReadableDate(date: LocalDate?): String {
return date?.format(READABLE_DATE_FORMATTER) ?: "Нет данных"
}
/**
* Форматирует месяц и год
*/
fun formatMonthYear(date: LocalDate): String {
return date.format(MONTH_YEAR_FORMATTER)
}
}

View File

@@ -2,5 +2,4 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

View File

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