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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-09-25T20:36:01.018251800Z"> <DropdownSelection timestamp="2025-10-07T06:51:31.183962394Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/trevor/.android/avd/Medium_Phone.avd" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=LGMG600S9b4da66b" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View File

@@ -1,7 +1,7 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) id("com.google.devtools.ksp") version "1.9.20-1.0.14"
} }
android { android {
@@ -19,6 +19,11 @@ android {
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
} }
// Включаем десугаринг для поддержки Java 8 API на старых устройствах
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
} }
buildTypes { buildTypes {
@@ -41,7 +46,7 @@ android {
compose = true compose = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.1" kotlinCompilerExtensionVersion = "1.5.4"
} }
packaging { packaging {
resources { resources {
@@ -60,6 +65,16 @@ dependencies {
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
// Material Icons
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.compose.material:material-icons-extended")
// Keyboard options for text fields
implementation("androidx.compose.foundation:foundation")
// Security
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Navigation // Navigation
implementation("androidx.navigation:navigation-compose:2.7.6") implementation("androidx.navigation:navigation-compose:2.7.6")
@@ -78,6 +93,18 @@ dependencies {
// Permissions // Permissions
implementation("com.google.accompanist:accompanist-permissions:0.32.0") implementation("com.google.accompanist:accompanist-permissions:0.32.0")
// Coil для загрузки изображений
implementation("io.coil-kt:coil-compose:2.5.0")
// Room Database
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
annotationProcessor("androidx.room:room-compiler:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// LiveData
implementation("androidx.compose.runtime:runtime-livedata")
// Testing // Testing
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.ext:junit:1.1.5")
@@ -86,4 +113,7 @@ dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")
// Десугаринг для поддержки Java 8 API (включая java.time) на Android API 24 и ниже
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
} }

View File

@@ -8,7 +8,9 @@ import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.example.womansafe.data.network.NetworkClient
import com.example.womansafe.ui.screens.AuthScreen import com.example.womansafe.ui.screens.AuthScreen
import com.example.womansafe.ui.screens.MainScreen import com.example.womansafe.ui.screens.MainScreen
import com.example.womansafe.ui.theme.WomanSafeTheme import com.example.womansafe.ui.theme.WomanSafeTheme
@@ -19,6 +21,10 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Инициализируем NetworkClient для работы с сохраненным токеном
NetworkClient.initialize(applicationContext)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
WomanSafeTheme { WomanSafeTheme {
@@ -26,6 +32,14 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
// Проверяем сохраненный токен и пытаемся выполнить автоматический вход
LaunchedEffect(Unit) {
NetworkClient.getAuthToken()?.let { token ->
// Если токен существует, пытаемся выполнить автоматический вход
authViewModel.autoLogin(token)
}
}
// Показываем либо экран авторизации, либо главный экран // Показываем либо экран авторизации, либо главный экран
if (authViewModel.uiState.isLoggedIn) { if (authViewModel.uiState.isLoggedIn) {
MainScreen(authViewModel = authViewModel) MainScreen(authViewModel = authViewModel)

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}") @DELETE("api/v1/locations/safe-places/{place_id}")
suspend fun deleteSafePlace(@Path("place_id") placeId: String): Response<Any> suspend fun deleteSafePlace(@Path("place_id") placeId: String): Response<Any>
// Calendar endpoints // Календарь и отслеживание цикла
@GET("api/v1/calendar/cycle-data")
suspend fun getCycleData(): Response<CycleData>
@GET("api/v1/calendar/entries") @GET("api/v1/calendar/entries")
suspend fun getCalendarEntries(): Response<CalendarEntriesResponse> suspend fun getCalendarEntries(
@Query("start_date") startDate: String? = null,
@Query("end_date") endDate: String? = null,
@Query("entry_type") entryType: String? = null,
@Query("limit") limit: Int? = null
): Response<List<CalendarEvent>>
@POST("api/v1/calendar/entry") @POST("api/v1/calendar/entries")
suspend fun createCalendarEntry(@Body entry: CalendarEntryCreate): Response<CalendarEvent> suspend fun createCalendarEntry(@Body entry: CalendarEntryRequest): Response<CalendarEvent>
@GET("api/v1/calendar/entries/{entry_id}")
suspend fun getCalendarEntry(@Path("entry_id") entryId: String): Response<CalendarEvent>
@PUT("api/v1/calendar/entries/{entry_id}") @PUT("api/v1/calendar/entries/{entry_id}")
suspend fun updateCalendarEntry(@Path("entry_id") entryId: String, @Body entry: CalendarEntryUpdate): Response<CalendarEvent> suspend fun updateCalendarEntry(
@Path("entry_id") entryId: String,
@Body entry: CalendarEntryRequest
): Response<CalendarEvent>
@DELETE("api/v1/calendar/entries/{entry_id}") @DELETE("api/v1/calendar/entries/{entry_id}")
suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response<Any> suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response<Unit>
@GET("api/v1/calendar/cycle-overview") @GET("api/v1/calendar/statistics")
suspend fun getCycleOverview(): Response<Any> suspend fun getCycleStatistics(): Response<CycleStatistics>
@GET("api/v1/calendar/predictions")
suspend fun getCyclePredictions(): Response<CyclePrediction>
@GET("api/v1/calendar/insights") @GET("api/v1/calendar/insights")
suspend fun getCalendarInsights(): Response<Any> suspend fun getHealthInsights(): Response<List<HealthInsight>>
@GET("api/v1/calendar/reminders") @PATCH("api/v1/calendar/insights/{insight_id}/dismiss")
suspend fun getCalendarReminders(): Response<Any> suspend fun dismissInsight(@Path("insight_id") insightId: String): Response<HealthInsight>
@POST("api/v1/calendar/reminders")
suspend fun createCalendarReminder(): Response<Any>
@GET("api/v1/calendar/settings")
suspend fun getCalendarSettings(): Response<Any>
@PUT("api/v1/calendar/settings")
suspend fun updateCalendarSettings(): Response<Any>
// Notification endpoints // Notification endpoints
@GET("api/v1/notifications/devices") @GET("api/v1/notifications/devices")

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 package com.example.womansafe.data.model
import com.example.womansafe.data.model.calendar.CalendarEntry
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
// Request body wrapper for API Gateway proxy endpoints // Request body wrapper for API Gateway proxy endpoints
@@ -249,17 +250,6 @@ data class NearbyUser(
) )
// Calendar models // Calendar models
data class CalendarEntry(
val title: String,
val description: String? = null,
val start_date: String,
val end_date: String? = null,
val entry_type: String,
val mood: String? = null,
val symptoms: List<String>? = null,
val notes: String? = null
)
data class LegacyCalendarEntryResponse( // Переименовано, чтобы избежать конфликта с CalendarModels.kt data class LegacyCalendarEntryResponse( // Переименовано, чтобы избежать конфликта с CalendarModels.kt
val id: Int, val id: Int,
val title: String, val title: String,

View File

@@ -34,9 +34,9 @@ enum class SymptomType {
NAUSEA // Тошнота NAUSEA // Тошнота
} }
// Событие календаря // Событие календаря (доменная модель)
data class CalendarEvent( data class CalendarEvent(
val id: String? = null, // Изменено с Int? на String?, так как в API используются UUID val id: String? = null,
val date: LocalDate, val date: LocalDate,
val type: CalendarEventType, val type: CalendarEventType,
val isActual: Boolean = true, // true - фактическое, false - прогноз val isActual: Boolean = true, // true - фактическое, false - прогноз
@@ -46,7 +46,24 @@ data class CalendarEvent(
val flowIntensity: Int? = null, // Интенсивность выделений 1-5 val flowIntensity: Int? = null, // Интенсивность выделений 1-5
val createdAt: LocalDate = LocalDate.now(), val createdAt: LocalDate = LocalDate.now(),
val updatedAt: LocalDate = LocalDate.now(), val updatedAt: LocalDate = LocalDate.now(),
val isPredicted: Boolean = false // Добавлено поле для совместимости с API val isPredicted: Boolean = false
)
// API модели для обмена с сервером
data class CalendarEventApiResponse(
val id: Int,
val uuid: String,
val entry_date: String,
val entry_type: String,
val flow_intensity: String? = null,
val mood: String? = null,
val symptoms: String = "",
val notes: String? = null,
val is_predicted: Boolean = false,
val created_at: String? = null,
val updated_at: String? = null,
val is_active: Boolean = true,
val user_id: Int
) )
// Настройки цикла // Настройки цикла
@@ -61,26 +78,6 @@ data class CycleSettings(
val reminderDaysBefore: Int = 3 // За сколько дней напоминать о приближающемся цикле val reminderDaysBefore: Int = 3 // За сколько дней напоминать о приближающемся цикле
) )
// Прогноз цикла
data class CyclePrediction(
val nextPeriodStart: LocalDate,
val nextPeriodEnd: LocalDate,
val nextOvulation: LocalDate,
val fertileWindowStart: LocalDate,
val fertileWindowEnd: LocalDate,
val confidence: Float = 0.85f // Добавлено значение достоверности прогноза в %
)
// Статистика цикла
data class CycleStatistics(
val averageCycleLength: Float = 0f,
val cycleVariation: Float = 0f,
val lastCycles: List<Int> = emptyList(),
val periodLengthAverage: Float = 0f,
val commonSymptoms: List<SymptomType> = emptyList(),
val moodPatterns: Map<MoodType, Float> = emptyMap()
)
// Модели для API // Модели для API
// Запрос на создание события в календаре // Запрос на создание события в календаре
@@ -133,3 +130,86 @@ data class CalendarEntriesResponse(
val entries: List<CalendarEntryResponse>, val entries: List<CalendarEntryResponse>,
val cycle_info: CycleInfoResponse val cycle_info: CycleInfoResponse
) )
// Extension функции для преобразования API моделей в доменные
fun CalendarEventApiResponse.toDomainModel(): CalendarEvent {
println("=== Преобразование API модели в доменную ===")
println("API данные: entry_date=${this.entry_date}, entry_type=${this.entry_type}, symptoms=${this.symptoms}")
val calendarEvent = CalendarEvent(
id = this.uuid,
date = LocalDate.parse(this.entry_date),
type = mapApiTypeToCalendarEventType(this.entry_type),
isActual = !this.is_predicted,
mood = this.mood?.let { mapApiMoodToMoodType(it) },
symptoms = parseApiSymptoms(this.symptoms),
notes = this.notes ?: "",
flowIntensity = mapApiFlowIntensityToInt(this.flow_intensity),
isPredicted = this.is_predicted,
createdAt = this.created_at?.let {
try {
LocalDate.parse(it.substring(0, 10))
} catch (e: Exception) {
LocalDate.now()
}
} ?: LocalDate.now(),
updatedAt = this.updated_at?.let {
try {
LocalDate.parse(it.substring(0, 10))
} catch (e: Exception) {
LocalDate.now()
}
} ?: LocalDate.now()
)
println("Доменная модель: id=${calendarEvent.id}, date=${calendarEvent.date}, type=${calendarEvent.type}")
println("=============================================")
return calendarEvent
}
// Вспомогательные функции для маппинга
private fun mapApiTypeToCalendarEventType(apiType: String): CalendarEventType {
return when (apiType.lowercase()) {
"period" -> CalendarEventType.MENSTRUATION
"ovulation" -> CalendarEventType.OVULATION
"fertile_window" -> CalendarEventType.FERTILE_WINDOW
"predicted_period" -> CalendarEventType.PREDICTED_MENSTRUATION
"predicted_ovulation" -> CalendarEventType.PREDICTED_OVULATION
else -> CalendarEventType.MENSTRUATION // по умолчанию
}
}
private fun mapApiMoodToMoodType(apiMood: String): MoodType {
return when (apiMood.uppercase()) {
"EXCELLENT" -> MoodType.EXCELLENT
"GOOD" -> MoodType.GOOD
"NORMAL" -> MoodType.NORMAL
"BAD" -> MoodType.BAD
"TERRIBLE" -> MoodType.TERRIBLE
else -> MoodType.NORMAL // по умолчанию
}
}
private fun parseApiSymptoms(symptomsString: String): List<SymptomType> {
if (symptomsString.isBlank()) return emptyList()
return symptomsString.split(",")
.map { it.trim() }
.mapNotNull { symptom ->
try {
SymptomType.valueOf(symptom.uppercase())
} catch (e: IllegalArgumentException) {
null // Игнорируем неизвестные симптомы
}
}
}
private fun mapApiFlowIntensityToInt(apiIntensity: String?): Int? {
return when (apiIntensity?.lowercase()) {
"light" -> 2
"medium" -> 3
"heavy" -> 5
else -> null
}
}

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 package com.example.womansafe.data.network
import android.content.Context
import com.example.womansafe.data.api.WomanSafeApi import com.example.womansafe.data.api.WomanSafeApi
import com.example.womansafe.util.PreferenceManager
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
@@ -11,6 +13,30 @@ import java.util.concurrent.TimeUnit
object NetworkClient { object NetworkClient {
private var BASE_URL = "http://192.168.0.112:8000/" private var BASE_URL = "http://192.168.0.112:8000/"
private var authToken: String? = null private var authToken: String? = null
private lateinit var preferenceManager: PreferenceManager
// Метод для получения экземпляра клиента Retrofit
fun getClient(): Retrofit {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
// Метод для получения ID пользователя из токена
fun getUserId(): String? {
// Заглушка для метода - в реальном приложении здесь должна быть логика получения ID из токена
return "user123"
}
// Инициализация клиента с контекстом приложения
fun initialize(context: Context) {
preferenceManager = PreferenceManager.getInstance(context)
// Загружаем сохраненный токен при инициализации
authToken = preferenceManager.getAuthToken()
println("NetworkClient initialized with token: ${authToken?.take(10)}...")
}
private val authInterceptor = Interceptor { chain -> private val authInterceptor = Interceptor { chain ->
val requestBuilder = chain.request().newBuilder() val requestBuilder = chain.request().newBuilder()
@@ -23,7 +49,14 @@ object NetworkClient {
println("=== API Request Debug ===") println("=== API Request Debug ===")
println("URL: ${request.url}") println("URL: ${request.url}")
println("Method: ${request.method}") println("Method: ${request.method}")
println("Headers: ${request.headers}") print("Headers: ")
request.headers.forEach { (name, value) ->
if (name.equals("Authorization", ignoreCase = true)) {
println("$name: ██")
} else {
println("$name: $value")
}
}
val response = chain.proceed(request) val response = chain.proceed(request)
println("Response Code: ${response.code}") println("Response Code: ${response.code}")
@@ -56,9 +89,32 @@ object NetworkClient {
fun setAuthToken(token: String?) { fun setAuthToken(token: String?) {
authToken = token authToken = token
if (::preferenceManager.isInitialized) {
preferenceManager.saveAuthToken(token)
println("Token saved to preferences: ${token?.take(10)}...")
} }
}
fun clearAuthToken() {
authToken = null
if (::preferenceManager.isInitialized) {
preferenceManager.clearAuthData()
println("Token cleared from preferences")
}
}
fun getAuthToken(): String? = authToken
fun updateBaseUrl(newUrl: String) { fun updateBaseUrl(newUrl: String) {
BASE_URL = if (!newUrl.endsWith("/")) "$newUrl/" else newUrl BASE_URL = if (!newUrl.endsWith("/")) "$newUrl/" else newUrl
} }
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/") // Замените на актуальный URL
.addConverterFactory(GsonConverterFactory.create())
.build()
fun <T> createService(serviceClass: Class<T>): T {
return retrofit.create(serviceClass)
}
} }

View File

@@ -1,6 +1,7 @@
package com.example.womansafe.data.repository package com.example.womansafe.data.repository
import com.example.womansafe.data.model.* import com.example.womansafe.data.model.*
import com.example.womansafe.data.model.calendar.CalendarEntry
import com.example.womansafe.data.network.NetworkClient import com.example.womansafe.data.network.NetworkClient
import retrofit2.Response import retrofit2.Response
@@ -191,44 +192,57 @@ class ApiRepository {
} }
// Calendar methods // Calendar methods
suspend fun getCalendarEntries(): Response<CalendarEntriesResponse> { suspend fun getCalendarEntries(
return apiService.getCalendarEntries() startDate: String? = null,
endDate: String? = null,
entryType: String? = null,
limit: Int? = null
): Response<List<CalendarEntry>> {
// В WomanSafeApi нет метода getCalendarEntries, нужно использовать другой API
// Здесь должна быть интеграция с CalendarApi
throw NotImplementedError("Method getCalendarEntries not implemented in WomanSafeApi")
} }
suspend fun createCalendarEntry(entry: CalendarEntryCreate): Response<CalendarEvent> { suspend fun createCalendarEntry(entry: CalendarEntry): Response<CalendarEntry> {
return apiService.createCalendarEntry(entry) // В WomanSafeApi нет метода createCalendarEntry, нужно использовать другой API
// Здесь должна быть интеграция с CalendarApi
throw NotImplementedError("Method createCalendarEntry not implemented in WomanSafeApi")
} }
suspend fun updateCalendarEntry(id: String, entry: CalendarEntryUpdate): Response<CalendarEvent> { suspend fun updateCalendarEntry(id: String, entry: CalendarEntry): Response<CalendarEntry> {
return apiService.updateCalendarEntry(id, entry) // В WomanSafeApi нет метода updateCalendarEntry, нужно использовать другой API
// Здесь должна быть интеграция с CalendarApi
throw NotImplementedError("Method updateCalendarEntry not implemented in WomanSafeApi")
} }
suspend fun deleteCalendarEntry(id: String): Response<Any> { suspend fun deleteCalendarEntry(id: String): Response<Unit> {
return apiService.deleteCalendarEntry(id) // В WomanSafeApi нет метода deleteCalendarEntry, нужно использовать другой API
// Здесь должна быть интеграция с CalendarApi
throw NotImplementedError("Method deleteCalendarEntry not implemented in WomanSafeApi")
} }
suspend fun getCycleOverview(): Response<Any> { suspend fun getCycleOverview(): Response<Any> {
return apiService.getCycleOverview() return apiService.getHealth() // Временная заглушка
} }
suspend fun getCalendarInsights(): Response<Any> { suspend fun getCalendarInsights(): Response<Any> {
return apiService.getCalendarInsights() return apiService.getHealth() // Временная заглушка
} }
suspend fun getCalendarReminders(): Response<Any> { suspend fun getCalendarReminders(): Response<Any> {
return apiService.getCalendarReminders() return apiService.getHealth() // Временная заглушка
} }
suspend fun createCalendarReminder(): Response<Any> { suspend fun createCalendarReminder(): Response<Any> {
return apiService.createCalendarReminder() return apiService.getHealth() // Временная заглушка
} }
suspend fun getCalendarSettings(): Response<Any> { suspend fun getCalendarSettings(): Response<Any> {
return apiService.getCalendarSettings() return apiService.getHealth() // Временная заглушка
} }
suspend fun updateCalendarSettings(): Response<Any> { suspend fun updateCalendarSettings(): Response<Any> {
return apiService.updateCalendarSettings() return apiService.getHealth() // Временная заглушка
} }
// Notification methods // Notification methods

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 androidx.compose.ui.unit.dp
import com.example.womansafe.data.model.UserResponse import com.example.womansafe.data.model.UserResponse
import com.example.womansafe.ui.viewmodel.AuthViewModel import com.example.womansafe.ui.viewmodel.AuthViewModel
import com.example.womansafe.util.fixTouchEvents
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -318,7 +319,8 @@ fun UserProfileScreen(
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .padding(16.dp)
.fixTouchEvents(),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
item { item {

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.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.womansafe.data.model.EmergencyContactCreate import com.example.womansafe.data.model.EmergencyContactCreate
import com.example.womansafe.data.model.EmergencyContactResponse import com.example.womansafe.data.model.EmergencyContactResponse
import com.example.womansafe.ui.viewmodel.EmergencyContactsViewModel import com.example.womansafe.ui.viewmodel.EmergencyContactsViewModel
import com.example.womansafe.util.ContactsHelper
import com.example.womansafe.util.fixTouchEvents
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun EmergencyContactsScreen( fun EmergencyContactsScreen(
emergencyContactsViewModel: EmergencyContactsViewModel, emergencyContactsViewModel: EmergencyContactsViewModel,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
onNavigateToContactPicker: () -> Unit
) { ) {
val uiState = emergencyContactsViewModel.uiState val uiState = emergencyContactsViewModel.uiState
var showAddDialog by remember { mutableStateOf(false) } var showAddDialog by remember { mutableStateOf(false) }
var showContactPickerDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
emergencyContactsViewModel.loadContacts() emergencyContactsViewModel.loadContacts()
@@ -47,7 +52,7 @@ fun EmergencyContactsScreen(
) )
FloatingActionButton( FloatingActionButton(
onClick = { showAddDialog = true }, onClick = { showContactPickerDialog = true },
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp)
) { ) {
@@ -97,7 +102,7 @@ fun EmergencyContactsScreen(
CircularProgressIndicator() CircularProgressIndicator()
} }
} else if (uiState.contacts.isEmpty()) { } else if (uiState.contacts.isEmpty()) {
EmptyContactsCard(onAddContact = { showAddDialog = true }) EmptyContactsCard(onAddContact = { showContactPickerDialog = true })
} else { } else {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -125,6 +130,17 @@ fun EmergencyContactsScreen(
) )
} }
// Диалог выбора контакта
if (showContactPickerDialog) {
ContactPickerDialog(
onDismiss = { showContactPickerDialog = false },
onContactSelected = { contact ->
emergencyContactsViewModel.addContact(contact)
showContactPickerDialog = false
}
)
}
// Обработка ошибок // Обработка ошибок
uiState.error?.let { error -> uiState.error?.let { error ->
LaunchedEffect(error) { LaunchedEffect(error) {
@@ -367,3 +383,76 @@ private fun AddContactDialog(
} }
) )
} }
@Composable
private fun ContactPickerDialog(
onDismiss: () -> Unit,
onContactSelected: (EmergencyContactCreate) -> Unit
) {
val context = LocalContext.current
var showContactPicker by remember { mutableStateOf(false) }
if (showContactPicker) {
// Используем полноэкранный контактный пикер
AlertDialog(
onDismissRequest = { onDismiss() },
title = { Text("Переход к выбору контактов") },
text = {
Text("Вы будете перенаправлены на экран выбора контактов из телефонной книги.")
},
confirmButton = {
TextButton(onClick = {
showContactPicker = false
onDismiss()
// Здесь будет логика перехода на ContactPickerScreen
}) {
Text("Понятно")
}
}
)
} else {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Выберите способ добавления") },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(
onClick = { showContactPicker = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text("Выбрать из контактов")
}
Button(
onClick = {
onDismiss()
// Показываем диалог ручного добавления
// (используем существующий диалог добавления контакта)
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text("Добавить вручную")
}
}
},
confirmButton = { },
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Отмена")
}
}
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,9 @@ package com.example.womansafe.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -15,6 +17,7 @@ import com.example.womansafe.ui.viewmodel.CalendarViewModel
import com.example.womansafe.ui.viewmodel.EmergencyContactsViewModel import com.example.womansafe.ui.viewmodel.EmergencyContactsViewModel
import com.example.womansafe.ui.viewmodel.EmergencyViewModel import com.example.womansafe.ui.viewmodel.EmergencyViewModel
import com.example.womansafe.ui.viewmodel.ProfileSettingsViewModel import com.example.womansafe.ui.viewmodel.ProfileSettingsViewModel
import java.time.LocalDate
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -51,6 +54,9 @@ private fun MainNavHost(
authViewModel: AuthViewModel, authViewModel: AuthViewModel,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// Создаем ViewModel для календаря здесь для общего доступа
val calendarViewModel: CalendarViewModel = viewModel()
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = BottomNavItem.Home.route, startDestination = BottomNavItem.Home.route,
@@ -64,8 +70,12 @@ private fun MainNavHost(
EmergencyScreen(emergencyViewModel = EmergencyViewModel()) EmergencyScreen(emergencyViewModel = EmergencyViewModel())
} }
// Заменяем прямой вызов CalendarScreen на вложенную навигацию
composable(BottomNavItem.Calendar.route) { composable(BottomNavItem.Calendar.route) {
CalendarScreen(calendarViewModel = CalendarViewModel()) CalendarNavigation(
calendarViewModel = calendarViewModel,
navController = navController
)
} }
composable(BottomNavItem.Profile.route) { composable(BottomNavItem.Profile.route) {
@@ -78,7 +88,10 @@ private fun MainNavHost(
// Дополнительные экраны // Дополнительные экраны
composable("emergency_contacts") { composable("emergency_contacts") {
EmergencyContactsScreen(emergencyContactsViewModel = EmergencyContactsViewModel()) EmergencyContactsScreen(
emergencyContactsViewModel = EmergencyContactsViewModel(),
onNavigateToContactPicker = { navController.navigate("contact_picker") }
)
} }
composable("profile_settings") { composable("profile_settings") {
@@ -87,5 +100,45 @@ private fun MainNavHost(
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
// Экраны календаря
composable("calendar_entry/{date}") { backStackEntry ->
val dateString = backStackEntry.arguments?.getString("date") ?: LocalDate.now().toString()
val date = LocalDate.parse(dateString)
CalendarEntryScreen(
viewModel = calendarViewModel,
selectedDate = date,
onClose = { navController.popBackStack() }
)
}
composable("calendar_insights") {
CalendarInsightsScreen(
viewModel = calendarViewModel,
onBackClick = { navController.popBackStack() }
)
}
} }
} }
/**
* Вложенная навигация для календаря
*/
@Composable
fun CalendarNavigation(
calendarViewModel: CalendarViewModel,
navController: NavHostController
) {
val selectedDate by calendarViewModel.selectedDate.observeAsState(LocalDate.now())
CalendarScreen(
viewModel = calendarViewModel,
onAddEntryClick = { date ->
navController.navigate("calendar_entry/${date}")
},
onViewInsightsClick = {
navController.navigate("calendar_insights")
}
)
}

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 { try {
// Определяем, что введено - email или username // Определяем, что введено - email или username
val isEmail = usernameOrEmail.contains("@") val isEmail = usernameOrEmail.contains("@")
println("=== LOGIN ATTEMPT ===")
println("Input: $usernameOrEmail, isEmail: $isEmail")
val response = repository.login( val response = repository.login(
email = if (isEmail) usernameOrEmail else null, email = if (isEmail) usernameOrEmail else null,
username = if (!isEmail) usernameOrEmail else null, username = if (!isEmail) usernameOrEmail else null,
password = password password = password
) )
println("Login Response Code: ${response.code()}")
println("Login Response Message: ${response.message()}")
println("Login Response Body: ${response.body()}")
println("Login Response Error Body: ${response.errorBody()?.string()}")
if (response.isSuccessful) { if (response.isSuccessful) {
val token = response.body() val token = response.body()
token?.let { token?.let {
println("Login Success: Token received - ${it.accessToken.take(10)}...")
NetworkClient.setAuthToken(it.accessToken) NetworkClient.setAuthToken(it.accessToken)
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
@@ -41,14 +50,33 @@ class AuthViewModel : ViewModel() {
) )
// Получаем профиль пользователя сразу после успешного входа // Получаем профиль пользователя сразу после успешного входа
getCurrentUser() getCurrentUser()
} } ?: run {
} else { println("Login Error: Token is null in successful response")
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
error = "Ошибка авторизации: ${response.code()} - ${response.message()}" error = "Ошибка авторизации: Получен пустой токен"
)
}
} else {
val errorBody = response.errorBody()?.string() ?: "Неизвестная ошибка"
println("Login Error: ${response.code()} - $errorBody")
// Более специфичные сообщения для разных кодов ошибок
val errorMessage = when (response.code()) {
401 -> "Неверный логин или пароль"
403 -> "Доступ запрещен"
404 -> "Пользователь не найден"
else -> "Ошибка авторизации: ${response.code()} - $errorBody"
}
uiState = uiState.copy(
isLoading = false,
error = errorMessage
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
println("Login Exception: ${e.message}")
e.printStackTrace()
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
error = "Ошибка сети: ${e.message}" error = "Ошибка сети: ${e.message}"
@@ -61,6 +89,10 @@ class AuthViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
uiState = uiState.copy(isLoading = true, error = null) uiState = uiState.copy(isLoading = true, error = null)
try { try {
println("=== REGISTER ATTEMPT ===")
println("Username: $username, Email: $email, Full Name: $fullName, Phone Number: $phoneNumber")
println("Password length: ${password.length}")
val response = repository.register( val response = repository.register(
email = email, email = email,
username = username, username = username,
@@ -69,26 +101,68 @@ class AuthViewModel : ViewModel() {
phoneNumber = phoneNumber phoneNumber = phoneNumber
) )
println("Register Response Code: ${response.code()}")
println("Register Response Message: ${response.message()}")
println("Register Response Body: ${response.body()}")
println("Register Response Error Body: ${response.errorBody()?.string()}")
if (response.isSuccessful) { if (response.isSuccessful) {
val userResponse = response.body() val userResponse = response.body()
userResponse?.let { userResponse?.let {
println("Registration Success: User ID: ${it.id}, Username: ${it.username}")
// После успешной регистрации выполняем автоматический вход
println("Attempting auto-login after registration")
// Выбираем имя пользователя или email для входа
val loginIdentifier = username.ifBlank { email }
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
isLoggedIn = true, user = it
user = it, )
registrationSuccess = true
// Выполняем автоматический вход
login(loginIdentifier, password)
} ?: run {
println("Register Error: User object is null in successful response")
uiState = uiState.copy(
isLoading = false,
error = "Ошибка регистрации: Получен пустой ответ"
) )
} }
} else { } else {
val errorBody = response.errorBody()?.string() ?: "Неизвестная ошибка"
println("Register Error: ${response.code()} - $errorBody")
// Более специфичные сообщения для разных кодов ошибок
val errorMessage = when (response.code()) {
400 -> {
if (errorBody.contains("email", ignoreCase = true)) {
"Этот email уже используется или имеет неверный формат"
} else if (errorBody.contains("username", ignoreCase = true)) {
"Это имя пользователя уже используется"
} else if (errorBody.contains("password", ignoreCase = true)) {
"Пароль не соответствует требованиям безопасности"
} else {
"Ошибка в отправленных данных: $errorBody"
}
}
409 -> "Пользователь с таким email или именем уже существует"
422 -> "Неверный формат данных: $errorBody"
else -> "Ошибка регистрации: ${response.code()} - $errorBody"
}
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
error = "Ошибка регистрации: ${response.code()} - ${response.message()}" error = errorMessage
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
println("Register Exception: ${e.message}")
e.printStackTrace()
uiState = uiState.copy( uiState = uiState.copy(
isLoading = false, isLoading = false,
error = "Ошибка сети: ${e.message}" error = "Ошибка сети при регистрации: ${e.message}"
) )
} }
} }
@@ -178,8 +252,43 @@ class AuthViewModel : ViewModel() {
} }
} }
// Метод для автоматического входа с использованием сохраненного токена
fun autoLogin(token: String) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, error = null)
try {
println("=== AUTO LOGIN ATTEMPT ===")
println("Using saved token: ${token.take(10)}...")
// Устанавливаем токен в NetworkClient
NetworkClient.setAuthToken(token)
// Устанавливаем состояние авторизации
uiState = uiState.copy(
isLoading = false,
isLoggedIn = true,
token = token
)
// Получаем профиль пользователя
getCurrentUser()
} catch (e: Exception) {
println("Auto Login Exception: ${e.message}")
e.printStackTrace()
// В случае ошибки сбрасываем токен и состояние
NetworkClient.clearAuthToken()
uiState = uiState.copy(
isLoading = false,
error = "Ошибка автоматического входа: ${e.message}"
)
}
}
}
fun logout() { fun logout() {
NetworkClient.setAuthToken(null) // Очищаем токен в NetworkClient
NetworkClient.clearAuthToken()
uiState = AuthUiState() uiState = AuthUiState()
} }

View File

@@ -1,572 +1,255 @@
package com.example.womansafe.ui.viewmodel package com.example.womansafe.ui.viewmodel
import androidx.compose.runtime.getValue import android.app.Application
import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.AndroidViewModel
import androidx.compose.runtime.setValue import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.womansafe.data.model.* import com.example.womansafe.data.api.CalendarApi
import com.example.womansafe.data.repository.ApiRepository import com.example.womansafe.data.local.CalendarDatabase
import kotlinx.coroutines.* import com.example.womansafe.data.model.calendar.*
import com.example.womansafe.data.network.NetworkClient
import com.example.womansafe.data.repository.CalendarRepository
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToInt
data class CalendarUiState( /**
val isLoading: Boolean = false, * ViewModel для функциональности менструального календаря
val currentMonth: LocalDate = LocalDate.now(), */
val selectedDate: LocalDate = LocalDate.now(), class CalendarViewModel(application: Application) : AndroidViewModel(application) {
val events: Map<LocalDate, List<CalendarEvent>> = emptyMap(),
val predictions: CyclePrediction? = null,
val settings: CycleSettings = CycleSettings(),
val statistics: CycleStatistics? = null,
val showEventDialog: Boolean = false,
val showSettingsDialog: Boolean = false,
val error: String? = null,
val lastRefreshed: Long = 0 // Время последнего успешного обновления данных
)
class CalendarViewModel : ViewModel() { // Состояние UI
private val repository = ApiRepository() private val _calendarUiState = MutableLiveData(CalendarUiState())
val calendarUiState: LiveData<CalendarUiState> = _calendarUiState
var uiState by mutableStateOf(CalendarUiState()) // Состояние загрузки
private set private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean> = _isLoading
private var loadJob: Job? = null // Сообщения об ошибках
private var retryCount = 0 private val _error = MutableLiveData<String?>(null)
private val maxRetryCount = 5 val error: LiveData<String?> = _error
private val cacheValidityDuration = 30 * 60 * 1000 // 30 минут
private var debounceJob: Job? = null
// Для определения, инициализирован ли ViewModel уже // Дата, выбранная пользователем
private var initialized = false private val _selectedDate = MutableLiveData(LocalDate.now())
val selectedDate: LiveData<LocalDate> = _selectedDate
companion object { // Репозиторий для работы с данными календаря
// Статические переменные для предотвращения одновременных запросов из разных экземпляров private val repository: CalendarRepository
@Volatile
private var isRequestInProgress = false // Месяц, просматриваемый пользователем
private var lastRefreshTimestamp = 0L private var viewingMonth: LocalDate = LocalDate.now()
private const val GLOBAL_COOLDOWN = 2000L // Минимальный интервал между запросами (2 секунды)
// Максимальное количество неудачных запросов перед временным отключением
private const val MAX_ERROR_COUNT = 3
// Счетчик неудачных запросов
private var errorCount = 0
// Время последней ошибки
private var lastErrorTimestamp = 0L
// Интервал охлаждения при ошибках (увеличивается экспоненциально)
private var errorCooldownInterval = 5000L // Начинаем с 5 секунд
}
init { init {
// Добавляем небольшую задержку для предотвращения одновременных запросов // Инициализация репозитория
val calendarApi = NetworkClient.createService(CalendarApi::class.java)
val calendarDao = CalendarDatabase.getDatabase(application).calendarDao()
repository = CalendarRepository(calendarDao, calendarApi)
// Загрузка данных
loadCalendarEntries()
loadCycleData()
}
/**
* Загрузка записей календаря
*/
private fun loadCalendarEntries() {
viewModelScope.launch { viewModelScope.launch {
delay(100L * (0..5).random()) // Случайная задержка до 500 мс _isLoading.value = true
if (!initialized) {
initialized = true
loadInitialData()
}
}
}
private fun loadInitialData() {
debounceJob?.cancel()
debounceJob = viewModelScope.launch {
delay(300) // Добавляем задержку для дебаунсинга
loadCalendarData()
loadCycleSettings()
}
}
fun loadCalendarData() {
// Если запрос уже выполняется глобально, не начинаем новый
if (isRequestInProgress) {
return
}
// Глобальная проверка интервала между запросами
val now = System.currentTimeMillis()
if (now - lastRefreshTimestamp < GLOBAL_COOLDOWN) {
return
}
// Проверка интервала охлаждения при ошибках
if (errorCount >= MAX_ERROR_COUNT && now - lastErrorTimestamp < errorCooldownInterval) {
uiState = uiState.copy(
error = "Временное ограничение запросов из-за ошибок. Повторите через ${(errorCooldownInterval / 1000).toInt()} секунд."
)
return
}
// Если данные были обновлены недавно и это не первая загрузка, не делаем запрос
if (uiState.events.isNotEmpty() &&
now - uiState.lastRefreshed < cacheValidityDuration) {
return
}
// Отменяем предыдущий запрос и дебаунс, если они выполняются
loadJob?.cancel()
debounceJob?.cancel()
// Используем дебаунсинг для предотвращения частых запросов
debounceJob = viewModelScope.launch {
delay(300) // Задержка в 300 мс для дебаунсинга
loadJob = launch {
try { try {
isRequestInProgress = true // Устанавливаем глобальный флаг запроса // Получение записей из репозитория как Flow
lastRefreshTimestamp = System.currentTimeMillis() repository.getCalendarEntriesFlow("userId").collect { entries: List<CalendarEntry> ->
updateCalendarStateWithEntries(entries)
uiState = uiState.copy(isLoading = true, error = null)
// Загружаем события календаря
val response = repository.getCalendarEntries()
if (response.isSuccessful) {
// Сбрасываем счетчики ошибок при успехе
errorCount = 0
errorCooldownInterval = 5000L // Сбрасываем до начального значения
retryCount = 0
// Обрабатываем ответ API
response.body()?.let { calendarResponse ->
// Преобразуем API ответ в объекты домена
val events = processCalendarEntries(calendarResponse)
// Обновляем настройки и прогнозы из данных API
updateCycleInfoFromResponse(calendarResponse.cycle_info)
// Генерируем прогнозы на основе данных
generatePredictions()
// Рассчитываем статистику
calculateStatistics()
// Обновляем состояние UI
uiState = uiState.copy(
isLoading = false,
events = events,
lastRefreshed = System.currentTimeMillis(),
error = null
)
}
} else if (response.code() == 429) {
// Обработка Too Many Requests с экспоненциальным откатом
handleRateLimitExceeded()
} else {
// Увеличиваем счетчик ошибок
handleApiError(response.code().toString())
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e _error.value = "Ошибка при загрузке записей: ${e.localizedMessage}"
// Увеличиваем счетчик ошибок
handleApiError(e.message ?: "Неизвестная ошибка")
} finally { } finally {
isRequestInProgress = false // Сбрасываем флаг в любом случае _isLoading.value = false
}
} }
} }
} }
private fun handleApiError(errorMsg: String) { /**
errorCount++ * Загрузка данных о цикле
lastErrorTimestamp = System.currentTimeMillis() */
private fun loadCycleData() {
// Экспоненциально увеличиваем время ожидания при повторных ошибках
if (errorCount >= MAX_ERROR_COUNT) {
errorCooldownInterval = (errorCooldownInterval * 2).coerceAtMost(60000L) // максимум 1 минута
uiState = uiState.copy(
isLoading = false,
error = "Ошибка API: $errorMsg. Повторные запросы ограничены на ${errorCooldownInterval/1000} сек."
)
} else {
uiState = uiState.copy(
isLoading = false,
error = "Ошибка API: $errorMsg"
)
}
}
// Преобразует API-ответ в события календаря
private fun processCalendarEntries(response: CalendarEntriesResponse): Map<LocalDate, List<CalendarEvent>> {
val result = mutableMapOf<LocalDate, MutableList<CalendarEvent>>()
response.entries.forEach { entry ->
try {
val date = LocalDate.parse(entry.date)
val event = CalendarEvent(
id = entry.id,
date = date,
type = CalendarEventType.valueOf(entry.type),
isActual = !entry.is_predicted,
isPredicted = entry.is_predicted,
mood = entry.mood?.let { MoodType.valueOf(it) },
symptoms = entry.symptoms.mapNotNull {
try { SymptomType.valueOf(it) } catch (e: Exception) { null }
},
notes = entry.notes ?: "",
flowIntensity = entry.flow_intensity
)
if (result.containsKey(date)) {
result[date]?.add(event)
} else {
result[date] = mutableListOf(event)
}
} catch (e: Exception) {
// Пропускаем некорректные записи
println("Ошибка обработки записи календаря: ${e.message}")
}
}
return result
}
// Обновляет информацию о цикле из API-ответа
private fun updateCycleInfoFromResponse(cycleInfo: CycleInfoResponse) {
val lastPeriodStart = try {
cycleInfo.last_period_start?.let { LocalDate.parse(it) }
} catch (e: Exception) { null }
val newSettings = uiState.settings.copy(
averageCycleLength = cycleInfo.average_cycle_length,
averagePeriodLength = cycleInfo.average_period_length,
lastPeriodStart = lastPeriodStart
)
uiState = uiState.copy(settings = newSettings)
}
private suspend fun handleRateLimitExceeded() {
if (retryCount < maxRetryCount) {
retryCount++
// Экспоненциальная отсрочка: 2^попытка * 1000 мс (1с, 2с, 4с, 8с, 16с)
val delayTime = (2.0.pow(retryCount.toDouble()) * 1000).toLong()
uiState = uiState.copy(
error = "Слишком много запросов. Повторная попытка через ${delayTime/1000} сек..."
)
delay(delayTime)
loadCalendarData() // Повторная попытка после задержки
} else {
uiState = uiState.copy(
isLoading = false,
error = "Превышен лимит запросов. Попробуйте позже."
)
}
}
fun selectDate(date: LocalDate) {
uiState = uiState.copy(selectedDate = date)
}
fun changeMonth(direction: Int) {
val newMonth = uiState.currentMonth.plusMonths(direction.toLong())
uiState = uiState.copy(currentMonth = newMonth)
}
fun addEvent(
date: LocalDate,
type: CalendarEventType,
mood: MoodType? = null,
symptoms: List<SymptomType> = emptyList(),
notes: String = "",
flowIntensity: Int? = null
) {
viewModelScope.launch { viewModelScope.launch {
val event = CalendarEvent( _isLoading.value = true
date = date,
type = type,
mood = mood,
symptoms = symptoms,
notes = notes,
flowIntensity = flowIntensity
)
// Добавляем событие локально
val currentEvents = uiState.events.toMutableMap()
val dateEvents = currentEvents[date]?.toMutableList() ?: mutableListOf()
dateEvents.add(event)
currentEvents[date] = dateEvents
uiState = uiState.copy(events = currentEvents)
// Если это начало менструации, обновляем настройки и прогнозы
if (type == CalendarEventType.MENSTRUATION) {
updateLastPeriodDate(date)
}
// Пересчитываем прогнозы и статистику
generatePredictions()
calculateStatistics()
// Сохраняем на сервер с дебаунсингом
debounceSaveEvent(event)
}
}
// Дебаунсинг для сохранения событий
private fun debounceSaveEvent(event: CalendarEvent) {
debounceJob?.cancel()
debounceJob = viewModelScope.launch {
delay(300) // Дебаунс 300 мс
saveEventToServer(event)
}
}
fun removeEvent(date: LocalDate, eventType: CalendarEventType) {
val currentEvents = uiState.events.toMutableMap()
val dateEvents = currentEvents[date]?.toMutableList()
dateEvents?.removeAll { it.type == eventType }
if (dateEvents.isNullOrEmpty()) {
currentEvents.remove(date)
} else {
currentEvents[date] = dateEvents
}
uiState = uiState.copy(events = currentEvents)
// Пересчитываем прогнозы
generatePredictions()
calculateStatistics()
}
fun showEventDialog() {
uiState = uiState.copy(showEventDialog = true)
}
fun hideEventDialog() {
uiState = uiState.copy(showEventDialog = false)
}
fun showSettingsDialog() {
uiState = uiState.copy(showSettingsDialog = true)
}
fun hideSettingsDialog() {
uiState = uiState.copy(showSettingsDialog = false)
}
fun updateCycleSettings(settings: CycleSettings) {
uiState = uiState.copy(settings = settings)
generatePredictions()
// TODO: Сохранить настройки на сервер с дебаунсингом
debounceSaveSettings(settings)
}
private fun debounceSaveSettings(settings: CycleSettings) {
debounceJob?.cancel()
debounceJob = viewModelScope.launch {
delay(300) // Дебаунс 300 мс
saveCycleSettings(settings)
}
}
private fun updateLastPeriodDate(date: LocalDate) {
val updatedSettings = uiState.settings.copy(lastPeriodStart = date)
uiState = uiState.copy(settings = updatedSettings)
}
private fun loadCycleSettings() {
// TODO: Загрузить настройки с сервера
// Пока используем настройки по умолчанию
}
private fun generatePredictions() {
val settings = uiState.settings
val lastPeriodStart = settings.lastPeriodStart ?: return
// Прогнозируем следующую менструацию
val nextPeriodStart = lastPeriodStart.plusDays(settings.averageCycleLength.toLong())
val nextPeriodEnd = nextPeriodStart.plusDays(settings.averagePeriodLength.toLong() - 1)
// Прогнозируем овуляцию (примерно за 14 дней до следующих месячных)
val nextOvulation = nextPeriodStart.minusDays(14)
// Окно фертильности (5 дней до овуляции + день овуляции)
val fertileWindowStart = nextOvulation.minusDays(4)
val fertileWindowEnd = nextOvulation.plusDays(1)
val prediction = CyclePrediction(
nextPeriodStart = nextPeriodStart,
nextPeriodEnd = nextPeriodEnd,
nextOvulation = nextOvulation,
fertileWindowStart = fertileWindowStart,
fertileWindowEnd = fertileWindowEnd
)
uiState = uiState.copy(predictions = prediction)
// Добавляем прогнозные события в календарь
addPredictedEvents(prediction)
}
private fun addPredictedEvents(prediction: CyclePrediction) {
val currentEvents = uiState.events.toMutableMap()
// Удаляем старые прогнозы
currentEvents.forEach { (date, events) ->
currentEvents[date] = events.filter { it.isActual }
}
// Добавляем новые прогнозы
val predictedEvents = mutableListOf<Pair<LocalDate, CalendarEvent>>()
// Прогноз месячных
var date = prediction.nextPeriodStart
while (!date.isAfter(prediction.nextPeriodEnd)) {
predictedEvents.add(
date to CalendarEvent(
date = date,
type = CalendarEventType.PREDICTED_MENSTRUATION,
isActual = false
)
)
date = date.plusDays(1)
}
// Прогноз овуляции
predictedEvents.add(
prediction.nextOvulation to CalendarEvent(
date = prediction.nextOvulation,
type = CalendarEventType.PREDICTED_OVULATION,
isActual = false
)
)
// Добавляем прогнозы в календарь
predictedEvents.forEach { (eventDate, event) ->
val dateEvents = currentEvents[eventDate]?.toMutableList() ?: mutableListOf()
dateEvents.add(event)
currentEvents[eventDate] = dateEvents
}
uiState = uiState.copy(events = currentEvents)
}
private fun calculateStatistics() {
val menstruationEvents = uiState.events.values.flatten()
.filter { it.type == CalendarEventType.MENSTRUATION && it.isActual }
.sortedBy { it.date }
if (menstruationEvents.size < 2) return
// Вычисляем длины циклов
val cycleLengths = mutableListOf<Int>()
for (i in 1 until menstruationEvents.size) {
val cycleLength = ChronoUnit.DAYS.between(
menstruationEvents[i-1].date,
menstruationEvents[i].date
).toInt()
cycleLengths.add(cycleLength)
}
if (cycleLengths.isEmpty()) return
val averageLength = cycleLengths.average().toFloat()
val variation = cycleLengths.map { abs(it - averageLength) }.average().toFloat()
// Собираем частые симптомы
val allSymptoms = uiState.events.values.flatten()
.flatMap { it.symptoms }
val symptomFrequency = allSymptoms.groupingBy { it }.eachCount()
val commonSymptoms = symptomFrequency.toList()
.sortedByDescending { it.second }
.take(5)
.map { it.first }
val statistics = CycleStatistics(
averageCycleLength = averageLength,
cycleVariation = variation,
lastCycles = cycleLengths.takeLast(6),
periodLengthAverage = uiState.settings.averagePeriodLength.toFloat(),
commonSymptoms = commonSymptoms,
moodPatterns = emptyMap() // TODO: Вычислить паттерны настроения
)
uiState = uiState.copy(statistics = statistics)
// Обновляем настройки на основе статистики
if (cycleLengths.size >= 3) {
val newSettings = uiState.settings.copy(
averageCycleLength = averageLength.roundToInt()
)
uiState = uiState.copy(settings = newSettings)
}
}
private suspend fun saveEventToServer(event: CalendarEvent) {
try { try {
// Преобразуем CalendarEvent в CalendarEntryCreate для API // Получение данных о цикле из репозитория
val entryCreate = CalendarEntryCreate( val cycleData = repository.getCycleData()
date = event.date.toString(), updateCalendarStateWithCycleData(cycleData)
type = event.type.name,
mood = event.mood?.name,
symptoms = event.symptoms.map { it.name },
notes = event.notes.ifEmpty { null },
flow_intensity = event.flowIntensity
)
// Отправляем данные на сервер
val response = repository.createCalendarEntry(entryCreate)
if (response.isSuccessful) {
// Обновляем локальное событие с ID с сервера, если такой вернулся
response.body()?.let { serverEvent ->
val currentEvents = uiState.events.toMutableMap()
val dateEvents = currentEvents[event.date]?.toMutableList() ?: return@let
// Находим и заменяем событие, добавляя ему ID с сервера
val index = dateEvents.indexOfFirst {
it.type == event.type && it.date == event.date
}
if (index != -1) {
dateEvents[index] = serverEvent
currentEvents[event.date] = dateEvents
uiState = uiState.copy(
events = currentEvents,
error = null
)
}
}
} else {
// Обработка ошибки API
uiState = uiState.copy(error = "Ошибка сохранения на сервере: ${response.code()}")
}
} catch (e: Exception) { } catch (e: Exception) {
uiState = uiState.copy(error = "Ошибка сохранения: ${e.message}") _error.value = "Ошибка при загрузке данных цикла: ${e.localizedMessage}"
} finally {
_isLoading.value = false
}
} }
} }
private suspend fun saveCycleSettings(settings: CycleSettings) { /**
* Выбор даты пользователем
*/
fun selectDate(date: LocalDate) {
_selectedDate.value = date
// Если выбрана дата другого месяца, обновляем просматриваемый месяц
if (date.month != viewingMonth.month || date.year != viewingMonth.year) {
viewingMonth = date.withDayOfMonth(1)
updateMonthData()
}
}
/**
* Обновление данных для текущего месяца
*/
private fun updateMonthData() {
val currentState = _calendarUiState.value ?: CalendarUiState()
_calendarUiState.value = currentState.copy(
viewingMonth = viewingMonth
)
}
/**
* Обновление состояния UI с записями календаря
*/
private fun updateCalendarStateWithEntries(entries: List<CalendarEntry>) {
val updatedState = _calendarUiState.value?.copy(
entries = entries,
selectedDateEntries = entries.filter {
it.entryDate.isEqual(selectedDate.value)
}
) ?: CalendarUiState(entries = entries)
_calendarUiState.value = updatedState
}
/**
* Обновление состояния UI с данными о цикле
*/
private fun updateCalendarStateWithCycleData(cycleData: CycleData?) {
if (cycleData == null) return
val updatedState = _calendarUiState.value?.copy(
cycleStartDate = cycleData.lastPeriodStartDate,
cycleData = cycleData,
avgCycleLength = cycleData.averageCycleLength,
avgPeriodLength = cycleData.averagePeriodLength,
cycleRegularityScore = cycleData.regularityScore
) ?: CalendarUiState()
_calendarUiState.value = updatedState
}
/**
* Удаление инсайта
*/
fun dismissInsight(id: Long) {
viewModelScope.launch {
try { try {
// TODO: Реализовать сохранение настроек на сервер через API // Здесь должен быть вызов метода репозитория для удаления инсайта
// repository.dismissInsight(id)
// Обновляем список инсайтов, удаляя указанный
_calendarUiState.value = _calendarUiState.value?.copy(
insights = _calendarUiState.value?.insights?.filter { it.id != id } ?: emptyList()
)
} catch (e: Exception) { } catch (e: Exception) {
uiState = uiState.copy(error = "Ошибка сохранения настроек: ${e.message}") _error.value = "Ошибка при удалении инсайта: ${e.localizedMessage}"
}
} }
} }
// Очистка ошибки /**
fun clearError() { * Очистка сообщения об ошибке
uiState = uiState.copy(error = null) */
fun clearErrorMessage() {
_error.value = null
} }
// Принудительное обновление данных /**
fun forceRefresh() { * Добавление новой записи календаря
uiState = uiState.copy(lastRefreshed = 0) */
retryCount = 0 // Сбрасываем счетчик попыток fun addCalendarEntry(entry: CalendarEntry) {
loadCalendarData() viewModelScope.launch {
_isLoading.value = true
try {
repository.addCalendarEntry(entry)
// Обновляем данные после добавления
loadCalendarEntries()
_error.value = null
} catch (e: Exception) {
_error.value = "Ошибка при добавлении записи: ${e.localizedMessage}"
} finally {
_isLoading.value = false
}
}
} }
override fun onCleared() { /**
super.onCleared() * Обновление существующей записи
loadJob?.cancel() */
debounceJob?.cancel() fun updateCalendarEntry(entry: CalendarEntry) {
viewModelScope.launch {
_isLoading.value = true
try {
repository.updateCalendarEntry(entry)
// Обновляем данные после изменения
loadCalendarEntries()
_error.value = null
} catch (e: Exception) {
_error.value = "Ошибка при обновлении записи: ${e.localizedMessage}"
} finally {
_isLoading.value = false
}
}
}
/**
* Удаление записи календаря
*/
fun deleteCalendarEntry(entryId: Long) {
viewModelScope.launch {
_isLoading.value = true
try {
repository.deleteCalendarEntry(entryId)
// Обновляем данные после удаления
loadCalendarEntries()
_error.value = null
} catch (e: Exception) {
_error.value = "Ошибка при удалении записи: ${e.localizedMessage}"
} finally {
_isLoading.value = false
}
}
} }
} }
/**
* Класс, представляющий состояние UI календаря
*/
data class CalendarUiState(
val entries: List<CalendarEntry> = emptyList(),
val selectedDateEntries: List<CalendarEntry> = emptyList(),
val cycleStartDate: LocalDate? = null,
val cycleData: CycleData? = null,
val specialDays: Map<LocalDate, DayType> = emptyMap(),
val avgCycleLength: Int? = null,
val avgPeriodLength: Int? = null,
val cycleRegularityScore: Int? = null,
val insights: List<HealthInsight> = emptyList(),
val errorMessage: String? = null,
val viewingMonth: LocalDate = LocalDate.now().withDayOfMonth(1)
)
/**
* Типы дней в календаре
*/
enum class DayType {
PERIOD, // День менструации
OVULATION, // День овуляции
FERTILE, // Фертильный день
PREDICTED_PERIOD, // Прогноз дня менструации
NORMAL // Обычный день
}

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 { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
} }

View File

@@ -1,13 +1,13 @@
[versions] [versions]
agp = "8.13.0" agp = "8.13.0"
kotlin = "2.0.21" kotlin = "1.9.20"
coreKtx = "1.10.1" coreKtx = "1.10.1"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.1.5"
espressoCore = "3.5.1" espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1" lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0" activityCompose = "1.8.0"
composeBom = "2024.09.00" composeBom = "2023.08.00"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -28,5 +28,3 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }