This commit is contained in:
coco
2026-07-16 16:46:33 +08:00
parent 84db04a515
commit 5a58127ee5
17 changed files with 1289 additions and 27 deletions
@@ -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<DailyRecord?>
/** 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<List<DailyRecord>>
/** Get all dates marked as period start. */
@Query("SELECT * FROM daily_records WHERE is_period_start = 1")
fun getAllPeriodStarts(): Flow<List<DailyRecord>>
/** 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<DailyRecord>
/** Insert or replace a record. */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(record: DailyRecord)
/** Delete a record. */
@Delete
suspend fun delete(record: DailyRecord)
}
@@ -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<PeriodSettings?>
/** Upsert settings (insert or replace the single row). */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSettings(settings: PeriodSettings)
}
@@ -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 }
}
}
}
}
}
@@ -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,
)
@@ -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,
)
@@ -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<LocalDate>,
): CycleInfo {
val phases = mutableMapOf<LocalDate, DayPhase>()
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<LocalDate>()
val ovulationDates = mutableMapOf<LocalDate, DayPhase>()
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)
}
}
@@ -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<LocalDate, DayPhase>
) {
/** Get the phase for a given date, or [DayPhase.NONE] if not set. */
fun getPhase(date: LocalDate): DayPhase = phases[date] ?: DayPhase.NONE
}
@@ -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,
}
@@ -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,
)
}
}
@@ -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<DynamicSelectionState>,
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,
)
}
}
}
@@ -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)
}
}
@@ -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 (315 days)
* - Cycle length wheel picker (1731 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)
}
}
}
@@ -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<Int>,
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),
)
}
@@ -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()
}
}
}
@@ -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() {
@@ -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<PinkApplication>()).database
private val settingsDao = db.periodSettingsDao()
private val recordDao = db.dailyRecordDao()
/** Current period settings (single row). */
val settings: Flow<PeriodSettings?> = settingsDao.getSettings()
/** All dates the user has marked as "月经来了" (period start). */
val allPeriodStarts: StateFlow<Set<LocalDate>> = 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<List<DailyRecord>> =
recordDao.getRecordsInRange(start.toEpochDay(), end.toEpochDay())
/** Get a record for a specific date. */
fun getRecord(date: LocalDate): Flow<DailyRecord?> =
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))
}
}
}
}
@@ -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)
}