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 lunarDay = solarDay.getLunarDay()
val lunarMonth = lunarDay.getLunarMonth() val lunarMonth = lunarDay.getLunarMonth()
val lunarMonthName = lunarMonth.getName() val lunarMonthName = lunarMonth.getName()
val isBirthday = (date.month.number == 9 && date.day == 4) || // 阳历生日:每年 9 月 4 日
(lunarDay.getLunarMonth().getIndexInYear() == 0 && lunarDay.day == 21) 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() val lunarFestival = lunarDay.getFestival()
@ -118,6 +121,7 @@ class LunarCache(
* @param annotationText 底部标注文字农历/节气/节日 * @param annotationText 底部标注文字农历/节气/节日
* @param isAnnotationHighlight 是否为高亮标注节日/节气 * @param isAnnotationHighlight 是否为高亮标注节日/节气
* @param holidayBadge 法定调休角标""/""/null * @param holidayBadge 法定调休角标""/""/null
* @param isBirthday 是否为生日
*/ */
data class DayCellInfo( data class DayCellInfo(
val annotationText: String, val annotationText: String,

View File

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