Files
yola/Pink/docs/fertility-calendar-design.md
T
2026-07-17 15:37:35 +08:00

576 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 备孕日历 — 架构设计方案
> 基于美柚 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<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 数据库迁移
```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<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 日历单元格渲染逻辑
```kotlin
enum class DayPhase {
NONE, // 无特殊状态
ACTUAL_PERIOD, // 月经期 — 深粉色背景
PREDICTED_PERIOD, // 预测经期 — 浅粉色背景
OVULATION, // 排卵期 — 紫色背景
OVULATION_DAY, // 排卵日 — 紫色背景 + 六边形标记
}
```
自定义 `dayContent` 替换库默认的 `DefaultDay`
```kotlin
@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 颜色定义
```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<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
```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<DynamicSelectionState>) -> 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 添加入口**:一行导航