576 lines
20 KiB
Markdown
576 lines
20 KiB
Markdown
# 备孕日历 — 架构设计方案
|
||
|
||
> 基于美柚 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 添加入口**:一行导航
|