From 2427ae18a43928eed0eb5d42e8cb4bce2506fea6 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 15 Jun 2026 15:59:17 +0800 Subject: [PATCH] docs: add birthday crown implementation plan --- .../plans/2026-06-15-birthday-crown.md | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-birthday-crown.md diff --git a/docs/superpowers/plans/2026-06-15-birthday-crown.md b/docs/superpowers/plans/2026-06-15-birthday-crown.md new file mode 100644 index 0000000..6b8a4df --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-birthday-crown.md @@ -0,0 +1,368 @@ +# 生日皇冠标识实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在月视图和周视图的日期单元格中,为阳历 9 月 4 日与农历正月二十一日显示左上角金色皇冠,并在点击时播放放大回弹动画。 + +**Architecture:** 复用 `:core` 已有的 `LunarCache` 计算每个日期的 `isBirthday` 标志,通过 `DayCellInfo` 传递到 `DayCell`,由 `DayCell` 在单元格左上角绘制旋转后的皇冠图标并处理点击动画。 + +**Tech Stack:** Android/Jetpack Compose, Material 3, tyme4kt, kotlinx-datetime, JUnit 4, Kotlin Coroutines Test + +--- + +## 文件结构 + +| 文件 | 作用 | +|------|------| +| `core/src/main/res/drawable/ic_birthday_crown.xml` | 皇冠 Vector Drawable,保留 SVG 原始黄色/金色 | +| `core/src/main/kotlin/plus/rua/project/LunarCache.kt` | 新增 `isBirthday` 字段与生日判断逻辑 | +| `core/src/test/kotlin/plus/rua/project/LunarCacheBirthdayTest.kt` | 生日计算单元测试 | +| `core/src/main/kotlin/plus/rua/project/ui/DayCell.kt` | 在左上角显示皇冠并处理点击动画 | + +--- + +### Task 1: 添加皇冠 Vector Drawable + +**Files:** +- Create: `core/src/main/res/drawable/ic_birthday_crown.xml` + +- [ ] **Step 1: 创建 Vector Drawable 文件** + +```bash +mkdir -p core/src/main/res/drawable +``` + +```xml + + + + + + + + + + + +``` + +- [ ] **Step 2: 验证资源可被引用** + +构建一次确认资源文件格式正确: + +```bash +./gradlew :core:mergeDebugResources +``` + +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 3: Commit** + +```bash +git add core/src/main/res/drawable/ic_birthday_crown.xml +git commit -m "feat: add birthday crown vector drawable" +``` + +--- + +### Task 2: 在 LunarCache 中计算生日并添加测试 + +**Files:** +- Modify: `core/src/main/kotlin/plus/rua/project/LunarCache.kt` +- Create: `core/src/test/kotlin/plus/rua/project/LunarCacheBirthdayTest.kt` + +- [ ] **Step 1: 编写失败测试** + +```kotlin +// core/src/test/kotlin/plus/rua/project/LunarCacheBirthdayTest.kt +package plus.rua.project + +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LunarCacheBirthdayTest { + private val cache = LunarCache() + + @Test + fun solarBirthdaySeptember4_returnsTrue() = runTest { + val info = cache.getOrCompute(LocalDate(2026, 9, 4)) + assertTrue("阳历 9 月 4 日应为生日", info.isBirthday) + } + + @Test + fun lunarBirthdayFirstMonthDay21_returnsTrue() = runTest { + // 2026 年农历正月二十一对应阳历 2026-03-09 + val info = cache.getOrCompute(LocalDate(2026, 3, 9)) + assertTrue("农历正月二十一应为生日", info.isBirthday) + } + + @Test + fun regularDate_returnsFalse() = runTest { + val info = cache.getOrCompute(LocalDate(2026, 6, 15)) + assertFalse("普通日期不应为生日", info.isBirthday) + } + + @Test + fun solarBirthdayNotFirstLunarDay21_stillReturnsTrue() = runTest { + // 2026 年 9 月 4 日是农历六月廿三,验证仅满足阳历条件时也返回 true + val info = cache.getOrCompute(LocalDate(2026, 9, 4)) + assertTrue(info.isBirthday) + } + + @Test + fun lunarBirthdayNotSeptember4_stillReturnsTrue() = runTest { + // 2026 年 3 月 9 日是阳历,不是 9 月 4 日,验证仅满足农历条件时也返回 true + val info = cache.getOrCompute(LocalDate(2026, 3, 9)) + assertTrue(info.isBirthday) + } +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +./gradlew :core:testDebugUnitTest --tests "plus.rua.project.LunarCacheBirthdayTest" +``` + +Expected: 测试编译失败(`isBirthday` 不存在)或运行失败。 + +- [ ] **Step 3: 修改 LunarCache.kt** + +在 `compute()` 中计算 `isBirthday`,并更新 `DayCellInfo` 数据类: + +```kotlin +private fun compute(date: LocalDate): DayCellInfo { + val solarDay = SolarDay.fromYmd(date.year, date.month.number, date.day) + val holidayBadge = solarDay.getLegalHoliday()?.let { if (it.isWork()) "班" else "休" } + val lunarDay = solarDay.getLunarDay() + val lunarMonth = lunarDay.getLunarMonth() + val lunarMonthName = lunarMonth.getName() + + val isBirthday = (date.month.number == 9 && date.day == 4) || + (lunarDay.getLunarMonth().getIndexInYear() == 1 && lunarDay.getDay() == 21) + + // 农历传统节日(仅当天) + val lunarFestival = lunarDay.getFestival() + if (lunarFestival != null) { + return DayCellInfo(lunarFestival.getName(), true, holidayBadge, lunarMonthName, isBirthday) + } + + // 节气(当天才显示) + val termDay = solarDay.getTermDay() + if (termDay.getDayIndex() == 0) { + return DayCellInfo(termDay.getSolarTerm().getName(), true, holidayBadge, lunarMonthName, isBirthday) + } + + // 公历节日(仅当天) + val solarFestival = solarDay.getFestival() + if (solarFestival != null) { + return DayCellInfo(solarFestival.getName(), true, holidayBadge, lunarMonthName, isBirthday) + } + + // 默认:农历日期 + val name = lunarDay.getName() + val text = if (name == "初一") { + lunarMonthName + } else { + name + } + return DayCellInfo(text, false, holidayBadge, lunarMonthName, isBirthday) +} +``` + +更新 `DayCellInfo`: + +```kotlin +data class DayCellInfo( + val annotationText: String, + val isAnnotationHighlight: Boolean, + val holidayBadge: String?, + val lunarMonthName: String? = null, + val isBirthday: Boolean = false +) +``` + +- [ ] **Step 4: 运行测试确认通过** + +```bash +./gradlew :core:testDebugUnitTest --tests "plus.rua.project.LunarCacheBirthdayTest" +``` + +Expected: `BUILD SUCCESSFUL` 且 5 个测试全部通过。 + +- [ ] **Step 5: Commit** + +```bash +git add core/src/main/kotlin/plus/rua/project/LunarCache.kt \ + core/src/test/kotlin/plus/rua/project/LunarCacheBirthdayTest.kt +git commit -m "feat: compute birthday flag in LunarCache" +``` + +--- + +### Task 3: 在 DayCell 中显示皇冠和点击动画 + +**Files:** +- Modify: `core/src/main/kotlin/plus/rua/project/ui/DayCell.kt` + +- [ ] **Step 1: 添加所需 import** + +在 `DayCell.kt` 顶部添加以下 import: + +```kotlin +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.material3.Icon +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.painterResource +import plus.rua.project.shared.R +``` + +- [ ] **Step 2: 修改 DayCellImpl 参数签名并添加动画状态** + +在 `DayCellImpl` 函数体开始处(`val annotationText = lunarData.annotationText` 之前)添加: + +```kotlin + val isBirthday = lunarData.isBirthday + var isPressedBirthday by remember { mutableStateOf(false) } + val crownScale = remember { Animatable(1f) } + LaunchedEffect(isPressedBirthday) { + if (isPressedBirthday) { + crownScale.animateTo(1.4f, spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + crownScale.animateTo(1f, spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + isPressedBirthday = false + } + } +``` + +- [ ] **Step 3: 包装点击回调** + +找到 `Box` 上的 `.clickable(...)` 调用,将 `onClick = onClick` 替换为: + +```kotlin + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { + if (isBirthday) isPressedBirthday = true + onClick() + } + ), +``` + +- [ ] **Step 4: 在单元格左上角添加皇冠** + +在 `DayCellImpl` 最外层 `Box` 内部、班次标记 `if (shiftKind != null) { ... }` 之后添加: + +```kotlin + if (isBirthday) { + Icon( + painter = painterResource(R.drawable.ic_birthday_crown), + contentDescription = "生日", + tint = Color.Unspecified, + modifier = Modifier + .align(Alignment.TopStart) + .zIndex(1f) + .padding(start = 2.dp, top = 2.dp) + .size(14.dp) + .graphicsLayer { + rotationZ = -45f + transformOrigin = TransformOrigin.Center + scaleX = crownScale.value + scaleY = crownScale.value + } + ) + } +``` + +- [ ] **Step 5: 编译确认无错误** + +```bash +./gradlew :core:compileDebugKotlin +``` + +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/kotlin/plus/rua/project/ui/DayCell.kt +git commit -m "feat: show tilted birthday crown with click bounce animation" +``` + +--- + +### Task 4: 格式化与全量测试 + +**Files:** +- Modify: 前述所有文件(由 spotless 自动格式化) + +- [ ] **Step 1: 运行代码格式化** + +```bash +./gradlew spotlessApply +``` + +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 2: 运行全量单元测试** + +```bash +./gradlew :core:testDebugUnitTest +``` + +Expected: `BUILD SUCCESSFUL` 且所有测试通过。 + +- [ ] **Step 3: 提交格式化改动** + +```bash +git add -u +git commit -m "style: apply spotless formatting" +``` + +--- + +## Self-Review Checklist + +1. **Spec coverage** + - 阳历 9 月 4 日生日 → Task 2 `isBirthday` 计算 + Task 2 测试。 + - 农历正月二十一生日 → Task 2 `isBirthday` 计算 + Task 2 测试。 + - 仅月/周视图显示 → Task 3 仅修改 `DayCell`,不碰 `YearGridView`。 + - 左上角、左倾 45°、14dp、保留原色 → Task 3 皇冠代码。 + - 重合显示一个 → Task 2 使用 OR 逻辑,Task 3 单一 Icon。 + - 点击放大回弹动画 → Task 3 `Animatable` + `spring`。 + +2. **Placeholder scan** + - 无 TBD/TODO;所有代码块完整;所有命令含预期输出。 + +3. **Type consistency** + - `DayCellInfo.isBirthday` 在 Task 2 定义,Task 3 读取。 + - `R.drawable.ic_birthday_crown` 在 Task 1 创建,Task 3 引用。 + - tyme4kt API 使用 `getIndexInYear()` 与 `getDay()`,已通过本地 JVM 脚本验证。