大法师
This commit is contained in:
@@ -0,0 +1,575 @@
|
||||
# 备孕日历 — 架构设计方案
|
||||
|
||||
> 基于美柚 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 添加入口**:一行导航
|
||||
@@ -0,0 +1,32 @@
|
||||
项目下的 美柚APP备孕日历功能列表.md 和 经期设置和计算.md 是美柚这个产品跟备孕日历设置相关的内容(不一定完全准确)。
|
||||
我想在pink项目里面,逐步实现大部分相关的功能。
|
||||
首先,我想实现经期设置 最基础的功能,在日历上展示:月经期、预测经期、排卵期、排卵日
|
||||
我们的日历需要相应的改造,能够呈现月经期、预测经期、排卵期、排卵日的状态。
|
||||
|
||||
预测经期:通过 淡粉色背景色填充日历的格子, 在日历上自动多选连续的预测经期。
|
||||
排卵期: 通过 紫色背景色填充日历的格子, 在日历上自动多选连续的排卵期。
|
||||
排卵日: 使用 深紫色的 小实心6变形,在日历中被预测的排卵日那个格子右下角花上这个6边形。
|
||||
月经期:当月日历中,实际的月经期,可能跟预测经期重叠,也可能不重叠。如果重叠或者部分重叠。重叠的格子,则显示深粉色的背景色(月经期的背景色)。
|
||||
目前你选择的日历是Period多选方式,对照美柚的UI ,可以看到这里应该采用单选,选中某个日期,则在当前界面日历的下面呈现可以打卡的项目列表(月经来了,爱爱、心情、白带等等)。
|
||||
月经来了:这是一个 是/否 这样的一个switch开关,用户点击了选择了**是**,则表示这一天是当月月经开始的第一天,根据用户设置的**经期长度**,自动通过 深粉色背景色填充日历的格子, 在日历上自动多选连续的实际的经期。
|
||||
用户只要重新设置了**月经来了**,这自动重算当前月日历视图上的实际的月经期。
|
||||
|
||||
|
||||
在MinePage添加一行导航,放在“设置”上面,点击则弹出 经期设置 的界面(使用dialog方式),设置:
|
||||
经期长度:通过轮播方式选择经期的天数,3,4,5,6,7,8....15 --- 设置您的月经大概维持几天?
|
||||
周期长度:通过轮播方式选择周期的天数,17,18,......31 --- 两次月经开始日大概间隔多久?
|
||||
最近一次月经开始时间:这里通过弹出一个日期选择?
|
||||
有了以上3个数据,就可以根据公式,算出预测经期、排卵期、排卵日!
|
||||
|
||||
### 标准计算公式
|
||||
1. 先算「下次月经来潮日」
|
||||
本次月经首日 + 平均月经周期天数 = 下次月经首日
|
||||
2. 排卵日 = 下次月经首日 − 14天
|
||||
示例:
|
||||
本次月经7.1,周期30天
|
||||
下次月经:7.1+30=7.31
|
||||
排卵日=7.31−14=7.17
|
||||
|
||||
---
|
||||
|
||||
你需要研究一下,怎么在本地的Room数据库存储,经期数据和关联的打卡数据。仔细分析美柚的两个文件、结合你对类似产品功能的设计能力,给出你的业务流程和建议,技术实现方案! 你把你的设计内容写成文档到docs目录
|
||||
Reference in New Issue
Block a user