Compare commits

...

4 Commits

5 changed files with 217 additions and 8 deletions

View File

@ -80,23 +80,25 @@ class LunarCache(
// 农历生日每年正月二十一tyme4kt 中正月 indexInYear = 0
val isLunarBirthday = lunarMonth.getIndexInYear() == 0 && lunarDay.day == 21
val isBirthday = isSolarBirthday || isLunarBirthday
// 玫瑰日:每年 10 月 16 日
val isRoseDay = date.month.number == 10 && date.day == 16
// 农历传统节日(仅当天)
val lunarFestival = lunarDay.getFestival()
if (lunarFestival != null) {
return DayCellInfo(lunarFestival.getName(), true, holidayBadge, lunarMonthName, isBirthday)
return DayCellInfo(lunarFestival.getName(), true, holidayBadge, lunarMonthName, isBirthday, isRoseDay)
}
// 节气(当天才显示)
val termDay = solarDay.getTermDay()
if (termDay.getDayIndex() == 0) {
return DayCellInfo(termDay.getSolarTerm().getName(), true, holidayBadge, lunarMonthName, isBirthday)
return DayCellInfo(termDay.getSolarTerm().getName(), true, holidayBadge, lunarMonthName, isBirthday, isRoseDay)
}
// 公历节日(仅当天)
val solarFestival = solarDay.getFestival()
if (solarFestival != null) {
return DayCellInfo(solarFestival.getName(), true, holidayBadge, lunarMonthName, isBirthday)
return DayCellInfo(solarFestival.getName(), true, holidayBadge, lunarMonthName, isBirthday, isRoseDay)
}
// 默认:农历日期
@ -106,7 +108,7 @@ class LunarCache(
} else {
name
}
return DayCellInfo(text, false, holidayBadge, lunarMonthName, isBirthday)
return DayCellInfo(text, false, holidayBadge, lunarMonthName, isBirthday, isRoseDay)
}
companion object {
@ -122,11 +124,13 @@ class LunarCache(
* @param isAnnotationHighlight 是否为高亮标注节日/节气
* @param holidayBadge 法定调休角标""/""/null
* @param isBirthday 是否为生日
* @param isRoseDay 是否为玫瑰日每年 10 16
*/
data class DayCellInfo(
val annotationText: String,
val isAnnotationHighlight: Boolean,
val holidayBadge: String?,
val lunarMonthName: String? = null,
val isBirthday: Boolean = false
val isBirthday: Boolean = false,
val isRoseDay: Boolean = false
)

View File

@ -60,7 +60,8 @@ enum class DayCellState {
}
/**
* 单个日期单元格显示日期数字并支持选中/今天/非当月状态生日日期左上角显示金色皇冠
* 单个日期单元格显示日期数字并支持选中/今天/非当月状态玫瑰日日期左上角显示玫瑰图标
* 生日日期左上角显示金色皇冠若两者重合则优先显示玫瑰图标
*
* @param date 日期
* @param isCurrentMonth 是否属于当前显示月份
@ -159,6 +160,16 @@ private fun DayCellImpl(
}
}
val isRoseDay = lunarData.isRoseDay
var roseClickTick by remember(date) { mutableIntStateOf(0) }
val roseScale = remember(date) { Animatable(1f) }
LaunchedEffect(roseClickTick) {
if (roseClickTick > 0) {
roseScale.animateTo(1.4f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
roseScale.animateTo(1f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
}
}
val annotationText = lunarData.annotationText
val isAnnotationHighlight = lunarData.isAnnotationHighlight
val holidayBadge = lunarData.holidayBadge
@ -321,7 +332,11 @@ private fun DayCellImpl(
interactionSource = interactionSource,
indication = null,
onClick = {
if (isBirthday) birthdayClickTick += 1
if (isRoseDay) {
roseClickTick += 1
} else if (isBirthday) {
birthdayClickTick += 1
}
onClick()
}
),
@ -373,7 +388,24 @@ private fun DayCellImpl(
)
}
}
if (isBirthday) {
if (isRoseDay) {
Icon(
painter = painterResource(R.drawable.ic_rose),
contentDescription = null,
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 = roseScale.value
scaleY = roseScale.value
}
)
} else if (isBirthday) {
Icon(
painter = painterResource(R.drawable.ic_birthday_crown),
contentDescription = null,

View File

@ -0,0 +1,50 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="72"
android:viewportHeight="72">
<path
android:pathData="M38.163 16.825s-5.19-.514-5.862 3.044 1.757 6.498 2.93 6.655c0 0-2.555 6.294 1.873 9.694s9.162 1.191 10.976-2.122c0 0 8.084.967 8.44-6.457 0 0-.965-6.424-5.866-6.293 0 0 1.363-6.977-3.898-8.471s-8.593 3.95-8.593 3.95"
android:fillColor="#EA5A47" />
<path
android:pathData="M25.93 32.603c3.991 4.886 2.804 12.462 2.804 12.462s-7.66-.346-11.653-5.232-2.805-12.462-2.805-12.462 7.661.35 11.653 5.232"
android:fillColor="#B1CC33" />
<path
android:pathData="M44.412 38.237c.91 1.976 1.874 3.717 3.497 4.563 1.708.89 3.683.676 5.128-.125 1.526-.847 2.655-2.51 2.697-4.595.036-1.762-1.106-3.524-2.148-5.403l-5.037 2.432zM51.952 20.074s.196-3.275-.304-4.074c0 0 1.142-1.674 3.088-1.317 1.482.272 2.332 3.318 2.653 4.755.112.503.288.723-.05 1.112-.545.627-1.803 1.805-2.96 2.058z"
android:fillColor="#D22F27" />
<path
android:pathData="M48.366 22.704c0 1.631-2.093 2.954-4.675 2.954s-4.675-1.323-4.675-2.954"
android:fillColor="#EA5A47" />
<path
android:pathData="M50.525 31.17c-.501.686-1.424 1.104-2.286 1.355a8.3 8.3 0 0 1-3.543.254 10 10 0 0 1-5.45-2.651 9.3 9.3 0 0 1-2.708-4.85q-.001 0-.014-.089a3.88 3.88 0 0 1 1.391-3.413c.656-.554 1.584-.882 2.476-1.004a8.3 8.3 0 0 1 3.542.269 9.84 9.84 0 0 1 4.96 3.418 9.15 9.15 0 0 1 1.898 5.188"
android:fillColor="#D22F27" />
<path
android:pathData="M39.016 22.839c-.002 1.612 1.917 2.667 4.515 2.778s4.759-1.12 4.828-2.75v-.315c-.02-1.632-2.142-2.974-4.74-2.998s-4.622 1.652-4.603 3.285"
android:fillColor="#EA5A47" />
<path
android:pathData="M43.174 24.818c-.61-1.417.329-3.184 2.098-3.948.918-.396 1.96-.354 2.875-.035 1.417.494 2.41 1.88 2.375 3.382a1.8 1.8 0 0 1-.103.606"
android:fillColor="#D22F27" />
<path
android:pathData="M43.174 23.83c-.61 1.416.329 3.184 2.098 3.947.918.397 1.96.354 2.875.035 1.417-.494 2.41-1.88 2.375-3.382a1.8 1.8 0 0 0-.103-.605"
android:fillColor="#D22F27" />
<path
android:pathData="M49.868 30.398c-.443.635-1.259 1.02-2.021 1.253a7.06 7.06 0 0 1-3.132.235 8.7 8.7 0 0 1-4.818-2.452 8.7 8.7 0 0 1-2.393-4.485M40.003 22.822c0-1.63 1.653-2.953 3.692-2.953s3.692 1.322 3.692 2.953"
android:strokeColor="#000000"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeMiterLimit="10" />
<path
android:pathData="M49.41 24.987c0 1.16-1.176 2.102-2.627 2.102-.753 0-1.461-.34-1.998-.834-.833-.764-1.107-2.018-.635-3.045.069-.15.152-.284.252-.388M53.308 15.903c.98-.624 2.405-.138 3.185 1.086s.617 2.722-.362 3.346M54.195 35.924a4.363 4.363 0 0 1-7.63 4.233M38.163 16.193c-2.253-.855-5.918 1.231-6.512 3.676-.57 2.341.9 6.214 3.153 7.069M50.867 20.834c5.416.628 6.396 6.461 5.802 8.774-.882 3.435-5.39 5.401-8.12 4.69M50.867 20.834a6.762 6.762 0 1 0-12.704-4.64M34.804 26.938a7.796 7.796 0 1 0 13.745 7.36"
android:strokeColor="#000000"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeMiterLimit="10" />
<path
android:pathData="M25.93 32.31c3.991 4.885 2.804 12.461 2.804 12.461s-7.66-.347-11.653-5.233-2.805-12.462-2.805-12.462 7.661.351 11.653 5.233M37.104 40.864c-3.438 3.548-7.676 10.246-5.829 20.735"
android:strokeColor="#000000"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View File

@ -0,0 +1,29 @@
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 LunarCacheRoseDayTest {
private val cache = LunarCache()
@Test
fun october16_returnsTrue() = runTest {
val info = cache.getOrCompute(LocalDate(2026, 10, 16))
assertTrue("10 月 16 日应为玫瑰日", info.isRoseDay)
}
@Test
fun september16_returnsFalse() = runTest {
val info = cache.getOrCompute(LocalDate(2026, 9, 16))
assertFalse("非 10 月的 16 日不应为玫瑰日", info.isRoseDay)
}
@Test
fun regularDate_returnsFalse() = runTest {
val info = cache.getOrCompute(LocalDate(2026, 6, 15))
assertFalse("普通日期不应为玫瑰日", info.isRoseDay)
}
}

View File

@ -0,0 +1,94 @@
# 玫瑰日图标设计
## 背景
在日历单元格上为每年 **10 月 16 日** 增加一个小玫瑰图标,作为纪念日标识。实现方式参考已有的生日皇冠图标配置。
## 目标
- 每年 10 月 16 日的单元格左上角显示玫瑰图标。
- 与生日皇冠共用同一套渲染位置、尺寸和点击动画。
- 若 10 月 16 日同时命中生日,优先显示玫瑰图标。
- 不引入额外的无障碍文本,图标仅作为视觉装饰。
## 方案选择
采用**方案一:沿用生日模式做最小扩展**。理由:
- 与现有生日代码完全同构,改动最小;
- 符合用户“参考已有生日的配置”的要求;
- 可读性最好,降低维护者理解成本。
## 详细设计
### 数据层
文件:`core/src/main/kotlin/plus/rua/project/LunarCache.kt`
`DayCellInfo` 中新增字段:
```kotlin
data class DayCellInfo(
val annotationText: String,
val isAnnotationHighlight: Boolean,
val holidayBadge: String?,
val lunarMonthName: String? = null,
val isBirthday: Boolean = false,
val isRoseDay: Boolean = false,
)
```
`LunarCache.compute()` 中加入玫瑰日判断:
```kotlin
val isRoseDay = date.month.number == 10 && date.day == 16
```
并将 `isRoseDay` 传入所有 `DayCellInfo(...)` 构造分支。
### UI 层
文件:`core/src/main/kotlin/plus/rua/project/ui/DayCell.kt`
`DayCellImpl` 中:
1. 读取 `val isRoseDay = lunarData.isRoseDay`
2. 新增 `roseClickTick``roseScale` 状态。
3. 在 `clickable``onClick` 中:若 `isRoseDay` 则触发 `roseClickTick += 1`,随后调用外部 `onClick()`
4. 在 `Box` 的左上角优先渲染玫瑰图标:
- 若 `isRoseDay``true`,显示 `R.drawable.ic_rose`
- 否则若 `isBirthday``true`,显示 `R.drawable.ic_birthday_crown`
5. 图标尺寸、内边距与皇冠保持一致:
- `size(14.dp)`
- `padding(start = 2.dp, top = 2.dp)`
- `rotationZ = -45f`
- 点击时通过 `graphicsLayer``scaleX / scaleY` 播放弹跳动画。
### 资源
- 源文件:`~/Pictures/rose-svgrepo-com.svg`
- 目标路径:`core/src/main/res/drawable/ic_rose.xml`
- 转换方式:将 SVG 转为 Android VectorDrawable保留原始填充色渲染时使用 `Color.Unspecified`
### 测试
新增文件:`core/src/test/kotlin/plus/rua/project/LunarCacheRoseDayTest.kt`
覆盖以下场景:
1. `LocalDate(2026, 10, 16)``isRoseDay``true`
2. 普通日期(如 `LocalDate(2026, 6, 15)`)的 `isRoseDay``false`
3. 非 10 月的 16 日(如 `LocalDate(2026, 9, 16)`)的 `isRoseDay``false`
## 改动文件
- `core/src/main/kotlin/plus/rua/project/LunarCache.kt`
- `core/src/main/kotlin/plus/rua/project/ui/DayCell.kt`
- `core/src/main/res/drawable/ic_rose.xml`(新增)
- `core/src/test/kotlin/plus/rua/project/LunarCacheRoseDayTest.kt`(新增)
## 风险与注意事项
- 图标与生日皇冠同位置,需确保 `isRoseDay` 判断在前,避免被生日皇冠覆盖。
- SVG 转换后需检查 `viewportWidth` / `viewportHeight``pathData` 是否正确。
- 新增字段后 `DayCellInfo``copy` / 解构等用法不受影响。