# 备孕日历 — 架构设计方案 > 基于美柚 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` — 经期设置(单行表) ```kotlin @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` — 每日打卡记录 ```kotlin @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 ```kotlin @Dao interface PeriodSettingsDao { @Query("SELECT * FROM period_settings WHERE id = 1") fun getSettings(): Flow @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 @Query("SELECT * FROM daily_records WHERE date BETWEEN :start AND :end") fun getRecordsInRange(start: Long, end: Long): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(record: DailyRecord) @Delete suspend fun delete(record: DailyRecord) } ``` #### 3.2.4 数据库迁移 ```kotlin @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: ```kotlin 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 依赖) ```kotlin // data/model/CycleCalculator.kt data class CycleInfo( val actualPeriodRange: Set, // 实际月经期 val predictedPeriodRange: Set, // 预测经期 val ovulationDay: LocalDate?, // 排卵日 val ovulationRange: Set, // 排卵期(易孕期) ) object CycleCalculator { /** * @param yearMonth 要计算的月份 * @param settings 经期设置 * @param actualPeriodStarts 用户标记的所有"月经来了"日期集合 */ fun calculate( yearMonth: YearMonth, settings: PeriodSettings, actualPeriodStarts: Set, ): CycleInfo { // 1. 推算下月预测经期 // 2. 根据实际标记修正本月经期 // 3. 计算排卵日 & 排卵期 // ... } } ``` ### 3.4 日历单元格渲染逻辑 ```kotlin enum class DayPhase { NONE, // 无特殊状态 ACTUAL_PERIOD, // 月经期 — 深粉色背景 PREDICTED_PERIOD, // 预测经期 — 浅粉色背景 OVULATION, // 排卵期 — 紫色背景 OVULATION_DAY, // 排卵日 — 紫色背景 + 六边形标记 } ``` 自定义 `dayContent` 替换库默认的 `DefaultDay`: ```kotlin @Composable fun FertilityDayContent(state: DayState, 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 颜色定义 ```kotlin 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 核心逻辑伪代码 ```kotlin @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 ```kotlin @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 — 添加经期设置入口 在"设置"上方插入一行: ```kotlin // 在 MenuItemRow(icon = Icons.Default.Settings, ...) 之前添加: MenuItemRow( icon = Icons.Default.CalendarMonth, // 或自定义 icon title = "经期设置", onClick = { showPeriodSettings = true } ) ``` ### 4.5 PeriodSettingsDialog ```kotlin @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 ```kotlin class RecordsViewModel( private val settingsDao: PeriodSettingsDao, private val recordDao: DailyRecordDao, ) : ViewModel() { val settings: StateFlow private val actualPeriodStarts: StateFlow> fun cycleInfoForMonth(yearMonth: YearMonth): Flow fun getRecord(date: LocalDate): Flow fun setPeriodStart(date: LocalDate, isStart: Boolean) // 重新计算 —— 当用户修改 periodStart 后触发 private fun recalculate() } ``` ### 5.2 ViewModel 关键逻辑:setPeriodStart ```kotlin 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 库的关键结论 1. **自定义 dayContent**:`SelectableCalendar` 支持 `dayContent: @Composable BoxScope.(DayState) -> Unit` 参数,可以完全替换默认的单元格渲染,实现我们需要的彩色背景 + 六边形标记 2. **不依赖库的选中机制来展示经期/排卵期**:库的 `DynamicSelectionState.selection` 用于用户交互(单选日期),而经期/排卵期的「自动多选」显示完全在自定义 `dayContent` 中通过判断日期位置来实现 3. **M2/M3 兼容**:库内部使用 M2 组件,但我们通过自定义 `dayContent` 绕过了库的 `DefaultDay`,可以在其中使用 M3 颜色体系 4. **MonthState 可用**:通过 `calendarState.monthState.currentMonth` 可以获知当前展示的月份,用于计算该月的周期信息 --- ## 九、实施顺序(推荐) 1. **Room 层**:新增 Entity + DAO + Migration 2. **CycleCalculator**:纯逻辑,可先写单元测试 3. **FertilityDayContent + HexagonMarker**:自定义日历格子 4. **RecordsViewModel**:连接数据与 UI 5. **RecordsPage 改造**:Single 选择 + 自定义 dayContent + CheckInPanel 6. **WheelPicker**:通用滚轮组件 7. **PeriodSettingsDialog**:经期设置弹窗 8. **MinePage 添加入口**:一行导航