From 5a58127ee5a06fe3e9daa5c10ad4c86cc5d057c4 Mon Sep 17 00:00:00 2001 From: coco <2927951@qq.com> Date: Thu, 16 Jul 2026 16:46:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=93=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pinkov/data/dao/DailyRecordDao.kt | 41 ++++ .../pinkov/data/dao/PeriodSettingsDao.kt | 20 ++ .../pinkov/data/database/AppDatabase.kt | 49 ++++- .../pinkov/data/entity/DailyRecord.kt | 42 ++++ .../pinkov/data/entity/PeriodSettings.kt | 26 +++ .../pinkov/data/model/CycleCalculator.kt | 113 ++++++++++ .../pinkbear/pinkov/data/model/CycleInfo.kt | 15 ++ .../pinkbear/pinkov/data/model/DayPhase.kt | 21 ++ .../pinkov/ui/component/CheckInPanel.kt | 135 ++++++++++++ .../ui/component/FertilityDayContent.kt | 95 ++++++++ .../pinkov/ui/component/HexagonMarker.kt | 41 ++++ .../ui/component/PeriodSettingsDialog.kt | 193 +++++++++++++++++ .../pinkov/ui/component/WheelPicker.kt | 174 +++++++++++++++ .../com/pinkbear/pinkov/ui/page/MinePage.kt | 37 +++- .../pinkbear/pinkov/ui/page/RecordsPage.kt | 204 ++++++++++++++++-- .../pinkov/ui/page/RecordsViewModel.kt | 78 +++++++ .../pinkov/ui/theme/FertilityColors.kt | 32 +++ 17 files changed, 1289 insertions(+), 27 deletions(-) create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/data/dao/DailyRecordDao.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/data/dao/PeriodSettingsDao.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/data/entity/DailyRecord.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/data/entity/PeriodSettings.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/data/model/CycleCalculator.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/data/model/CycleInfo.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/data/model/DayPhase.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/CheckInPanel.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/FertilityDayContent.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/HexagonMarker.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/PeriodSettingsDialog.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/WheelPicker.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/RecordsViewModel.kt create mode 100644 Pink/app/src/main/java/com/pinkbear/pinkov/ui/theme/FertilityColors.kt diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/data/dao/DailyRecordDao.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/data/dao/DailyRecordDao.kt new file mode 100644 index 0000000..df6ee34 --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/data/dao/DailyRecordDao.kt @@ -0,0 +1,41 @@ +package com.pinkbear.pinkov.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.pinkbear.pinkov.data.entity.DailyRecord +import kotlinx.coroutines.flow.Flow + +@Dao +interface DailyRecordDao { + + /** Get the record for a specific date. */ + @Query("SELECT * FROM daily_records WHERE date = :date") + fun getRecord(date: Long): Flow + + /** Get all records within a date range (epoch days, inclusive). */ + @Query("SELECT * FROM daily_records WHERE date BETWEEN :start AND :end") + fun getRecordsInRange(start: Long, end: Long): Flow> + + /** Get all dates marked as period start. */ + @Query("SELECT * FROM daily_records WHERE is_period_start = 1") + fun getAllPeriodStarts(): Flow> + + /** Get the record for a specific date (one-shot, suspend). */ + @Query("SELECT * FROM daily_records WHERE date = :date") + suspend fun getRecordOnce(date: Long): DailyRecord? + + /** Get all records within a date range (one-shot, suspend). */ + @Query("SELECT * FROM daily_records WHERE date BETWEEN :start AND :end") + suspend fun getRecordsInRangeOnce(start: Long, end: Long): List + + /** Insert or replace a record. */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(record: DailyRecord) + + /** Delete a record. */ + @Delete + suspend fun delete(record: DailyRecord) +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/data/dao/PeriodSettingsDao.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/data/dao/PeriodSettingsDao.kt new file mode 100644 index 0000000..a7d9879 --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/data/dao/PeriodSettingsDao.kt @@ -0,0 +1,20 @@ +package com.pinkbear.pinkov.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.pinkbear.pinkov.data.entity.PeriodSettings +import kotlinx.coroutines.flow.Flow + +@Dao +interface PeriodSettingsDao { + + /** Observe the current settings (single row, id=1). */ + @Query("SELECT * FROM period_settings WHERE id = 1") + fun getSettings(): Flow + + /** Upsert settings (insert or replace the single row). */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertSettings(settings: PeriodSettings) +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/data/database/AppDatabase.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/data/database/AppDatabase.kt index 6da7996..09e3dff 100644 --- a/Pink/app/src/main/java/com/pinkbear/pinkov/data/database/AppDatabase.kt +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/data/database/AppDatabase.kt @@ -4,26 +4,69 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.pinkbear.pinkov.data.dao.DailyRecordDao +import com.pinkbear.pinkov.data.dao.PeriodSettingsDao import com.pinkbear.pinkov.data.dao.ScanRecordDao +import com.pinkbear.pinkov.data.entity.DailyRecord +import com.pinkbear.pinkov.data.entity.PeriodSettings import com.pinkbear.pinkov.data.entity.ScanRecord -@Database(entities = [ScanRecord::class], version = 1, exportSchema = false) +@Database( + entities = [ScanRecord::class, PeriodSettings::class, DailyRecord::class], + version = 2, + exportSchema = false +) abstract class AppDatabase : RoomDatabase() { abstract fun scanRecordDao(): ScanRecordDao + abstract fun periodSettingsDao(): PeriodSettingsDao + abstract fun dailyRecordDao(): DailyRecordDao companion object { @Volatile private var INSTANCE: AppDatabase? = null + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL(""" + CREATE TABLE IF NOT EXISTS `period_settings` ( + `id` INTEGER NOT NULL, + `period_length` INTEGER NOT NULL DEFAULT 5, + `cycle_length` INTEGER NOT NULL DEFAULT 28, + `last_period_date` INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(`id`) + ) + """.trimIndent()) + db.execSQL(""" + CREATE TABLE IF NOT EXISTS `daily_records` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `date` INTEGER NOT NULL, + `is_period_start` INTEGER NOT NULL DEFAULT 0, + `had_sex` INTEGER NOT NULL DEFAULT 0, + `mood` TEXT, + `discharge` TEXT, + `note` TEXT + ) + """.trimIndent()) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_daily_records_date` ON `daily_records` (`date`)" + ) + } + } + fun getInstance(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "pinkov_database" - ).build().also { INSTANCE = it } + ) + .addMigrations(MIGRATION_1_2) + .build() + .also { INSTANCE = it } } } } -} \ No newline at end of file +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/data/entity/DailyRecord.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/data/entity/DailyRecord.kt new file mode 100644 index 0000000..77d646f --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/data/entity/DailyRecord.kt @@ -0,0 +1,42 @@ +package com.pinkbear.pinkov.data.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Per-date check-in record. One row per date (unique on date). + */ +@Entity( + tableName = "daily_records", + indices = [Index(value = ["date"], unique = true)] +) +data class DailyRecord( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + /** Date of this record, as epoch day (LocalDate.toEpochDay()). */ + @ColumnInfo(name = "date") + val date: Long, + + /** User marked "月经来了" (period started) on this date. */ + @ColumnInfo(name = "is_period_start") + val isPeriodStart: Boolean = false, + + /** User recorded sexual activity on this date. */ + @ColumnInfo(name = "had_sex") + val hadSex: Boolean = false, + + /** Mood tag (e.g. 焦虑, 期待, 低落, 放松). Null = not recorded. */ + @ColumnInfo(name = "mood") + val mood: String? = null, + + /** Cervical mucus / discharge description. Null = not recorded. */ + @ColumnInfo(name = "discharge") + val discharge: String? = null, + + /** Free-text notes for this date. */ + @ColumnInfo(name = "note") + val note: String? = null, +) diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/data/entity/PeriodSettings.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/data/entity/PeriodSettings.kt new file mode 100644 index 0000000..192108f --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/data/entity/PeriodSettings.kt @@ -0,0 +1,26 @@ +package com.pinkbear.pinkov.data.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Single-row table storing the user's period configuration. + */ +@Entity(tableName = "period_settings") +data class PeriodSettings( + @PrimaryKey + val id: Int = 1, + + /** Duration of menstrual bleeding in days (3-15). Default 5. */ + @ColumnInfo(name = "period_length") + val periodLength: Int = 5, + + /** Average menstrual cycle length in days (17-31). Default 28. */ + @ColumnInfo(name = "cycle_length") + val cycleLength: Int = 28, + + /** First day of the most recent actual period, as epoch day (LocalDate.toEpochDay()). */ + @ColumnInfo(name = "last_period_date") + val lastPeriodDate: Long = 0L, +) diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/data/model/CycleCalculator.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/data/model/CycleCalculator.kt new file mode 100644 index 0000000..63724f4 --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/data/model/CycleCalculator.kt @@ -0,0 +1,113 @@ +package com.pinkbear.pinkov.data.model + +import com.pinkbear.pinkov.data.entity.PeriodSettings +import java.time.LocalDate +import java.time.YearMonth + +/** + * Pure-Kotlin engine that computes menstrual / ovulation calendar phases. + * + * Formula (per standard gynecology): + * nextPeriod = lastPeriodStart + cycleLength + * ovulation = nextPeriod − 14 + * fertileWin = [ovulation − 5, ovulation + 1] + * predicted = [nextPeriod, nextPeriod + periodLength − 1] + * + * When an actual period (user-tagged "月经来了") overlaps a predicted period, + * actual takes priority. + */ +object CycleCalculator { + + /** + * Compute the [CycleInfo] for a given month. + * + * @param yearMonth the month to calculate phases for + * @param settings user's period settings (cycle length, period length, last period date) + * @param actualPeriodStarts dates the user marked as "月经来了" (period start) + * @return [CycleInfo] containing the phase for every date in the month that has one + */ + fun calculate( + yearMonth: YearMonth, + settings: PeriodSettings?, + actualPeriodStarts: Set, + ): CycleInfo { + val phases = mutableMapOf() + + if (settings == null || settings.lastPeriodDate == 0L) { + return CycleInfo(phases) + } + + val lastPeriodDate = LocalDate.ofEpochDay(settings.lastPeriodDate) + val periodLength = settings.periodLength + val cycleLength = settings.cycleLength + + // 1. Actual period ranges (deep pink) — highest priority + for (start in actualPeriodStarts) { + for (i in 0 until periodLength) { + val date = start.plusDays(i.toLong()) + if (date.month == yearMonth.month && date.year == yearMonth.year) { + phases[date] = DayPhase.ACTUAL_PERIOD + } + } + } + + // 2. Generate predicted cycles from lastPeriodDate forward + // We walk cycle-by-cycle until we pass the target month + val predictedPeriodDates = mutableSetOf() + val ovulationDates = mutableMapOf() + + var currentStart = lastPeriodDate + // Go back enough cycles so we cover the target month even if it's in the past + // Then go forward until we're past the target month + val cyclesToGenerate = 24 // generous: 2 years of cycles + val startCycle = currentStart + + for (cycleIndex in 0 until cyclesToGenerate) { + val cycleStart = startCycle.plusDays((cycleIndex * cycleLength).toLong()) + val nextPeriodStart = cycleStart.plusDays(cycleLength.toLong()) + val ovulationDay = nextPeriodStart.minusDays(14) + + // Predicted period: [cycleStart, cycleStart + periodLength - 1] + // Actually, the FIRST predicted period is the one AFTER the last known period. + // The current cycle's period starts at nextPeriodStart. + for (i in 0 until periodLength) { + val date = nextPeriodStart.plusDays(i.toLong()) + if (date.month == yearMonth.month && date.year == yearMonth.year) { + predictedPeriodDates.add(date) + } + } + + // Ovulation day + if (ovulationDay.month == yearMonth.month && ovulationDay.year == yearMonth.year) { + ovulationDates[ovulationDay] = DayPhase.OVULATION_DAY + } + + // Ovulation range: [ovulationDay - 5, ovulationDay + 1] + for (i in -5..1) { + val date = ovulationDay.plusDays(i.toLong()) + if (date.month == yearMonth.month && date.year == yearMonth.year) { + // Don't overwrite ovulation day + if (!ovulationDates.containsKey(date)) { + ovulationDates[date] = DayPhase.OVULATION + } + } + } + } + + // 3. Merge: predicted period (lower priority) + for (date in predictedPeriodDates) { + if (!phases.containsKey(date)) { + phases[date] = DayPhase.PREDICTED_PERIOD + } + } + + // 4. Merge: ovulation / fertile window (lowest priority among non-NONE) + for ((date, phase) in ovulationDates) { + if (!phases.containsKey(date)) { + phases[date] = phase + } + } + + return CycleInfo(phases) + } +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/data/model/CycleInfo.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/data/model/CycleInfo.kt new file mode 100644 index 0000000..b580744 --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/data/model/CycleInfo.kt @@ -0,0 +1,15 @@ +package com.pinkbear.pinkov.data.model + +import java.time.LocalDate + +/** + * Computed cycle information for a calendar month. + * + * @property phases map from date to its [DayPhase] — only dates with a phase are present. + */ +data class CycleInfo( + val phases: Map +) { + /** Get the phase for a given date, or [DayPhase.NONE] if not set. */ + fun getPhase(date: LocalDate): DayPhase = phases[date] ?: DayPhase.NONE +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/data/model/DayPhase.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/data/model/DayPhase.kt new file mode 100644 index 0000000..52ee216 --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/data/model/DayPhase.kt @@ -0,0 +1,21 @@ +package com.pinkbear.pinkov.data.model + +/** + * Phase / state of a single calendar day in the fertility calendar. + */ +enum class DayPhase { + /** No special state. */ + NONE, + + /** Actual menstrual period — user marked "月经来了" → deep pink background. */ + ACTUAL_PERIOD, + + /** Predicted menstrual period — formula-based → light pink background. */ + PREDICTED_PERIOD, + + /** Ovulation / fertile window (排卵日 ± several days) → purple background. */ + OVULATION, + + /** Ovulation day itself → purple background + hexagon marker. */ + OVULATION_DAY, +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/CheckInPanel.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/CheckInPanel.kt new file mode 100644 index 0000000..8242a7a --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/CheckInPanel.kt @@ -0,0 +1,135 @@ +package com.pinkbear.pinkov.ui.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.pinkbear.pinkov.data.entity.DailyRecord +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * Bottom panel showing check-in items for the selected calendar date. + * + * When no date is selected, shows a hint. When a date is selected, + * shows the "月经来了" switch and (in future) more check-in items. + */ +@Composable +fun CheckInPanel( + selectedDate: LocalDate?, + record: DailyRecord?, + onPeriodStartChanged: (LocalDate, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日", Locale.CHINESE) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + ) { + if (selectedDate == null) { + Text( + text = "点击日历上的日期\n查看或记录当天状态", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp, + textAlign = TextAlign.Center, + lineHeight = 20.sp, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Text( + text = selectedDate.format(formatter), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(12.dp)) + + // ---- 月经来了 ---- + CheckInSwitchRow( + label = "月经来了", + checked = record?.isPeriodStart ?: false, + onCheckedChange = { checked -> + onPeriodStartChanged(selectedDate, checked) + }, + ) + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + + // ---- Placeholder items (future) ---- + PlaceholderCheckInRow(label = "爱爱") + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + PlaceholderCheckInRow(label = "心情") + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + PlaceholderCheckInRow(label = "白带") + } + } +} + +@Composable +private fun CheckInSwitchRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun PlaceholderCheckInRow(label: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + text = "—", + fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/FertilityDayContent.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/FertilityDayContent.kt new file mode 100644 index 0000000..78594d8 --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/FertilityDayContent.kt @@ -0,0 +1,95 @@ +package com.pinkbear.pinkov.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +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.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.pinkbear.pinkov.data.model.CycleInfo +import com.pinkbear.pinkov.data.model.DayPhase +import com.pinkbear.pinkov.ui.theme.FertilityColors +import io.github.boguszpawlowski.composecalendar.day.DayState +import io.github.boguszpawlowski.composecalendar.selection.DynamicSelectionState + +/** + * Custom day-cell composable for the fertility calendar. + * + * Renders: + * - Colored background based on [CycleInfo] phase + * - Blue border for today + * - Filled border for selected date + * - Hexagon marker in bottom-right for ovulation day + * - Dimmed text for adjacent-month days + */ +@Composable +fun BoxScope.FertilityDayContent( + state: DayState, + cycleInfo: CycleInfo, +) { + val date = state.date + val phase = cycleInfo.getPhase(date) + val isSelected = state.selectionState.isDateSelected(date) + + val backgroundColor = when (phase) { + DayPhase.ACTUAL_PERIOD -> FertilityColors.actualPeriod + DayPhase.PREDICTED_PERIOD -> FertilityColors.predictedPeriod + DayPhase.OVULATION, DayPhase.OVULATION_DAY -> FertilityColors.ovulation + DayPhase.NONE -> Color.Transparent + } + + val textColor = when { + !state.isFromCurrentMonth -> FertilityColors.adjacentMonthText + phase == DayPhase.ACTUAL_PERIOD -> FertilityColors.textOnDark + else -> Color.Unspecified // use default (M3 onSurface) + } + + val borderModifier = when { + isSelected -> Modifier.border(2.dp, FertilityColors.selectedBorder, RoundedCornerShape(6.dp)) + state.isCurrentDay -> Modifier.border(1.5.dp, FertilityColors.todayBorder, RoundedCornerShape(6.dp)) + else -> Modifier + } + + Box( + modifier = Modifier + .aspectRatio(1f) + .padding(2.dp) + .clip(RoundedCornerShape(6.dp)) + .then(borderModifier) + .background(color = backgroundColor, shape = RoundedCornerShape(6.dp)) + .clickable { state.selectionState.onDateSelected(date) }, + contentAlignment = Alignment.Center + ) { + Text( + text = date.dayOfMonth.toString(), + color = textColor, + fontSize = 13.sp, + fontWeight = if (state.isCurrentDay) FontWeight.Bold else FontWeight.Normal, + textAlign = TextAlign.Center, + ) + + // Ovulation day hexagon marker + if (phase == DayPhase.OVULATION_DAY) { + HexagonMarker( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(14.dp), + color = FertilityColors.ovulationDayMarker, + size = 14.dp, + ) + } + } +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/HexagonMarker.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/HexagonMarker.kt new file mode 100644 index 0000000..f2c24af --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/HexagonMarker.kt @@ -0,0 +1,41 @@ +package com.pinkbear.pinkov.ui.component + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * A small filled hexagon marker drawn at the bottom-right of a day cell + * to indicate ovulation day. + */ +@Composable +fun HexagonMarker( + modifier: Modifier = Modifier, + color: Color = Color(0xFF7B1FA2), + size: Dp = 14.dp, +) { + Canvas(modifier = modifier) { + val w = size.toPx() + val h = size.toPx() + val cx = w / 2f + val cy = h / 2f + val r = w / 2f + + val path = Path().apply { + // Regular hexagon: vertices at 30° + k*60° + for (i in 0 until 6) { + val angle = Math.toRadians(30.0 + i * 60.0) + val x = cx + r * kotlin.math.cos(angle).toFloat() + val y = cy + r * kotlin.math.sin(angle).toFloat() + if (i == 0) moveTo(x, y) else lineTo(x, y) + } + close() + } + drawPath(path, color = color) + } +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/PeriodSettingsDialog.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/PeriodSettingsDialog.kt new file mode 100644 index 0000000..db7089b --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/PeriodSettingsDialog.kt @@ -0,0 +1,193 @@ +package com.pinkbear.pinkov.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.pinkbear.pinkov.data.entity.PeriodSettings +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * Dialog for configuring period settings. + * + * Contains: + * - Period length wheel picker (3–15 days) + * - Cycle length wheel picker (17–31 days) + * - Last period start date picker + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PeriodSettingsDialog( + currentSettings: PeriodSettings?, + onDismiss: () -> Unit, + onSave: (PeriodSettings) -> Unit, +) { + val dateFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日", Locale.CHINESE) + + // Local editable state, initialized from current settings or defaults + var periodLength by remember(currentSettings) { + mutableIntStateOf(currentSettings?.periodLength ?: 5) + } + var cycleLength by remember(currentSettings) { + mutableIntStateOf(currentSettings?.cycleLength ?: 28) + } + var lastPeriodDate by remember(currentSettings) { + mutableStateOf( + if (currentSettings != null && currentSettings.lastPeriodDate > 0L) + LocalDate.ofEpochDay(currentSettings.lastPeriodDate) + else + LocalDate.now() + ) + } + var showDatePicker by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "经期设置", + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // ---- Period length ---- + Text( + text = "设置您的月经大概维持几天?", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + WheelPicker( + items = (3..15).toList(), + selected = periodLength, + onSelected = { periodLength = it }, + visibleItems = 5, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // ---- Cycle length ---- + Text( + text = "两次月经开始日大概间隔多久?", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + WheelPicker( + items = (17..31).toList(), + selected = cycleLength, + onSelected = { cycleLength = it }, + visibleItems = 5, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // ---- Last period start date ---- + Text( + text = "最近一次月经开始时间", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = { showDatePicker = true }) { + Text( + text = lastPeriodDate.format(dateFormatter), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + }, + confirmButton = { + TextButton(onClick = { + onSave( + PeriodSettings( + id = 1, + periodLength = periodLength, + cycleLength = cycleLength, + lastPeriodDate = lastPeriodDate.toEpochDay(), + ) + ) + }) { + Text("保存") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("取消") + } + }, + ) + + // ---- Date picker sub-dialog ---- + if (showDatePicker) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = lastPeriodDate + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + ) + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { millis -> + lastPeriodDate = Instant.ofEpochMilli(millis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + } + showDatePicker = false + }) { + Text("确定") + } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { + Text("取消") + } + }, + ) { + DatePicker(state = datePickerState) + } + } +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/WheelPicker.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/WheelPicker.kt new file mode 100644 index 0000000..cfb27f6 --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/component/WheelPicker.kt @@ -0,0 +1,174 @@ +package com.pinkbear.pinkov.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * A vertical wheel picker that snaps items to the center position. + * + * Uses [LazyColumn] with [rememberSnapFlingBehavior] for a native-feeling scroll wheel. + * Gradient fades at top and bottom create the classic wheel illusion. + * + * @param items list of integer values to display + * @param selected currently selected value + * @param onSelected callback when the user selects a new value + * @param modifier modifier for the outer container + * @param itemHeight height of each item row + * @param visibleItems number of items visible at once (must be odd for symmetry) + */ +@Composable +fun WheelPicker( + items: List, + selected: Int, + onSelected: (Int) -> Unit, + modifier: Modifier = Modifier, + itemHeight: Dp = 48.dp, + visibleItems: Int = 5, +) { + require(visibleItems % 2 == 1) { "visibleItems must be odd for center alignment" } + val totalHeight = itemHeight * visibleItems + + val initialIndex = items.indexOf(selected).coerceAtLeast(0) + val listState = rememberLazyListState(initialFirstVisibleItemIndex = initialIndex) + + // Detect center item from layout info + val centerItemIndex by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + if (layoutInfo.visibleItemsInfo.isEmpty()) return@derivedStateOf initialIndex + + val viewportCenter = layoutInfo.viewportStartOffset + layoutInfo.viewportSize.height / 2 + layoutInfo.visibleItemsInfo + .minByOrNull { itemInfo -> + val itemCenter = itemInfo.offset + itemInfo.size / 2 + abs(itemCenter - viewportCenter) + }?.index ?: initialIndex + } + } + + // Notify when center item changes + LaunchedEffect(centerItemIndex) { + if (centerItemIndex in items.indices) { + onSelected(items[centerItemIndex]) + } + } + + Box( + modifier = modifier.height(totalHeight), + contentAlignment = Alignment.Center, + ) { + // Wheel items + LazyColumn( + state = listState, + flingBehavior = rememberSnapFlingBehavior(lazyListState = listState), + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Top spacer to allow first item to reach center + item(key = "top_spacer") { + Box(modifier = Modifier.height(totalHeight / 2 - itemHeight / 2)) + } + + itemsIndexed(items) { index, value -> + val isCenter = index == centerItemIndex + Box( + modifier = Modifier + .fillMaxWidth() + .height(itemHeight) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "${value}天", + fontSize = if (isCenter) 20.sp else 16.sp, + fontWeight = if (isCenter) FontWeight.Bold else FontWeight.Normal, + color = if (isCenter) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + + // Bottom spacer to allow last item to reach center + item(key = "bottom_spacer") { + Box(modifier = Modifier.height(totalHeight / 2 - itemHeight / 2)) + } + } + + // Center highlight indicator line + Box( + modifier = Modifier + .fillMaxWidth() + .height(itemHeight) + .padding(horizontal = 32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)), + ) + + // Gradient fades (top & bottom) + WheelFadeGradient( + modifier = Modifier.align(Alignment.TopCenter), + brush = Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.surface, + Color.Transparent, + ), + ), + height = itemHeight, + ) + WheelFadeGradient( + modifier = Modifier.align(Alignment.BottomCenter), + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + MaterialTheme.colorScheme.surface, + ), + ), + height = itemHeight, + ) + } +} + +@Composable +private fun BoxScope.WheelFadeGradient( + modifier: Modifier = Modifier, + brush: Brush, + height: Dp, +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(height) + .background(brush), + ) +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/MinePage.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/MinePage.kt index 7042016..5235a3e 100644 --- a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/MinePage.kt +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/MinePage.kt @@ -5,8 +5,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings @@ -17,10 +18,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pinkbear.pinkov.ui.component.PeriodSettingsDialog import com.pinkbear.pinkov.ui.theme.PinkOVTheme @Composable @@ -29,6 +32,11 @@ fun MinePage(modifier: Modifier = Modifier) { var isLoggedIn by remember { mutableStateOf(false) } var userName by remember { mutableStateOf("") } + // Period settings dialog + var showPeriodSettings by remember { mutableStateOf(false) } + val recordsViewModel: RecordsViewModel = viewModel() + val periodSettings by recordsViewModel.settings.collectAsStateWithLifecycle(initialValue = null) + Scaffold(modifier = modifier) { innerPadding -> Column( modifier = Modifier @@ -105,6 +113,17 @@ fun MinePage(modifier: Modifier = Modifier) { color = MaterialTheme.colorScheme.outlineVariant ) + // ---- 经期设置 (above 设置) ---- + MenuItemRow( + icon = Icons.Default.Favorite, + title = "经期设置", + onClick = { showPeriodSettings = true } + ) + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + MenuItemRow( icon = Icons.Default.Settings, title = "设置", @@ -125,6 +144,18 @@ fun MinePage(modifier: Modifier = Modifier) { } } } + + // ---- Period Settings Dialog ---- + if (showPeriodSettings) { + PeriodSettingsDialog( + currentSettings = periodSettings, + onDismiss = { showPeriodSettings = false }, + onSave = { settings -> + recordsViewModel.saveSettings(settings) + showPeriodSettings = false + }, + ) + } } /** @@ -174,4 +205,4 @@ private fun MinePagePreview() { PinkOVTheme { MinePage() } -} \ No newline at end of file +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/RecordsPage.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/RecordsPage.kt index 0df2af1..d6b329d 100644 --- a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/RecordsPage.kt +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/RecordsPage.kt @@ -1,43 +1,205 @@ package com.pinkbear.pinkov.ui.page -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pinkbear.pinkov.data.model.CycleCalculator +import com.pinkbear.pinkov.ui.component.CheckInPanel +import com.pinkbear.pinkov.ui.component.FertilityDayContent +import com.pinkbear.pinkov.ui.component.HexagonMarker +import com.pinkbear.pinkov.ui.theme.FertilityColors import com.pinkbear.pinkov.ui.theme.PinkOVTheme +import io.github.boguszpawlowski.composecalendar.SelectableCalendar +import io.github.boguszpawlowski.composecalendar.rememberSelectableCalendarState +import io.github.boguszpawlowski.composecalendar.selection.SelectionMode +import java.time.LocalDate @Composable -fun RecordsPage(modifier: Modifier = Modifier) { +fun RecordsPage( + modifier: Modifier = Modifier, + recordsViewModel: RecordsViewModel = viewModel(), +) { + val calendarState = rememberSelectableCalendarState( + initialSelectionMode = SelectionMode.Single, + ) + + // Auto-select today's date on first composition + val today = remember { LocalDate.now() } + LaunchedEffect(Unit) { + calendarState.selectionState.selection = listOf(today) + } + + // Data from ViewModel + val settings by recordsViewModel.settings.collectAsState(initial = null) + val actualPeriodStarts by recordsViewModel.allPeriodStarts.collectAsState() + val selectedDate by remember { + derivedStateOf { calendarState.selectionState.selection.firstOrNull() } + } + val currentMonth = calendarState.monthState.currentMonth + + // Compute cycle info whenever inputs change + val cycleInfo = remember(settings, actualPeriodStarts, currentMonth) { + CycleCalculator.calculate(currentMonth, settings, actualPeriodStarts) + } + + // Record for the currently selected date + val selectedRecord by recordsViewModel.getRecord(selectedDate ?: today) + .collectAsState(initial = null) + Scaffold(modifier = modifier) { innerPadding -> - Box( + Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center + .padding(innerPadding) ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "经期记录", - color = MaterialTheme.colorScheme.onSurface, - fontSize = 18.sp - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "日历与经期记录功能即将上线", - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 14.sp, - textAlign = TextAlign.Center - ) - } + // Calendar section + SelectableCalendar( + modifier = Modifier.fillMaxWidth(), + calendarState = calendarState, + horizontalSwipeEnabled = false, + weekDaysScrollEnabled = false, + dayContent = { dayState -> + FertilityDayContent( + state = dayState, + cycleInfo = cycleInfo, + ) + }, + ) + + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + + // Color legend + FertilityLegend() + + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + + // Check-in panel for the selected date + CheckInPanel( + selectedDate = selectedDate, + record = selectedRecord, + onPeriodStartChanged = { date, isStart -> + recordsViewModel.setPeriodStart(date, isStart) + }, + ) } } } +/** + * Color legend row showing the 4 calendar phase indicators. + */ +@Composable +private fun FertilityLegend() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // 月经期 — deep pink square + LegendItem( + indicator = { + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(2.dp)) + .background(FertilityColors.actualPeriod) + ) + }, + label = "月经期", + ) + + // 预测经期 — light pink square + LegendItem( + indicator = { + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(2.dp)) + .background(FertilityColors.predictedPeriod) + ) + }, + label = "预测经期", + ) + + // 排卵期 — purple square + LegendItem( + indicator = { + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(2.dp)) + .background(FertilityColors.ovulation) + ) + }, + label = "排卵期", + ) + + // 排卵日 — deep purple hexagon + LegendItem( + indicator = { + HexagonMarker( + modifier = Modifier.size(13.dp), + color = FertilityColors.ovulationDayMarker, + size = 13.dp, + ) + }, + label = "排卵日", + ) + } +} + +@Composable +private fun LegendItem( + indicator: @Composable () -> Unit, + label: String, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + indicator() + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = label, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @PreviewScreenSizes @Composable private fun RecordsPagePreview() { diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/RecordsViewModel.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/RecordsViewModel.kt new file mode 100644 index 0000000..152f2c8 --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/RecordsViewModel.kt @@ -0,0 +1,78 @@ +package com.pinkbear.pinkov.ui.page + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.pinkbear.pinkov.PinkApplication +import com.pinkbear.pinkov.data.entity.DailyRecord +import com.pinkbear.pinkov.data.entity.PeriodSettings +import java.time.LocalDate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * ViewModel for the Records (fertility calendar) page. + * + * Manages period settings, daily check-in records, and exposes + * the data needed to compute [com.pinkbear.pinkov.data.model.CycleInfo]. + */ +class RecordsViewModel(application: Application) : AndroidViewModel(application) { + + private val db = (getApplication()).database + private val settingsDao = db.periodSettingsDao() + private val recordDao = db.dailyRecordDao() + + /** Current period settings (single row). */ + val settings: Flow = settingsDao.getSettings() + + /** All dates the user has marked as "月经来了" (period start). */ + val allPeriodStarts: StateFlow> = recordDao.getAllPeriodStarts() + .map { records -> records.map { LocalDate.ofEpochDay(it.date) }.toSet() } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptySet()) + + /** All daily records (for the current month view). */ + fun getRecordsInRange(start: LocalDate, end: LocalDate): Flow> = + recordDao.getRecordsInRange(start.toEpochDay(), end.toEpochDay()) + + /** Get a record for a specific date. */ + fun getRecord(date: LocalDate): Flow = + recordDao.getRecord(date.toEpochDay()) + + /** Toggle "月经来了" (period start) for a date. */ + fun setPeriodStart(date: LocalDate, isStart: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + if (isStart) { + // Clear any existing period start in the same calendar month + clearPeriodStartsInMonth(date) + } + val existing = recordDao.getRecordOnce(date.toEpochDay()) + val record = existing?.copy(isPeriodStart = isStart) + ?: DailyRecord(date = date.toEpochDay(), isPeriodStart = isStart) + recordDao.upsert(record) + } + } + + /** Save or update period settings. */ + fun saveSettings(settings: PeriodSettings) { + viewModelScope.launch(Dispatchers.IO) { + settingsDao.upsertSettings(settings) + } + } + + /** Remove all period-start flags within the same month as [date], except [date] itself. */ + private suspend fun clearPeriodStartsInMonth(date: LocalDate) { + val monthStart = date.withDayOfMonth(1) + val monthEnd = date.withDayOfMonth(date.lengthOfMonth()) + val records = recordDao.getRecordsInRangeOnce(monthStart.toEpochDay(), monthEnd.toEpochDay()) + for (record in records) { + if (record.isPeriodStart && record.date != date.toEpochDay()) { + recordDao.upsert(record.copy(isPeriodStart = false)) + } + } + } +} diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/theme/FertilityColors.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/theme/FertilityColors.kt new file mode 100644 index 0000000..3b81f7c --- /dev/null +++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/theme/FertilityColors.kt @@ -0,0 +1,32 @@ +package com.pinkbear.pinkov.ui.theme + +import androidx.compose.ui.graphics.Color + +/** + * Color constants for the fertility calendar day cells. + */ +object FertilityColors { + /** Actual menstrual period — deep pink. */ + val actualPeriod = Color(0xFFE91E63) + + /** Predicted menstrual period — light pink. */ + val predictedPeriod = Color(0xFFFFCDD2) + + /** Ovulation / fertile window — soft purple. */ + val ovulation = Color(0xFFCE93D8) + + /** Ovulation day hexagon marker — deep purple. */ + val ovulationDayMarker = Color(0xFF7B1FA2) + + /** Today highlight border. */ + val todayBorder = Color(0xFF2196F3) + + /** Selected date border. */ + val selectedBorder = Color(0xFF1976D2) + + /** Text color on dark backgrounds (actual period). */ + val textOnDark = Color.White + + /** Text color for adjacent-month days. */ + val adjacentMonthText = Color(0xFFBDBDBD) +}