20 KiB
20 KiB
备孕日历 — 架构设计方案
基于美柚 APP 备孕日历功能分析,结合 Pink 项目现有架构的增量实现方案。
一、需求分析
1.1 核心功能范围(第一步:基础经期展示与设置)
| 模块 | 功能 | 优先级 |
|---|---|---|
| 日历展示 | 月经期(深粉)、预测经期(浅粉)、排卵期(紫)、排卵日(深紫六边形) | P0 |
| 单选交互 | 点击日期 → 下方展示打卡项目列表 | P0 |
| 月经来了 | Switch 开关,标记当日为月经首日,自动填充经期 | P0 |
| 打卡项 | 月经来了、爱爱、心情、白带等(本期先做月经来了) | P0 |
| 经期设置 | 经期长度(3-15天)、周期长度(17-31天)、末次月经日期 | P0 |
| 周期推算 | 根据公式自动计算预测经期、排卵日、排卵期 | P0 |
1.2 后续迭代范围(不在本次实现)
- 排卵试纸记录与 LH 曲线
- 基础体温(BBT)记录与双相曲线
- 同房记录与分布统计
- 身体症状库打卡
- 早孕试纸记录
- 生活习惯 & 药物记录
- 受孕概率计算
- 提醒推送
- 数据导出
二、业务算法
2.1 核心公式
已知:末次月经首日 LMP、周期长度 C(天)、经期长度 P(天)
1. 下次月经首日 = LMP + C
2. 排卵日 = 下次月经首日 − 14
3. 排卵期(易孕期):
- 起始 = 排卵日 − 5
- 结束 = 排卵日 + 1
- 区间共 7 天
4. 预测经期:
- 起始 = 下次月经首日
- 结束 = 下次月经首日 + P − 1
5. 实际经期(用户标记"月经来了"):
- 起始 = 用户标记的日期(当月月经首日)
- 结束 = 起始 + P − 1
2.2 月经期与预测经期的重叠处理
- 实际月经期 vs 预测经期 在日历上可能重叠或不重叠
- 重叠日期:以深粉色(实际月经期)优先显示
- 仅预测经期:浅粉色
- 仅实际月经期:深粉色
2.3 预测经期的自动修正
当用户在当月标记了「月经来了」,则:
- 本月用实际日期 +
P计算实际经期 - 下个月的预测经期 使用本次实际月经首日 +
C重新推算 - 现有的下月预测被覆盖
三、技术架构
3.1 整体分层
┌──────────────────────────────────────────────────────┐
│ UI Layer (Compose) │
│ ┌──────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ RecordsPage │ │MinePage │ │PeriodSettings│ │
│ │ + DayContent │ │+ 经期设置入口│ │ Dialog │ │
│ │ + CheckInPanel│ │ │ │ │ │
│ └──────┬───────┘ └─────┬──────┘ └──────┬───────┘ │
├─────────┼────────────────┼─────────────────┼─────────┤
│ ViewModel Layer │ │ │
│ ┌──────────────┐ ┌─────┴─────────────────┴───────┐ │
│ │RecordsViewModel│ │ PeriodSettingsViewModel │ │
│ │ - calendar │ │ - settings state │ │
│ │ - selection │ │ - save/load │ │
│ │ - period calc │ │ │ │
│ │ - daily recs │ │ │ │
│ └──────┬───────┘ └─────────────┬─────────────────┘ │
├─────────┼────────────────────────┼───────────────────┤
│ Data Layer (Room) │ │
│ ┌──────┴───────┐ ┌────────────┴─────────────────┐ │
│ │ DailyRecord │ │ PeriodSettings │ │
│ │ Dao │ │ Dao │ │
│ └──────────────┘ └──────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ CycleCalculator (pure logic, no DB) │ │
│ │ - 推算排卵日、预测经期、排卵期 │ │
│ └─────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
3.2 Room 数据库设计
3.2.1 PeriodSettings — 经期设置(单行表)
@Entity(tableName = "period_settings")
data class PeriodSettings(
@PrimaryKey val id: Int = 1, // 固定为 1,单行
@ColumnInfo(name = "period_length") // 经期长度 (天), 默认 5
val periodLength: Int = 5,
@ColumnInfo(name = "cycle_length") // 周期长度 (天), 默认 28
val cycleLength: Int = 28,
@ColumnInfo(name = "last_period_date") // 最近一次月经首日 (epoch day)
val lastPeriodDate: Long = 0L, // LocalDate.toEpochDay()
)
3.2.2 DailyRecord — 每日打卡记录
@Entity(
tableName = "daily_records",
indices = [Index(value = ["date"], unique = true)]
)
data class DailyRecord(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "date")
val date: Long, // LocalDate.toEpochDay()
@ColumnInfo(name = "is_period_start")
val isPeriodStart: Boolean = false, // 月经来了 (是/否)
@ColumnInfo(name = "had_sex")
val hadSex: Boolean = false, // 爱爱
@ColumnInfo(name = "mood")
val mood: String? = null, // 心情标签
@ColumnInfo(name = "discharge") // 白带
val discharge: String? = null,
@ColumnInfo(name = "note")
val note: String? = null, // 备注
)
3.2.3 DAO
@Dao
interface PeriodSettingsDao {
@Query("SELECT * FROM period_settings WHERE id = 1")
fun getSettings(): Flow<PeriodSettings?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSettings(settings: PeriodSettings)
}
@Dao
interface DailyRecordDao {
@Query("SELECT * FROM daily_records WHERE date = :date")
fun getRecord(date: Long): Flow<DailyRecord?>
@Query("SELECT * FROM daily_records WHERE date BETWEEN :start AND :end")
fun getRecordsInRange(start: Long, end: Long): Flow<List<DailyRecord>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(record: DailyRecord)
@Delete
suspend fun delete(record: DailyRecord)
}
3.2.4 数据库迁移
@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
// ...
}
由于现有 DB version=1 已有用户数据,需要提供 migration 1→2:
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`)
)
""")
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
)
""")
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_daily_records_date` ON `daily_records` (`date`)")
}
}
3.3 周期计算引擎(纯 Kotlin,无 Android 依赖)
// data/model/CycleCalculator.kt
data class CycleInfo(
val actualPeriodRange: Set<LocalDate>, // 实际月经期
val predictedPeriodRange: Set<LocalDate>, // 预测经期
val ovulationDay: LocalDate?, // 排卵日
val ovulationRange: Set<LocalDate>, // 排卵期(易孕期)
)
object CycleCalculator {
/**
* @param yearMonth 要计算的月份
* @param settings 经期设置
* @param actualPeriodStarts 用户标记的所有"月经来了"日期集合
*/
fun calculate(
yearMonth: YearMonth,
settings: PeriodSettings,
actualPeriodStarts: Set<LocalDate>,
): CycleInfo {
// 1. 推算下月预测经期
// 2. 根据实际标记修正本月经期
// 3. 计算排卵日 & 排卵期
// ...
}
}
3.4 日历单元格渲染逻辑
enum class DayPhase {
NONE, // 无特殊状态
ACTUAL_PERIOD, // 月经期 — 深粉色背景
PREDICTED_PERIOD, // 预测经期 — 浅粉色背景
OVULATION, // 排卵期 — 紫色背景
OVULATION_DAY, // 排卵日 — 紫色背景 + 六边形标记
}
自定义 dayContent 替换库默认的 DefaultDay:
@Composable
fun FertilityDayContent(state: DayState<DynamicSelectionState>, cycleInfo: CycleInfo) {
val date = state.date
val phase = getPhaseForDate(date, cycleInfo)
Box(
modifier = Modifier
.aspectRatio(1f)
.padding(2.dp)
.background(
color = phase.backgroundColor(),
shape = RoundedCornerShape(4.dp)
)
.clickable { state.selectionState.onDateSelected(date) },
contentAlignment = Alignment.Center
) {
Text(text = date.dayOfMonth.toString())
// 排卵日六边形标记
if (phase == DayPhase.OVULATION_DAY) {
HexagonMarker(
modifier = Modifier.align(Alignment.BottomEnd).size(12.dp),
color = Color(0xFF7B1FA2) // 深紫
)
}
}
}
3.5 颜色定义
object FertilityColors {
val actualPeriod = Color(0xFFE91E63) // 深粉 — 实际月经期
val predictedPeriod = Color(0xFFFFCDD2) // 浅粉 — 预测经期
val ovulation = Color(0xFFCE93D8) // 紫色 — 排卵期
val ovulationDayMarker = Color(0xFF7B1FA2) // 深紫 — 排卵日六边形
val todayBorder = Color(0xFF2196F3) // 今日蓝色边框
val selectedBorder = Color(0xFF1976D2) // 选中蓝色边框
}
四、UI 设计
4.1 RecordsPage 结构调整
┌─────────────────────────────┐
│ 月 份 标 题 │ ← DefaultMonthHeader (保持)
├─┬─┬─┬─┬─┬─┬─┬─────────────┤
│日│一│二│三│四│五│六│ 星期栏 │
├─┼─┼─┼─┼─┼─┼─┼─┼───────────┤
│ │ │ │ ●│ ■│ ■│ ■│ ← 实际经期 (深粉)
│ ■│ ■│ ■│ ■│ ■│ │ │ ← 预测经期 (浅粉)
│ ▓│ ▓│ ▓│ ▓│ ▓│ ▓│ ▓│ ← 排卵期 (紫)
│ │ │ │ │ │ │ │
│ │ │ │ │ │◆│ │ ← 排卵日六边形
├─────────────────────────────┤
│ HorizontalDivider │
├─────────────────────────────┤
│ 选中日期: 2026-07-16 │
│ ┌─────────────────────┐ │
│ │ 月经来了 [Switch] │ │
│ │ 爱爱 [ - ] │ │ ← 后续迭代
│ │ 心情 [ - ] │ │
│ │ 白带 [ - ] │ │
│ └─────────────────────┘ │
└─────────────────────────────┘
4.2 RecordPage 核心逻辑伪代码
@Composable
fun RecordsPage(viewModel: RecordsViewModel = viewModel()) {
val calendarState = rememberSelectableCalendarState(
initialSelectionMode = SelectionMode.Single
)
val selectedDate by derivedStateOf { calendarState.selectionState.selection.firstOrNull() }
val cycleInfo by viewModel.cycleInfoForMonth(calendarState.monthState.currentMonth).collectAsState()
val settings by viewModel.settings.collectAsState()
Scaffold {
Column {
SelectableCalendar(
calendarState = calendarState,
dayContent = { dayState ->
FertilityDayContent(dayState, cycleInfo)
},
// ...
)
HorizontalDivider(...)
CheckInPanel(selectedDate, viewModel)
}
}
}
4.3 CheckInPanel
@Composable
fun CheckInPanel(selectedDate: LocalDate?, viewModel: RecordsViewModel) {
if (selectedDate == null) {
// 提示文字
return
}
val record by viewModel.getRecord(selectedDate).collectAsState(initial = null)
Column(Modifier.padding(16.dp)) {
// 月经来了 — Switch
Row {
Text("月经来了")
Switch(
checked = record?.isPeriodStart ?: false,
onCheckedChange = { checked ->
viewModel.setPeriodStart(selectedDate, checked)
}
)
}
// 后续迭代再加:爱爱、心情、白带...
}
}
4.4 MinePage — 添加经期设置入口
在"设置"上方插入一行:
// 在 MenuItemRow(icon = Icons.Default.Settings, ...) 之前添加:
MenuItemRow(
icon = Icons.Default.CalendarMonth, // 或自定义 icon
title = "经期设置",
onClick = { showPeriodSettings = true }
)
4.5 PeriodSettingsDialog
@Composable
fun PeriodSettingsDialog(
currentSettings: PeriodSettings,
onDismiss: () -> Unit,
onSave: (PeriodSettings) -> Unit
) {
var periodLength by remember { mutableIntStateOf(currentSettings.periodLength) }
var cycleLength by remember { mutableIntStateOf(currentSettings.cycleLength) }
var lastPeriodDate by remember { mutableStateOf(currentSettings.lastPeriodDate) }
var showDatePicker by remember { mutableStateOf(false) }
AlertDialog(...) {
Column {
// 经期长度 — WheelPicker (3-15)
Text("设置您的月经大概维持几天?")
WheelPicker(
items = (3..15).toList(),
selected = periodLength,
onSelected = { periodLength = it }
)
// 周期长度 — WheelPicker (17-31)
Text("两次月经开始日大概间隔多久?")
WheelPicker(
items = (17..31).toList(),
selected = cycleLength,
onSelected = { cycleLength = it }
)
// 最近一次月经开始时间 — DatePicker
Text("最近一次月经开始时间")
TextButton(onClick = { showDatePicker = true }) {
Text(lastPeriodDate.toLocalDate().format(...))
}
// 确认/取消
}
}
if (showDatePicker) {
// DatePickerDialog
}
}
五、ViewModel 设计
5.1 RecordsViewModel
class RecordsViewModel(
private val settingsDao: PeriodSettingsDao,
private val recordDao: DailyRecordDao,
) : ViewModel() {
val settings: StateFlow<PeriodSettings?>
private val actualPeriodStarts: StateFlow<Set<LocalDate>>
fun cycleInfoForMonth(yearMonth: YearMonth): Flow<CycleInfo>
fun getRecord(date: LocalDate): Flow<DailyRecord?>
fun setPeriodStart(date: LocalDate, isStart: Boolean)
// 重新计算 —— 当用户修改 periodStart 后触发
private fun recalculate()
}
5.2 ViewModel 关键逻辑:setPeriodStart
fun setPeriodStart(date: LocalDate, isStart: Boolean) {
viewModelScope.launch {
val record = recordDao.getRecordOnce(date) ?: DailyRecord(date = date.toEpochDay())
recordDao.upsert(record.copy(isPeriodStart = isStart))
// 如果是月经来了,还需要清除本月之前的 periodStart 标记
// (一个月只能有一个月经首日)
if (isStart) {
clearOtherPeriodStartsInMonth(date)
}
// 重新计算触发 UI 更新
_actualPeriodStarts.update { ... }
}
}
六、数据流
用户设置经期参数
│
▼
PeriodSettings ──→ CycleCalculator.calculate()
│ │
▼ ▼
用户标记"月经来了" ◇──→ CycleInfo (月经期/预测经期/排卵日/排卵期)
│ │
▼ ▼
DailyRecord FertilityDayContent (日历格子渲染)
关键点:
- 预测数据不存储,由 CycleCalculator 每次实时计算
- 实际月经期 = 用户标记的
isPeriodStart日期 + settings.periodLength - 用户修改「经期设置」或「月经来了」→ 立即触发重算 → UI 自动更新
七、文件清单(本次需新增/修改)
新增文件
| 路径 | 说明 |
|---|---|
data/entity/PeriodSettings.kt |
经期设置 Room Entity |
data/entity/DailyRecord.kt |
每日打卡记录 Room Entity |
data/dao/PeriodSettingsDao.kt |
经期设置 DAO |
data/dao/DailyRecordDao.kt |
每日记录 DAO |
data/model/CycleCalculator.kt |
周期计算引擎,纯 Kotlin |
data/model/CycleInfo.kt |
周期信息数据类 |
ui/page/RecordsViewModel.kt |
日历页 ViewModel |
ui/component/FertilityDayContent.kt |
自定义日历日单元格 |
ui/component/CheckInPanel.kt |
打卡面板 |
ui/component/PeriodSettingsDialog.kt |
经期设置弹窗 |
ui/component/WheelPicker.kt |
滚轮选择器(经期/周期天数选择) |
ui/component/HexagonMarker.kt |
六边形标记组件 |
ui/theme/FertilityColors.kt |
备孕日历颜色常量 |
修改文件
| 路径 | 改动 |
|---|---|
data/database/AppDatabase.kt |
新增 entities、DAOs、migration 1→2 |
ui/page/RecordsPage.kt |
完全重写:Single 选择 + 自定义 dayContent + CheckInPanel |
ui/page/MinePage.kt |
添加"经期设置"入口 + 弹窗状态 |
PinkApplication.kt |
无需改动(database 实例已在) |
八、关于 ComposeCalendar 库的关键结论
-
自定义 dayContent:
SelectableCalendar支持dayContent: @Composable BoxScope.(DayState<DynamicSelectionState>) -> Unit参数,可以完全替换默认的单元格渲染,实现我们需要的彩色背景 + 六边形标记 -
不依赖库的选中机制来展示经期/排卵期:库的
DynamicSelectionState.selection用于用户交互(单选日期),而经期/排卵期的「自动多选」显示完全在自定义dayContent中通过判断日期位置来实现 -
M2/M3 兼容:库内部使用 M2 组件,但我们通过自定义
dayContent绕过了库的DefaultDay,可以在其中使用 M3 颜色体系 -
MonthState 可用:通过
calendarState.monthState.currentMonth可以获知当前展示的月份,用于计算该月的周期信息
九、实施顺序(推荐)
- Room 层:新增 Entity + DAO + Migration
- CycleCalculator:纯逻辑,可先写单元测试
- FertilityDayContent + HexagonMarker:自定义日历格子
- RecordsViewModel:连接数据与 UI
- RecordsPage 改造:Single 选择 + 自定义 dayContent + CheckInPanel
- WheelPicker:通用滚轮组件
- PeriodSettingsDialog:经期设置弹窗
- MinePage 添加入口:一行导航