Merge branch 'feature-birthday-crown'

This commit is contained in:
xfy 2026-06-15 16:48:24 +08:00
commit 5bf8a85f7a
4 changed files with 89 additions and 22 deletions

View File

@ -75,8 +75,11 @@ class LunarCache(
val lunarDay = solarDay.getLunarDay()
val lunarMonth = lunarDay.getLunarMonth()
val lunarMonthName = lunarMonth.getName()
val isBirthday = (date.month.number == 9 && date.day == 4) ||
(lunarDay.getLunarMonth().getIndexInYear() == 0 && lunarDay.day == 21)
// 阳历生日:每年 9 月 4 日
val isSolarBirthday = date.month.number == 9 && date.day == 4
// 农历生日每年正月二十一tyme4kt 中正月 indexInYear = 0
val isLunarBirthday = lunarMonth.getIndexInYear() == 0 && lunarDay.day == 21
val isBirthday = isSolarBirthday || isLunarBirthday
// 农历传统节日(仅当天)
val lunarFestival = lunarDay.getFestival()
@ -118,6 +121,7 @@ class LunarCache(
* @param annotationText 底部标注文字农历/节气/节日
* @param isAnnotationHighlight 是否为高亮标注节日/节气
* @param holidayBadge 法定调休角标""/""/null
* @param isBirthday 是否为生日
*/
data class DayCellInfo(
val annotationText: String,

View File

@ -1,9 +1,12 @@
package plus.rua.project.ui
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.ui.graphics.TransformOrigin
@ -16,14 +19,19 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -31,6 +39,7 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@ -44,13 +53,14 @@ import kotlinx.datetime.number
import plus.rua.project.DayCellInfo
import plus.rua.project.LunarCache
import plus.rua.project.ShiftKind
import plus.rua.project.shared.R
enum class DayCellState {
NORMAL, OTHER_MONTH, TODAY, SELECTED, SELECTED_TODAY
}
/**
* 单个日期单元格显示日期数字并支持选中/今天/非当月状态
* 单个日期单元格显示日期数字并支持选中/今天/非当月状态生日日期左上角显示金色皇冠
*
* @param date 日期
* @param isCurrentMonth 是否属于当前显示月份
@ -63,6 +73,9 @@ enum class DayCellState {
* @param holidayEdgeInfo 假日在连续序列中的边缘状态,决定背景圆角null 表示无假日
* @param cellIndex 单元格在月网格中的线性索引(weekIndex*7+dayIndex),用于法定假日波浪动画延迟
* @param onClick 点击回调
* @param interactionSource 点击交互源
* @param lunarCache 农历缓存实例
* @param lunarData 预计算的日期信息null 时内部自动获取
* @param modifier 外部布局修饰符
*/
@Composable
@ -76,10 +89,10 @@ fun DayCell(
holidayEdgeInfo: HolidayEdgeInfo? = null,
cellIndex: Int = 0,
onClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
lunarCache: LunarCache = LunarCache.default,
lunarData: DayCellInfo? = null,
modifier: Modifier = Modifier,
) {
if (lunarData != null) {
DayCellImpl(
@ -92,9 +105,9 @@ fun DayCell(
holidayEdgeInfo = holidayEdgeInfo,
cellIndex = cellIndex,
onClick = onClick,
modifier = modifier,
interactionSource = interactionSource,
lunarData = lunarData,
modifier = modifier,
)
} else {
val computed by produceState(
@ -114,9 +127,9 @@ fun DayCell(
holidayEdgeInfo = holidayEdgeInfo,
cellIndex = cellIndex,
onClick = onClick,
modifier = modifier,
interactionSource = interactionSource,
lunarData = computed,
modifier = modifier,
)
}
}
@ -132,10 +145,20 @@ private fun DayCellImpl(
holidayEdgeInfo: HolidayEdgeInfo?,
cellIndex: Int,
onClick: () -> Unit,
modifier: Modifier,
interactionSource: MutableInteractionSource,
lunarData: DayCellInfo,
modifier: Modifier,
) {
val isBirthday = lunarData.isBirthday
var birthdayClickTick by remember(date) { mutableIntStateOf(0) }
val crownScale = remember(date) { Animatable(1f) }
LaunchedEffect(birthdayClickTick) {
if (birthdayClickTick > 0) {
crownScale.animateTo(1.4f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
crownScale.animateTo(1f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
}
}
val annotationText = lunarData.annotationText
val isAnnotationHighlight = lunarData.isAnnotationHighlight
val holidayBadge = lunarData.holidayBadge
@ -266,10 +289,11 @@ private fun DayCellImpl(
modifier = Modifier
.fillMaxSize()
.semantics {
val birthdaySuffix = if (isBirthday) ",生日" else ""
contentDescription = if (isToday) {
"今天 ${date.year}${date.month.number}${date.day}"
"今天 ${date.year}${date.month.number}${date.day}$birthdaySuffix"
} else {
"${date.year}${date.month.number}${date.day}"
"${date.year}${date.month.number}${date.day}$birthdaySuffix"
}
}
.clip(CircleShape)
@ -296,7 +320,10 @@ private fun DayCellImpl(
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick
onClick = {
if (isBirthday) birthdayClickTick += 1
onClick()
}
),
contentAlignment = Alignment.Center
) {
@ -346,5 +373,23 @@ private fun DayCellImpl(
)
}
}
if (isBirthday) {
Icon(
painter = painterResource(R.drawable.ic_birthday_crown),
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 = crownScale.value
scaleY = crownScale.value
}
)
}
}
}

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FFC033"
android:pathData="M461.913,456.348H50.087c-9.223,0-16.696-7.473-16.696-16.696V372.87c0-9.223,7.473-16.696,16.696-16.696h411.826c9.223,0,16.696,7.473,16.696,16.696v66.783C478.609,448.875,471.136,456.348,461.913,456.348z" />
<path
android:fillColor="#FFE14D"
android:pathData="M478.609,389.565H33.391V72.348c0-6.957,4.31-13.185,10.821-15.63c6.527-2.424,13.859-0.598,18.44,4.636l102.511,117.152l76.946-115.418c6.608-9.913,21.175-9.913,27.783,0l76.946,115.418L449.349,61.353c4.587-5.234,11.935-7.06,18.44-4.636c6.51,2.445,10.82,8.674,10.82,15.63V389.565z" />
<path
android:fillColor="#F37B2A"
android:pathData="M256,322.783c-27.619,0-50.087-22.468-50.087-50.087s22.468-50.087,50.087-50.087s50.087,22.468,50.087,50.087S283.619,322.783,256,322.783z" />
<path
android:fillColor="#F37B2A"
android:pathData="M50.087,322.783C22.468,322.783,0,300.315,0,272.696s22.468-50.087,50.087-50.087s50.087,22.468,50.087,50.087S77.706,322.783,50.087,322.783z" />
<path
android:fillColor="#F9A926"
android:pathData="M461.913,356.174H256v100.174h205.913c9.223,0,16.696-7.473,16.696-16.696V372.87C478.609,363.647,471.136,356.174,461.913,356.174z" />
<path
android:fillColor="#FFCC33"
android:pathData="M478.609,389.565V72.348c0-6.957-4.31-13.185-10.821-15.63c-6.506-2.424-13.853-0.598-18.44,4.636L346.837,178.505L269.891,63.087c-3.304-4.957-8.597-7.435-13.891-7.435v333.913H478.609z" />
<path
android:fillColor="#E56722"
android:pathData="M306.087,272.696c0-27.619-22.468-50.087-50.087-50.087v100.174C283.619,322.783,306.087,300.315,306.087,272.696z" />
<path
android:fillColor="#E56722"
android:pathData="M461.913,322.783c-27.619,0-50.087-22.468-50.087-50.087s22.468-50.087,50.087-50.087S512,245.077,512,272.696S489.532,322.783,461.913,322.783z" />
</vector>

View File

@ -27,16 +27,4 @@ class LunarCacheBirthdayTest {
val info = cache.getOrCompute(LocalDate(2026, 6, 15))
assertFalse("普通日期不应为生日", info.isBirthday)
}
@Test
fun solarBirthdayNotFirstLunarDay21_stillReturnsTrue() = runTest {
val info = cache.getOrCompute(LocalDate(2026, 9, 4))
assertTrue(info.isBirthday)
}
@Test
fun lunarBirthdayNotSeptember4_stillReturnsTrue() = runTest {
val info = cache.getOrCompute(LocalDate(2026, 3, 9))
assertTrue(info.isBirthday)
}
}