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

20 KiB
Raw Blame History

备孕日历 — 架构设计方案

基于美柚 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 库的关键结论

  1. 自定义 dayContentSelectableCalendar 支持 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 添加入口:一行导航