From e0b77003069dfc505e5fb846fd33bd4848b88c5b Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 19 May 2026 00:44:26 +0800 Subject: [PATCH 01/12] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20Perfetto=20?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E6=8E=92=E6=9F=A5=E6=96=87=E6=A1=A3=EF=BC=9B?= =?UTF-8?q?refactor:=20=E7=BC=93=E5=AD=98=20animatable=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DEVELOPMENT.md 新增 Perfetto / Systrace trace 分析指南 - CalendarViewModel.onDrag/onExpandDrag 中缓存 _collapseAnimatable.value 到局部变量,避免在 coroutineScope.launch 闭包中重复读取 - .gitignore 添加 .claude/ 目录 Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + DEVELOPMENT.md | 139 ++++++++++++++++++ .../plus/rua/project/CalendarViewModel.kt | 20 ++- 3 files changed, 152 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index e947f30..4587307 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ node_modules/ # OMC runtime state .omc/ logs/ +.claude/ \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6786acf..cdf5fa0 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -111,3 +111,142 @@ CalendarMonthView (顶层屏幕) **折叠动画:** `CalendarViewModel.collapseProgress` 控制 0f(月)↔1f(周) 过渡。`BottomCard` 捕获垂直拖拽,释放时超过 50% 则弹簧动画吸附到最近状态。完全折叠后 `WeekPager` 替代 `CalendarPager` 实现高效单周分页。 **分页映射:** 两个 Pager 均使用 `Int.MAX_VALUE` 页数,中心页为 `Int.MAX_VALUE / 2`。页码到日期为算术转换,无索引列表。两者均跳过初始 `snapshotFlow` 发射 (`.drop(1)`) 以保留首次渲染时的"今日"选中。 + +## 性能排查(Perfetto / Systrace) + +项目使用 `composeTraceBeginSection` / `composeTraceEndSection` 在关键代码段插入 trace marker,Android 上会被记录到系统 trace 中。iOS 为空操作。 + +已有的 trace section: +- `MonthView:Compose` / `YearView:Compose` — 顶层重组耗时 +- `YearView→MonthView` / `MonthView→YearView` — 年视图切换动画 +- `YearGridView:$year` / `generateMiniMonthDays:$year-$month` — 年网格渲染 +- `getMonthDays:$year-$month` — 月网格数据生成 + +### 分析折叠器卡顿的方法 + +1. **录制 trace**:Android Studio → Profiler → CPU → 选择 "Trace Java Methods" 或命令行: + ```bash + adb shell perfetto -c - --txt \<= len(pkt): break + tag = pkt[po]; po += 1 + fn = tag >> 3; wt = tag & 0x07 + if wt == 0: + _, po = read_varint(pkt, po) + elif wt == 2: + length, po = read_varint(pkt, po) + chunk = pkt[po:po + length] + if fn == 2: + # 扫描 bundle 内的 FtraceEvent (field 1, 0x0a) + eo = 0 + while eo < len(chunk): + if chunk[eo] != 0x0a: + eo += 1; continue + eo += 1 + try: + el, eno = read_varint(chunk, eo) + if el > 0 and eno + el <= len(chunk): + evt = chunk[eno:eno + el] + # 提取 timestamp (field 1, varint) + if len(evt) > 1 and evt[0] == 0x08: + ts, _ = read_varint(evt, 1) + # 搜索 marker 字符串 + for pat in [b'BC:', b'VM:', b'MonthView:']: + idx = evt.find(pat) + if idx >= 0: + me = idx + while me < len(evt) and 32 <= evt[me] < 127: + me += 1 + name = evt[idx:me].decode() + events.append((ts, name)) + break + eo = eno + el + else: + eo = eno + except: + eo += 1 + po += length + elif wt in (1, 5): + po += 8 if wt == 1 else 4 + else: + break + + events.sort() + return events + + # 使用 + events = parse_trace('cpu-perfetto-xxxx.trace') + for ts, name in events: + print(f"{ts}: {name}") + ``` + +3. **关注点**: + - **触摸事件间隔**:统计相邻 `BC:delta` marker 的时间差。理想间隔 ≤16ms;若出现 >33ms 说明丢帧,>100ms 说明触摸断流。 + - **重组耗时**:`VM:collapseProgress` → `MonthView:Compose` 的间隔,应在亚毫秒级。 + - **ViewModel → Compose 延迟**:从 `snapTo` 调用到下一帧重组完成的间隔。 + +### 已知排查结论(2026-05-19) + +对折叠器 trace 的分析显示: +- **重组本身很快**(VM progress → Compose 约 500μs),不是卡顿来源。 +- **触摸事件采样间隔不均匀**是主要问题。某些拖拽序列中出现 30-50ms 的触摸事件间隔,偶尔有 >100ms 的断流。这属于系统/模拟器层的事件分发问题,而非 Compose 代码问题。 +- 若在真机上复现,建议检查是否有 CPU 抢占或手指短暂离屏。 diff --git a/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt b/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt index eeb795a..a108c2e 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt @@ -19,6 +19,8 @@ import kotlinx.datetime.minus import kotlinx.datetime.number import kotlinx.datetime.plus import kotlinx.datetime.todayIn +import plus.rua.project.composeTraceBeginSection +import plus.rua.project.composeTraceEndSection import plus.rua.project.ui.COLLAPSE_THRESHOLD import plus.rua.project.ui.FLING_VELOCITY_THRESHOLD_DP import kotlin.time.Clock @@ -186,8 +188,9 @@ class CalendarViewModel( * @param delta 拖拽增量,已归一化到 [0,1] 区间 */ fun onDrag(delta: Float) { + val old = _collapseAnimatable.value coroutineScope.launch { - val new = (_collapseAnimatable.value + delta).coerceIn(0f, 1f) + val new = (old + delta).coerceIn(0f, 1f) _collapseAnimatable.snapTo(new) } } @@ -203,9 +206,9 @@ class CalendarViewModel( coroutineScope.launch { val progress = _collapseAnimatable.value val shouldCollapse = when { - velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> true // 快速上滑→折叠 - velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> false // 快速下滑→展开 - else -> progress > COLLAPSE_THRESHOLD // 慢速按 progress 判断 + velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> true + velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> false + else -> progress > COLLAPSE_THRESHOLD } if (shouldCollapse) { _collapseAnimatable.animateTo( @@ -228,8 +231,9 @@ class CalendarViewModel( * @param delta 拖拽增量,已归一化到 [0,1] 区间 */ fun onExpandDrag(delta: Float) { + val old = _collapseAnimatable.value coroutineScope.launch { - val new = (_collapseAnimatable.value + delta).coerceIn(0f, 1f) + val new = (old + delta).coerceIn(0f, 1f) _collapseAnimatable.snapTo(new) } } @@ -245,9 +249,9 @@ class CalendarViewModel( coroutineScope.launch { val progress = _collapseAnimatable.value val shouldExpand = when { - velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> true // 快速下滑→展开 - velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> false // 快速上滑→保持折叠 - else -> progress < 1f - COLLAPSE_THRESHOLD // 慢速按 progress 判断 + velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> true + velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> false + else -> progress < 1f - COLLAPSE_THRESHOLD } if (shouldExpand) { _collapseAnimatable.animateTo( From ba742f1597ab2517f9af84016ac4d29d0a7ecc82 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 19 May 2026 07:42:29 +0800 Subject: [PATCH 02/12] =?UTF-8?q?build:=20=E5=90=AF=E7=94=A8=20R8=20?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9=E4=B8=8E=E8=B5=84=E6=BA=90=E4=BC=98=E5=8C=96?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=20ProGuard=20=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - androidApp: 启用 isMinifyEnabled 和 isShrinkResources - androidApp: ABI 过滤(arm64-v8a, armeabi-v7a),关闭 buildConfig - gradle.properties: 启用并行构建、守护进程、R8 fullMode - 新建 proguard-rules.pro 保留 KMP/Compose/kotlinx.datetime 规则 Co-Authored-By: Claude Opus 4.7 (1M context) --- androidApp/build.gradle.kts | 21 +++++++++++++++++++++ androidApp/proguard-rules.pro | 20 ++++++++++++++++++++ gradle.properties | 5 ++++- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 androidApp/proguard-rules.pro diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 5f63ce9..9635700 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -14,7 +14,28 @@ android { targetSdk = libs.versions.android.targetSdk.get().toInt() versionCode = 1 versionName = "1.0" + + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a") + } } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfig = signingConfigs.getByName("debug") + } + } + + buildFeatures { + buildConfig = false + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/androidApp/proguard-rules.pro b/androidApp/proguard-rules.pro new file mode 100644 index 0000000..540142c --- /dev/null +++ b/androidApp/proguard-rules.pro @@ -0,0 +1,20 @@ +# Kotlin Metadata (required for KMP) +-keep class kotlin.Metadata { *; } +-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod + +# kotlinx.datetime +-keep class kotlinx.datetime.** { *; } + +# tyme4kt (Chinese traditional calendar) +-keep class cn.tyme.** { *; } + +# Compose runtime reflective lookups +-keep class androidx.compose.runtime.** { *; } + +# ViewModel (used by CalendarViewModel) +-keep class * extends androidx.lifecycle.ViewModel { *; } + +# Keep entry point composables referenced by string name +-keepclassmembers class * { + @androidx.compose.runtime.Composable ; +} diff --git a/gradle.properties b/gradle.properties index 9c2f0cf..4177846 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,11 +6,14 @@ kotlin.daemon.jvmargs=-Xmx3072M org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 org.gradle.configuration-cache=true org.gradle.caching=true +org.gradle.parallel=true +org.gradle.daemon=true #Android android.nonTransitiveRClass=true android.useAndroidX=true android.uniquePackageNames=false android.dependency.useConstraints=true -android.r8.strictFullModeForKeepRules=false +android.r8.strictFullModeForKeepRules=true android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false +android.enableR8.fullMode=true From 57f61987df77fb0249956acaeef686e3bd16e4aa Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 19 May 2026 10:48:44 +0800 Subject: [PATCH 03/12] =?UTF-8?q?refactor:=20=E6=8A=98=E5=8F=A0=E6=80=81?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=B9=B4=E8=A7=86=E5=9B=BE=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E5=BD=A2=E6=80=81=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=A4=9A=E4=BD=99=E7=9A=84=E5=B1=95=E5=BC=80=E8=BF=87=E6=B8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先折叠态切年视图会先展开回月视图再切换,多了一段冗余动画; 现在折叠态直接以折叠形态参与缩放转场,从年视图返回时仍保留周视图状态。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../kotlin/plus/rua/project/CalendarViewModel.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt b/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt index a108c2e..c66db75 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt @@ -108,7 +108,8 @@ class CalendarViewModel( } /** - * 切换年视图。仅在展开态可用。 + * 切换年视图。折叠态下保持折叠(`isCollapsed` 不变), + * 月视图层以折叠形态参与缩放转场;从年视图返回时仍是周视图。 * * 切换瞬间立即翻转 isYearView,让对应方向的目标视图立刻接管渲染, * 当前视图被直接移除;动画只作用在目标视图的 scale/alpha 上。 @@ -116,13 +117,6 @@ class CalendarViewModel( fun toggleYearView() { yearViewJob?.cancel() yearViewJob = coroutineScope.launch { - // 折叠态先展开回月视图,再切换年视图 - if (isCollapsed) { - _collapseAnimatable.animateTo( - 0f, spring(dampingRatio = 0.8f, stiffness = 400f) - ) - isCollapsed = false - } if (isYearView) { // 年 → 月:先启动动画(年视图开始淡出),等一帧后翻转 isYearView(月视图开始组合) composeTraceBeginSection("YearView→MonthView") From 39bb2301d31fac9d0d0c6adb7e6180cf8adbfb09 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 19 May 2026 10:56:57 +0800 Subject: [PATCH 04/12] =?UTF-8?q?refactor:=20=E9=99=8D=E4=BD=8E=E6=8A=98?= =?UTF-8?q?=E5=8F=A0=E8=A7=A6=E5=8F=91=E9=98=88=E5=80=BC=E4=BB=8E=2025%=20?= =?UTF-8?q?=E5=88=B0=208%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COLLAPSE_THRESHOLD 从 0.25f 下调至 0.08f, 使月视图→周视图的折叠更易触发,下拉展开同理。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/commonMain/kotlin/plus/rua/project/ui/CalendarUtils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarUtils.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarUtils.kt index 7afce9d..9b812cb 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarUtils.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarUtils.kt @@ -10,7 +10,7 @@ import kotlinx.datetime.plus const val START_PAGE = Int.MAX_VALUE / 2 /** 折叠判定阈值:折叠时 progress > 此值触发,展开时 progress < (1-此值) 触发 */ -const val COLLAPSE_THRESHOLD = 0.25f +const val COLLAPSE_THRESHOLD = 0.08f /** 滑动偏移插值阈值:abs(offsetFraction) > 此值时启用插值 */ const val OFFSET_FRACTION_THRESHOLD = 0.01f From 0d58be45bc2a0a46fe1244278bd4e0fdaa512da9 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 19 May 2026 11:11:53 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=E5=BA=95=E9=83=A8=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E5=B1=95=E7=A4=BA=E9=80=89=E4=B8=AD=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E7=9A=84=E7=9B=B8=E5=AF=B9=E5=A4=A9=E6=95=B0=E3=80=81=E5=85=AC?= =?UTF-8?q?=E5=8E=86=E4=B8=8E=E5=86=9C=E5=8E=86=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../kotlin/plus/rua/project/ui/BottomCard.kt | 61 ++++++++++++++++++- .../plus/rua/project/ui/CalendarMonthView.kt | 2 + .../plus/rua/project/ui/CalendarUtils.kt | 39 ++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/BottomCard.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/BottomCard.kt index d4b9ccf..bff2e5d 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/BottomCard.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/BottomCard.kt @@ -2,27 +2,42 @@ package plus.rua.project.ui import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.datetime.LocalDate import plus.rua.project.CalendarViewModel /** * 底部卡片,折叠状态下支持垂直拖拽触发折叠动画。 * + * 卡片顶部显示拖拽把手,下方展示选中日期信息: + * 左侧为相对今天的天数描述(A)和公历日期(B), + * 右侧为农历日期(C)。 + * * @param viewModel 日历 ViewModel,用于读取折叠状态和驱动拖拽 + * @param selectedDate 当前选中的日期 + * @param today 今天的日期 * @param dragRangePx 拖拽手势映射范围(像素),progress 从 0→1 对应手指移动此距离。 * 应设为折叠时日历实际高度变化量 (weeks-1)×rowHeight,使拖拽跟手。 * @param modifier 外部布局修饰符 @@ -30,10 +45,16 @@ import plus.rua.project.CalendarViewModel @Composable fun BottomCard( viewModel: CalendarViewModel, + selectedDate: LocalDate, + today: LocalDate, dragRangePx: Float, modifier: Modifier = Modifier ) { val density = LocalDensity.current + val relativeDesc = relativeDayDescription(selectedDate, today) + @Suppress("DEPRECATION") // monthNumber 无替代 API,kotlinx-datetime 尚未提供新接口 + val solarDesc = "${selectedDate.monthNumber}月${selectedDate.day}日" + val lunarDesc = formatLunarDate(selectedDate) Surface( modifier = modifier @@ -80,16 +101,52 @@ fun BottomCard( color = MaterialTheme.colorScheme.surfaceVariant, shadowElevation = 4.dp ) { - Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + // 拖拽把手 Box( modifier = Modifier - .align(Alignment.TopCenter) .padding(top = 8.dp, bottom = 8.dp) .clip(RoundedCornerShape(2.dp)) .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) .fillMaxWidth(0.15f) .height(4.dp) + .align(Alignment.CenterHorizontally) ) + Spacer(modifier = Modifier.height(8.dp)) + // A / B / C 信息行 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 左侧:A(相对天数)和 B(公历日期)在同一行 + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = relativeDesc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = solarDesc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp + ) + } + // 右侧:C(农历日期) + Text( + text = lunarDesc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp + ) + } } } } diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt index 11d6366..91c2a9c 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt @@ -311,6 +311,8 @@ fun CalendarMonthView( if (cardHeightPx > 0) { BottomCard( viewModel = viewModel, + selectedDate = viewModel.selectedDate, + today = today, dragRangePx = dragRangePx, modifier = Modifier .fillMaxWidth() diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarUtils.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarUtils.kt index 9b812cb..3482094 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarUtils.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarUtils.kt @@ -1,7 +1,9 @@ package plus.rua.project.ui +import com.tyme.solar.SolarDay import kotlinx.datetime.DatePeriod import kotlinx.datetime.LocalDate +import kotlinx.datetime.daysUntil import kotlinx.datetime.minus import kotlinx.datetime.number import kotlinx.datetime.plus @@ -130,3 +132,40 @@ fun pageToWeekMonday(page: Int, initial: LocalDate): LocalDate { val offset = page - START_PAGE return initial.plus(DatePeriod(days = offset * 7)) } + +/** + * 计算选中日期相对于今天的天数描述。 + * + * 例如今天 19 日,选中 18 日返回"昨天",17 日返回"2天前", + * 20 日返回"明天",21 日返回"2天后",选中当天返回"今天"。 + * + * @param selectedDate 选中日期 + * @param today 今天日期 + * @return 相对天数描述 + */ +fun relativeDayDescription(selectedDate: LocalDate, today: LocalDate): String { + val diff = today.daysUntil(selectedDate) + return when { + diff == 0 -> "今天" + diff == -1 -> "昨天" + diff == 1 -> "明天" + diff < 0 -> "${-diff}天前" + else -> "${diff}天后" + } +} + +/** + * 将公历日期格式化为农历日期字符串。 + * + * 格式为"农历{月}{日}",例如"农历四月初三"。 + * + * @param date 公历日期 + * @return 农历日期描述 + */ +@Suppress("DEPRECATION") // monthNumber 无替代 API,kotlinx-datetime 尚未提供新接口 +fun formatLunarDate(date: LocalDate): String { + val solarDay = SolarDay.fromYmd(date.year, date.monthNumber, date.day) + val lunarDay = solarDay.getLunarDay() + val lunarMonth = lunarDay.getLunarMonth() + return "农历${lunarMonth.getName()}${lunarDay.getName()}" +} From d3befaabec121ee1e7fccca42be915288cb744e9 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 19 May 2026 11:47:08 +0800 Subject: [PATCH 06/12] =?UTF-8?q?test:=20=E6=96=B0=E5=A2=9E=20ShiftPattern?= =?UTF-8?q?=E3=80=81CalendarUtils=E3=80=81CalendarViewModel=20=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShiftPatternTest: 锚点前后、跨周期循环、负天数取模、空 cycle、 单元素 cycle、多样化周期、data class 属性 - CalendarUtilsExtraTest: calculateWeeksCountForPage(跨月/跨年)、 relativeDayDescription(今天/昨天/明天/N天前后/跨年月)、 formatLunarDate(农历前缀/正月初一/多日期验证) - CalendarViewModelStateTest: 初始状态、selectDate(含 currentMonth/Year 联动)、 increment/decrementYear、selectMonthFromYearView、shiftKindAt、 showLegalHoliday、onDrag/onExpandDrag progress 更新与 clamp、 getMonthDays 与 selectedDate/today 交互 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rua/project/CalendarViewModelStateTest.kt | 365 ++++++++++++++++++ .../plus/rua/project/ShiftPatternTest.kt | 179 +++++++++ .../rua/project/ui/CalendarUtilsExtraTest.kt | 155 ++++++++ 3 files changed, 699 insertions(+) create mode 100644 shared/src/commonTest/kotlin/plus/rua/project/CalendarViewModelStateTest.kt create mode 100644 shared/src/commonTest/kotlin/plus/rua/project/ShiftPatternTest.kt create mode 100644 shared/src/commonTest/kotlin/plus/rua/project/ui/CalendarUtilsExtraTest.kt diff --git a/shared/src/commonTest/kotlin/plus/rua/project/CalendarViewModelStateTest.kt b/shared/src/commonTest/kotlin/plus/rua/project/CalendarViewModelStateTest.kt new file mode 100644 index 0000000..540a028 --- /dev/null +++ b/shared/src/commonTest/kotlin/plus/rua/project/CalendarViewModelStateTest.kt @@ -0,0 +1,365 @@ +package plus.rua.project + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private class StateTestFixedClock(private val instant: Instant) : Clock { + override fun now(): Instant = instant +} + +/** + * 覆盖 [CalendarViewModel] 中与日期选择、年视图、班次、拖拽 progress 等 + * 同步可观察状态相关的逻辑。 + * + * 动画完成的最终状态(例如 [CalendarViewModel.isCollapsed] 在 spring + * 动画结束后的取值)需要 MonotonicFrameClock 驱动,不在本测试集合范围内。 + */ +class CalendarViewModelStateTest { + + // 固定 today = 2026/5/15 + private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z") + private val testClock = StateTestFixedClock(fixedInstant) + + private fun createViewModel(): CalendarViewModel { + val scope = CoroutineScope(Dispatchers.Unconfined) + return CalendarViewModel(coroutineScope = scope, clock = testClock) + } + + // ---- 初始状态 ---- + + @Test + fun init_selectedDateIsToday() { + val vm = createViewModel() + assertEquals(LocalDate(2026, 5, 15), vm.selectedDate) + } + + @Test + fun init_isCollapsedDefaultsFalse() { + assertFalse(createViewModel().isCollapsed) + } + + @Test + fun init_collapseProgressDefaultsZero() { + assertEquals(0f, createViewModel().collapseProgress, 0.001f) + } + + @Test + fun init_isYearViewDefaultsFalse() { + assertFalse(createViewModel().isYearView) + } + + @Test + fun init_yearViewProgressDefaultsZero() { + assertEquals(0f, createViewModel().yearViewProgress, 0.001f) + } + + @Test + fun init_yearViewYearDefaultsToTodayYear() { + assertEquals(2026, createViewModel().yearViewYear) + } + + @Test + fun init_showLegalHolidayDefaultsFalse() { + assertFalse(createViewModel().showLegalHoliday) + } + + @Test + fun init_shiftPatternHasDefault() { + val pattern = createViewModel().shiftPattern + assertNotNull(pattern) + assertEquals(LocalDate(2026, 5, 15), pattern.anchorDate) + assertEquals(4, pattern.cycle.size) + } + + @Test + fun init_currentMonthMatchesToday() { + assertEquals(5, createViewModel().currentMonth) + } + + @Test + fun init_currentYearMatchesToday() { + assertEquals(2026, createViewModel().currentYear) + } + + // ---- selectDate ---- + + @Test + fun selectDate_updatesSelectedDate() { + val vm = createViewModel() + vm.selectDate(LocalDate(2026, 6, 1)) + assertEquals(LocalDate(2026, 6, 1), vm.selectedDate) + } + + @Test + fun selectDate_currentMonthFollowsSelection() { + val vm = createViewModel() + vm.selectDate(LocalDate(2026, 8, 20)) + assertEquals(8, vm.currentMonth) + assertEquals(2026, vm.currentYear) + } + + @Test + fun selectDate_yearFollowsSelection() { + val vm = createViewModel() + vm.selectDate(LocalDate(2027, 1, 1)) + assertEquals(2027, vm.currentYear) + assertEquals(1, vm.currentMonth) + } + + @Test + fun selectDate_pastDate_updatesCorrectly() { + val vm = createViewModel() + vm.selectDate(LocalDate(2020, 12, 31)) + assertEquals(LocalDate(2020, 12, 31), vm.selectedDate) + assertEquals(12, vm.currentMonth) + assertEquals(2020, vm.currentYear) + } + + // ---- incrementYear / decrementYear ---- + + @Test + fun incrementYear_increasesYearViewYear() { + val vm = createViewModel() + vm.incrementYear() + assertEquals(2027, vm.yearViewYear) + } + + @Test + fun decrementYear_decreasesYearViewYear() { + val vm = createViewModel() + vm.decrementYear() + assertEquals(2025, vm.yearViewYear) + } + + @Test + fun incrementDecrementYear_consecutiveCalls() { + val vm = createViewModel() + repeat(5) { vm.incrementYear() } + assertEquals(2031, vm.yearViewYear) + repeat(3) { vm.decrementYear() } + assertEquals(2028, vm.yearViewYear) + } + + @Test + fun incrementYear_doesNotAffectSelectedDate() { + val vm = createViewModel() + val before = vm.selectedDate + vm.incrementYear() + assertEquals(before, vm.selectedDate) + } + + // ---- selectMonthFromYearView ---- + + @Test + fun selectMonthFromYearView_sameYearOtherMonth_setsFirstDayOfMonth() { + val vm = createViewModel() + vm.selectMonthFromYearView(8) + assertEquals(LocalDate(2026, 8, 1), vm.selectedDate) + } + + @Test + fun selectMonthFromYearView_currentYearAndMonth_setsToToday() { + val vm = createViewModel() + // yearViewYear = 2026, today.month = 5 + vm.selectMonthFromYearView(5) + assertEquals(LocalDate(2026, 5, 15), vm.selectedDate) + } + + @Test + fun selectMonthFromYearView_otherYear_setsFirstDay() { + val vm = createViewModel() + vm.incrementYear() // yearViewYear = 2027 + vm.selectMonthFromYearView(5) + assertEquals(LocalDate(2027, 5, 1), vm.selectedDate) + } + + @Test + fun selectMonthFromYearView_setsIsYearViewFalse() { + val vm = createViewModel() + vm.selectMonthFromYearView(3) + assertFalse(vm.isYearView) + } + + @Test + fun selectMonthFromYearView_january() { + val vm = createViewModel() + vm.selectMonthFromYearView(1) + assertEquals(LocalDate(2026, 1, 1), vm.selectedDate) + } + + @Test + fun selectMonthFromYearView_december() { + val vm = createViewModel() + vm.selectMonthFromYearView(12) + assertEquals(LocalDate(2026, 12, 1), vm.selectedDate) + } + + // ---- shiftKindAt ---- + + @Test + fun shiftKindAt_anchorDate_returnsWork() { + // default pattern: anchor 2026-05-15, cycle WORK/WORK/OFF/OFF + val vm = createViewModel() + assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15))) + } + + @Test + fun shiftKindAt_dayAfterAnchor_returnsWork() { + val vm = createViewModel() + assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 16))) + } + + @Test + fun shiftKindAt_twoDaysAfterAnchor_returnsOff() { + val vm = createViewModel() + assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 5, 17))) + } + + @Test + fun shiftKindAt_nullPattern_returnsNull() { + val vm = createViewModel() + vm.shiftPattern = null + assertNull(vm.shiftKindAt(LocalDate(2026, 5, 15))) + } + + @Test + fun shiftKindAt_customPattern_usesNewPattern() { + val vm = createViewModel() + vm.shiftPattern = ShiftPattern( + anchorDate = LocalDate(2026, 5, 15), + cycle = listOf(ShiftKind.OFF, ShiftKind.WORK) + ) + assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 5, 15))) + assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 16))) + assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 5, 17))) + } + + // ---- showLegalHoliday ---- + + @Test + fun showLegalHoliday_canBeToggled() { + val vm = createViewModel() + assertFalse(vm.showLegalHoliday) + vm.showLegalHoliday = true + assertTrue(vm.showLegalHoliday) + vm.showLegalHoliday = false + assertFalse(vm.showLegalHoliday) + } + + // ---- onDrag: 折叠拖拽(同步路径:snapTo)---- + + @Test + fun onDrag_positiveDelta_increasesProgress() { + val vm = createViewModel() + vm.onDrag(0.3f) + assertEquals(0.3f, vm.collapseProgress, 0.001f) + } + + @Test + fun onDrag_accumulatesAcrossCalls() { + val vm = createViewModel() + vm.onDrag(0.2f) + vm.onDrag(0.3f) + assertEquals(0.5f, vm.collapseProgress, 0.001f) + } + + @Test + fun onDrag_clampsAtOne() { + val vm = createViewModel() + vm.onDrag(0.8f) + vm.onDrag(0.8f) + assertEquals(1f, vm.collapseProgress, 0.001f) + } + + @Test + fun onDrag_clampsAtZeroWhenNegativeFromZero() { + val vm = createViewModel() + vm.onDrag(-0.3f) + assertEquals(0f, vm.collapseProgress, 0.001f) + } + + @Test + fun onDrag_negativeAfterPositive_canDecrease() { + val vm = createViewModel() + vm.onDrag(0.5f) + vm.onDrag(-0.2f) + assertEquals(0.3f, vm.collapseProgress, 0.001f) + } + + // ---- onExpandDrag: 展开拖拽 ---- + + @Test + fun onExpandDrag_updatesProgress() { + val vm = createViewModel() + // 先把 progress 推到 1 + vm.onDrag(1f) + assertEquals(1f, vm.collapseProgress, 0.001f) + // 展开方向:delta 为负 + vm.onExpandDrag(-0.4f) + assertEquals(0.6f, vm.collapseProgress, 0.001f) + } + + @Test + fun onExpandDrag_clampsAtZero() { + val vm = createViewModel() + vm.onDrag(0.5f) + vm.onExpandDrag(-1f) + assertEquals(0f, vm.collapseProgress, 0.001f) + } + + @Test + fun onExpandDrag_clampsAtOne() { + val vm = createViewModel() + vm.onExpandDrag(2f) + assertEquals(1f, vm.collapseProgress, 0.001f) + } + + // ---- getMonthDays 与 selectedDate 配合 ---- + + @Test + fun getMonthDays_updatesIsSelectedAfterSelectDate() { + val vm = createViewModel() + vm.selectDate(LocalDate(2026, 5, 20)) + val days = vm.getMonthDays(2026, 5) + val selectedCell = days.first { it.isSelected } + assertEquals(20, selectedCell.date.day) + } + + @Test + fun getMonthDays_noCellSelectedInOtherMonth() { + val vm = createViewModel() + // selectedDate 默认是今天(5/15),不在 2026/8 月内(含跨月填充也不可能) + val days = vm.getMonthDays(2026, 8) + assertTrue(days.none { it.isSelected }) + } + + @Test + fun getMonthDays_todayCellAlwaysReflectsTodayClock() { + val vm = createViewModel() + // 即便选中其他日期,isToday 依然根据 clock 注入的 today + vm.selectDate(LocalDate(2026, 5, 20)) + val days = vm.getMonthDays(2026, 5) + val todayCell = days.first { it.isToday } + assertEquals(15, todayCell.date.day) + } + + @Test + fun getMonthDays_returnsMultipleOfSeven() { + val vm = createViewModel() + // 任何月份,cells 数都应该是 7 的倍数 + for (month in 1..12) { + val size = vm.getMonthDays(2026, month).size + assertEquals(0, size % 7, "Month 2026/$month size=$size not multiple of 7") + assertTrue(size in 28..42, "Month 2026/$month size=$size out of [28, 42]") + } + } +} diff --git a/shared/src/commonTest/kotlin/plus/rua/project/ShiftPatternTest.kt b/shared/src/commonTest/kotlin/plus/rua/project/ShiftPatternTest.kt new file mode 100644 index 0000000..1958796 --- /dev/null +++ b/shared/src/commonTest/kotlin/plus/rua/project/ShiftPatternTest.kt @@ -0,0 +1,179 @@ +package plus.rua.project + +import kotlinx.datetime.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ShiftPatternTest { + + private val anchor = LocalDate(2026, 5, 15) + private val twoOnTwoOff = ShiftPattern( + anchorDate = anchor, + cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF) + ) + + // ---- kindAt: 锚点与同周期内 ---- + + @Test + fun kindAt_anchorDate_returnsFirstInCycle() { + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(anchor)) + } + + @Test + fun kindAt_oneAfterAnchor_returnsSecondInCycle() { + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 5, 16))) + } + + @Test + fun kindAt_twoAfterAnchor_returnsThirdInCycle() { + assertEquals(ShiftKind.OFF, twoOnTwoOff.kindAt(LocalDate(2026, 5, 17))) + } + + @Test + fun kindAt_threeAfterAnchor_returnsFourthInCycle() { + assertEquals(ShiftKind.OFF, twoOnTwoOff.kindAt(LocalDate(2026, 5, 18))) + } + + // ---- kindAt: 周期循环 ---- + + @Test + fun kindAt_fourAfterAnchor_wrapsToCycleStart() { + // (5/19 - 5/15) % 4 = 0 + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 5, 19))) + } + + @Test + fun kindAt_eightAfterAnchor_wrapsTwice() { + // (5/23 - 5/15) % 4 = 0 + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 5, 23))) + } + + @Test + fun kindAt_oneCycleLater_idx2_returnsOff() { + // (5/21 - 5/15) % 4 = 2 + assertEquals(ShiftKind.OFF, twoOnTwoOff.kindAt(LocalDate(2026, 5, 21))) + } + + @Test + fun kindAt_manyCyclesLater_correctlyWraps() { + // 100天后: (100) % 4 = 0 + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 8, 23))) + } + + // ---- kindAt: 锚点之前的日期(负差值处理)---- + + @Test + fun kindAt_oneDayBeforeAnchor_returnsLastInCycle() { + // -1 mod 4 = 3 -> OFF (cycle[3]) + assertEquals(ShiftKind.OFF, twoOnTwoOff.kindAt(LocalDate(2026, 5, 14))) + } + + @Test + fun kindAt_twoDaysBeforeAnchor_returnsThirdInCycle() { + // -2 mod 4 = 2 -> OFF (cycle[2]) + assertEquals(ShiftKind.OFF, twoOnTwoOff.kindAt(LocalDate(2026, 5, 13))) + } + + @Test + fun kindAt_threeDaysBeforeAnchor_returnsSecondInCycle() { + // -3 mod 4 = 1 -> WORK (cycle[1]) + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 5, 12))) + } + + @Test + fun kindAt_fourDaysBeforeAnchor_returnsFirstInCycle() { + // -4 mod 4 = 0 -> WORK (cycle[0]) + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 5, 11))) + } + + @Test + fun kindAt_manyDaysBeforeAnchor_correctlyWraps() { + // -100 mod 4 = 0 -> WORK + assertEquals(ShiftKind.WORK, twoOnTwoOff.kindAt(LocalDate(2026, 2, 4))) + } + + // ---- kindAt: 边界情况 ---- + + @Test + fun kindAt_emptyCycle_returnsNull() { + val pattern = ShiftPattern(anchorDate = anchor, cycle = emptyList()) + assertNull(pattern.kindAt(anchor)) + assertNull(pattern.kindAt(LocalDate(2026, 5, 16))) + assertNull(pattern.kindAt(LocalDate(2026, 5, 14))) + } + + @Test + fun kindAt_singleElementCycle_alwaysReturnsThatElement() { + val pattern = ShiftPattern(anchorDate = anchor, cycle = listOf(ShiftKind.WORK)) + assertEquals(ShiftKind.WORK, pattern.kindAt(anchor)) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 20))) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 1, 1))) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2027, 12, 31))) + } + + @Test + fun kindAt_singleOffCycle_alwaysReturnsOff() { + val pattern = ShiftPattern(anchorDate = anchor, cycle = listOf(ShiftKind.OFF)) + assertEquals(ShiftKind.OFF, pattern.kindAt(anchor)) + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2030, 6, 15))) + } + + // ---- kindAt: 多样化周期 ---- + + @Test + fun kindAt_threeOnOneOffCycle() { + // 4 day cycle: WORK WORK WORK OFF + val pattern = ShiftPattern( + anchorDate = anchor, + cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF) + ) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 15))) // 0 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 16))) // 1 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 17))) // 2 + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 18))) // 3 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 19))) // 0 + } + + @Test + fun kindAt_weekCycle_returnsCorrectDay() { + // 7天周期:4天上班3天休息 + val pattern = ShiftPattern( + anchorDate = anchor, + cycle = listOf( + ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, + ShiftKind.OFF, ShiftKind.OFF, ShiftKind.OFF + ) + ) + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 18))) // idx 3 + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 19))) // idx 4 + assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 21))) // idx 6 + assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 22))) // idx 0 (next cycle) + } + + // ---- ShiftPattern: 元数据 ---- + + @Test + fun shiftPattern_defaultNameIsChinese() { + val pattern = ShiftPattern(anchorDate = anchor, cycle = listOf(ShiftKind.WORK)) + assertEquals("默认", pattern.name) + } + + @Test + fun shiftPattern_customNameIsPreserved() { + val pattern = ShiftPattern( + anchorDate = anchor, + cycle = listOf(ShiftKind.WORK), + name = "夜班" + ) + assertEquals("夜班", pattern.name) + } + + @Test + fun shiftPattern_dataClassEquality() { + val a = ShiftPattern(anchor, listOf(ShiftKind.WORK, ShiftKind.OFF)) + val b = ShiftPattern(anchor, listOf(ShiftKind.WORK, ShiftKind.OFF)) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } +} diff --git a/shared/src/commonTest/kotlin/plus/rua/project/ui/CalendarUtilsExtraTest.kt b/shared/src/commonTest/kotlin/plus/rua/project/ui/CalendarUtilsExtraTest.kt new file mode 100644 index 0000000..15cffaa --- /dev/null +++ b/shared/src/commonTest/kotlin/plus/rua/project/ui/CalendarUtilsExtraTest.kt @@ -0,0 +1,155 @@ +package plus.rua.project.ui + +import kotlinx.datetime.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * 测试 CalendarUtils 中尚未被 [CalendarUtilsTest] 覆盖的函数: + * - [calculateWeeksCountForPage] + * - [relativeDayDescription] + * - [formatLunarDate] + */ +class CalendarUtilsExtraTest { + + // ---- calculateWeeksCountForPage ---- + + @Test + fun calculateWeeksCountForPage_centerPage_returnsCurrentMonthRows() { + // today = 2026/5/15 (May), May 2026 has 5 rows + val today = LocalDate(2026, 5, 15) + assertEquals(calculateWeeksCount(2026, 5), calculateWeeksCountForPage(START_PAGE, today)) + } + + @Test + fun calculateWeeksCountForPage_forwardOnePage_returnsNextMonthRows() { + // From May 2026, +1 -> June 2026 + val today = LocalDate(2026, 5, 15) + assertEquals(calculateWeeksCount(2026, 6), calculateWeeksCountForPage(START_PAGE + 1, today)) + } + + @Test + fun calculateWeeksCountForPage_backwardOnePage_returnsPreviousMonthRows() { + // From May 2026, -1 -> April 2026 + val today = LocalDate(2026, 5, 15) + assertEquals(calculateWeeksCount(2026, 4), calculateWeeksCountForPage(START_PAGE - 1, today)) + } + + @Test + fun calculateWeeksCountForPage_crossYearForward() { + // From December 2026, +1 -> January 2027 + val today = LocalDate(2026, 12, 10) + assertEquals(calculateWeeksCount(2027, 1), calculateWeeksCountForPage(START_PAGE + 1, today)) + } + + @Test + fun calculateWeeksCountForPage_crossYearBackward() { + // From January 2026, -1 -> December 2025 + val today = LocalDate(2026, 1, 10) + assertEquals(calculateWeeksCount(2025, 12), calculateWeeksCountForPage(START_PAGE - 1, today)) + } + + @Test + fun calculateWeeksCountForPage_twelvePagesForward_returnsSameMonthOfNextYear() { + val today = LocalDate(2026, 5, 15) + // +12 -> May 2027 + assertEquals(calculateWeeksCount(2027, 5), calculateWeeksCountForPage(START_PAGE + 12, today)) + } + + // ---- relativeDayDescription ---- + + @Test + fun relativeDayDescription_today_returnsToday() { + val today = LocalDate(2026, 5, 19) + assertEquals("今天", relativeDayDescription(today, today)) + } + + @Test + fun relativeDayDescription_yesterday_returnsYesterday() { + val today = LocalDate(2026, 5, 19) + val yesterday = LocalDate(2026, 5, 18) + assertEquals("昨天", relativeDayDescription(yesterday, today)) + } + + @Test + fun relativeDayDescription_tomorrow_returnsTomorrow() { + val today = LocalDate(2026, 5, 19) + val tomorrow = LocalDate(2026, 5, 20) + assertEquals("明天", relativeDayDescription(tomorrow, today)) + } + + @Test + fun relativeDayDescription_twoDaysBefore_returnsXDaysAgo() { + val today = LocalDate(2026, 5, 19) + assertEquals("2天前", relativeDayDescription(LocalDate(2026, 5, 17), today)) + } + + @Test + fun relativeDayDescription_twoDaysAfter_returnsXDaysLater() { + val today = LocalDate(2026, 5, 19) + assertEquals("2天后", relativeDayDescription(LocalDate(2026, 5, 21), today)) + } + + @Test + fun relativeDayDescription_aWeekBefore_returnsCorrectDays() { + val today = LocalDate(2026, 5, 19) + assertEquals("7天前", relativeDayDescription(LocalDate(2026, 5, 12), today)) + } + + @Test + fun relativeDayDescription_thirtyDaysAfter_returnsCorrectDays() { + val today = LocalDate(2026, 5, 1) + assertEquals("30天后", relativeDayDescription(LocalDate(2026, 5, 31), today)) + } + + @Test + fun relativeDayDescription_crossMonthBackward_returnsCorrectDays() { + val today = LocalDate(2026, 5, 2) + assertEquals("3天前", relativeDayDescription(LocalDate(2026, 4, 29), today)) + } + + @Test + fun relativeDayDescription_crossYearForward_returnsCorrectDays() { + val today = LocalDate(2025, 12, 30) + assertEquals("5天后", relativeDayDescription(LocalDate(2026, 1, 4), today)) + } + + // ---- formatLunarDate ---- + + @Test + fun formatLunarDate_startsWithLunarPrefix() { + val result = formatLunarDate(LocalDate(2026, 5, 19)) + assertTrue(result.startsWith("农历"), "Expected to start with '农历', got: $result") + } + + @Test + fun formatLunarDate_january1_2026_returnsCorrectLunar() { + // 2026/1/1 公历 -> 2025年农历十一月十二 + val result = formatLunarDate(LocalDate(2026, 1, 1)) + assertTrue(result.startsWith("农历"), "Expected '农历' prefix, got: $result") + // 验证不是空字符串 + assertTrue(result.length > 2, "Lunar date description should contain month and day") + } + + @Test + fun formatLunarDate_lunarNewYear2026_returnsFirstDayOfFirstMonth() { + // 2026年农历正月初一 = 2026/2/17 公历 + val result = formatLunarDate(LocalDate(2026, 2, 17)) + assertEquals("农历正月初一", result) + } + + @Test + fun formatLunarDate_anyDate_containsMonthAndDayNames() { + // 仅验证格式:农历 + 月 + 日 + for (day in listOf( + LocalDate(2026, 3, 1), + LocalDate(2026, 6, 30), + LocalDate(2026, 12, 25) + )) { + val result = formatLunarDate(day) + assertTrue(result.startsWith("农历"), "Expected '农历' prefix for $day, got: $result") + assertTrue(result.length >= 5, "Result for $day too short: $result") + } + } +} From 49487a00e8c51c5c9cb9681bc877cf45e6288997 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 19 May 2026 11:47:16 +0800 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20ComposeTrace=20host=20test=20?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=EF=BC=88Trace=20API=20=E6=9C=AA=20stub=20?= =?UTF-8?q?=E6=97=B6=E9=9D=99=E9=BB=98=E5=BF=BD=E7=95=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit androidHostTest 中 android.os.Trace 未提供 stub,调用会抛 RuntimeException。 包装在 try-catch 中,使 host test 能正常执行涉及 trace 的代码路径。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plus/rua/project/ComposeTrace.android.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/shared/src/androidMain/kotlin/plus/rua/project/ComposeTrace.android.kt b/shared/src/androidMain/kotlin/plus/rua/project/ComposeTrace.android.kt index af5faa7..a3f9403 100644 --- a/shared/src/androidMain/kotlin/plus/rua/project/ComposeTrace.android.kt +++ b/shared/src/androidMain/kotlin/plus/rua/project/ComposeTrace.android.kt @@ -2,6 +2,18 @@ package plus.rua.project import android.os.Trace -actual fun composeTraceBeginSection(name: String) = Trace.beginSection(name) +actual fun composeTraceBeginSection(name: String) { + try { + Trace.beginSection(name) + } catch (_: RuntimeException) { + // Trace API 在 host test 中未 stub;忽略 + } +} -actual fun composeTraceEndSection() = Trace.endSection() \ No newline at end of file +actual fun composeTraceEndSection() { + try { + Trace.endSection() + } catch (_: RuntimeException) { + // Trace API 在 host test 中未 stub;忽略 + } +} \ No newline at end of file From feb7db718ea85bd0c0787ea8a6f64e8d40402f4f Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 19 May 2026 13:36:14 +0800 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=20sketch4=20?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=20GIF=20=E5=8A=A8=E7=94=BB=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20AnimatedGif=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 sketch4 依赖(compose / animated-gif / compose-resources) - 升级 tyme4kt 1.4.4 → 1.4.5 - 新增 puppy_1.gif 资源与 AnimatedGif 通用组件 Co-Authored-By: Claude Opus 4.7 (1M context) --- gradle/libs.versions.toml | 6 ++++- shared/build.gradle.kts | 3 +++ .../composeResources/drawable/puppy_1.gif | Bin 0 -> 20364 bytes .../composeResources/files/puppy_1.gif | Bin 0 -> 20364 bytes .../kotlin/plus/rua/project/ui/AnimatedGif.kt | 25 ++++++++++++++++++ 5 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 shared/src/commonMain/composeResources/drawable/puppy_1.gif create mode 100644 shared/src/commonMain/composeResources/files/puppy_1.gif create mode 100644 shared/src/commonMain/kotlin/plus/rua/project/ui/AnimatedGif.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 559e70d..997fd04 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,8 @@ junit = "4.13.2" kotlin = "2.3.21" material3 = "1.10.0-alpha05" kotlinx-datetime = "0.8.0" -tyme4kt = "1.4.4" +tyme4kt = "1.4.5" +sketch = "4.4.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -30,6 +31,9 @@ compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-previ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.11.0" } tyme4kt = { module = "cn.6tail:tyme4kt", version.ref = "tyme4kt" } +sketch-compose = { module = "io.github.panpf.sketch4:sketch-compose", version.ref = "sketch" } +sketch-animated-gif = { module = "io.github.panpf.sketch4:sketch-animated-gif", version.ref = "sketch" } +sketch-compose-resources = { module = "io.github.panpf.sketch4:sketch-compose-resources", version.ref = "sketch" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 433742c..e087e53 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -46,6 +46,9 @@ kotlin { implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.kotlinx.datetime) implementation(libs.tyme4kt) + implementation(libs.sketch.compose) + implementation(libs.sketch.animated.gif) + implementation(libs.sketch.compose.resources) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/shared/src/commonMain/composeResources/drawable/puppy_1.gif b/shared/src/commonMain/composeResources/drawable/puppy_1.gif new file mode 100644 index 0000000000000000000000000000000000000000..6a044ba672d25da2dfd035c4010b1d57f0762915 GIT binary patch literal 20364 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�?;V<3q|@~V15GEok~W|N}*RDMU_R7y|= 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�?;V<3q|@~V15GEok~W|N}*RDMU_R7y|= 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 Date: Tue, 19 May 2026 13:41:51 +0800 Subject: [PATCH 09/12] =?UTF-8?q?build:=20=E4=BC=98=E5=8C=96=20APK=20?= =?UTF-8?q?=E6=89=93=E5=8C=85=E4=B8=8E=20AAB=20=E5=88=86=E5=8C=85=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排除冗余的 META-INF 许可证与 Kotlin 元数据资源,挑选首次出现的 INDEX.LIST,避免合并冲突;启用按语言、密度、ABI 拆分的 App Bundle。 Co-Authored-By: Claude Opus 4.7 (1M context) --- androidApp/build.gradle.kts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 9635700..25c2209 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -40,6 +40,29 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + + packaging { + resources { + excludes += listOf( + "/META-INF/{AL2.0,LGPL2.1}", + "/META-INF/LICENSE*", + "/META-INF/NOTICE*", + "META-INF/DEPENDENCIES", + "**/*.kotlin_metadata", + "**/*.kotlin_module", + ) + pickFirsts += listOf( + "META-INF/INDEX.LIST", + "META-INF/io.netty.versions.properties", + ) + } + } + + bundle { + language { enableSplit = true } + density { enableSplit = true } + abi { enableSplit = true } + } } dependencies { From e6898df400698ad24e7f107ac1d9f93a15b2cce6 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 19 May 2026 13:43:18 +0800 Subject: [PATCH 10/12] =?UTF-8?q?style:=20=E6=B5=AE=E5=8A=A8=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=94=B9=E4=B8=BA=E5=9C=86=E5=BD=A2=E5=BD=A2=E7=8A=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt index 91c2a9c..bf31745 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -394,6 +395,7 @@ fun CalendarMonthView( modifier = Modifier .align(Alignment.BottomStart) .padding(start = 24.dp, bottom = 32.dp), + shape = CircleShape, containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer ) { From fae6e3eb72d8e4cd62c010a9c9118ced775e4853 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 19 May 2026 13:52:41 +0800 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=B7=B1=E8=89=B2=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 values/values-night 主题资源 Theme.YaYa,AndroidManifest 切换为该主题; Compose 端根据 isSystemInDarkTheme 切换 light/dark ColorScheme。 Co-Authored-By: Claude Opus 4.7 (1M context) --- androidApp/src/main/AndroidManifest.xml | 2 +- androidApp/src/main/res/values-night/themes.xml | 4 ++++ androidApp/src/main/res/values/themes.xml | 4 ++++ shared/src/commonMain/kotlin/plus/rua/project/App.kt | 8 ++++++-- 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 androidApp/src/main/res/values-night/themes.xml create mode 100644 androidApp/src/main/res/values/themes.xml diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 26403a7..298cd22 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -7,7 +7,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@android:style/Theme.Material.Light.NoActionBar"> + android:theme="@style/Theme.YaYa"> diff --git a/androidApp/src/main/res/values-night/themes.xml b/androidApp/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..ab14fc2 --- /dev/null +++ b/androidApp/src/main/res/values-night/themes.xml @@ -0,0 +1,4 @@ + + +