Merge branch 'DefectingCat:main' into main

This commit is contained in:
promise 2026-05-19 14:25:52 +08:00 committed by GitHub
commit c262572eee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1103 additions and 33 deletions

1
.gitignore vendored
View File

@ -21,3 +21,4 @@ node_modules/
# OMC runtime state # OMC runtime state
.omc/ .omc/
logs/ logs/
.claude/

View File

@ -111,3 +111,142 @@ CalendarMonthView (顶层屏幕)
**折叠动画:** `CalendarViewModel.collapseProgress` 控制 0f(月)↔1f(周) 过渡。`BottomCard` 捕获垂直拖拽,释放时超过 50% 则弹簧动画吸附到最近状态。完全折叠后 `WeekPager` 替代 `CalendarPager` 实现高效单周分页。 **折叠动画:** `CalendarViewModel.collapseProgress` 控制 0f(月)↔1f(周) 过渡。`BottomCard` 捕获垂直拖拽,释放时超过 50% 则弹簧动画吸附到最近状态。完全折叠后 `WeekPager` 替代 `CalendarPager` 实现高效单周分页。
**分页映射:** 两个 Pager 均使用 `Int.MAX_VALUE` 页数,中心页为 `Int.MAX_VALUE / 2`。页码到日期为算术转换,无索引列表。两者均跳过初始 `snapshotFlow` 发射 (`.drop(1)`) 以保留首次渲染时的"今日"选中。 **分页映射:** 两个 Pager 均使用 `Int.MAX_VALUE` 页数,中心页为 `Int.MAX_VALUE / 2`。页码到日期为算术转换,无索引列表。两者均跳过初始 `snapshotFlow` 发射 (`.drop(1)`) 以保留首次渲染时的"今日"选中。
## 性能排查Perfetto / Systrace
项目使用 `composeTraceBeginSection` / `composeTraceEndSection` 在关键代码段插入 trace markerAndroid 上会被记录到系统 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 \<<EOF
buffers: { size_kb: 65536 }
data_sources: {
config {
name: "linux.ftrace"
ftrace_config {
ftrace_events: "ftrace/print"
ftrace_events: "sched/sched_switch"
buffer_size_kb: 8192
}
}
}
data_sources: {
config {
name: "android.packages_list"
}
}
duration_ms: 10000
EOF
```
2. **用 Python 分析 trace**(无需 Perfetto UI
```python
def read_varint(data, offset):
result = 0; shift = 0
while offset < len(data):
byte = data[offset]
result |= (byte & 0x7F) << shift
offset += 1
if not (byte & 0x80): break
shift += 7
return result, offset
def parse_trace(path):
with open(path, 'rb') as f:
data = f.read()
# 1) 读取所有 TracePacket
packets = []
offset = 0
while offset < len(data):
if data[offset] != 0x0a:
offset += 1; continue
offset += 1
try:
length, new_offset = read_varint(data, offset)
if 0 < length < 1_000_000 and new_offset + length <= len(data):
packets.append(data[new_offset:new_offset + length])
offset = new_offset + length
else:
offset = new_offset
except:
offset += 1
# 2) 在 ftrace_events 中搜索自定义 marker
events = []
for pkt in packets:
# 找 field 2 (ftrace_events bundle)
po = 0
while po < len(pkt):
if po >= 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 抢占或手指短暂离屏。

View File

@ -14,11 +14,55 @@ android {
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1 versionCode = 1
versionName = "1.0" 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 { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = 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 { dependencies {

20
androidApp/proguard-rules.pro vendored Normal file
View File

@ -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 <methods>;
}

View File

@ -7,7 +7,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@style/Theme.YaYa">
<activity <activity
android:exported="true" android:exported="true"
android:name=".MainActivity"> android:name=".MainActivity">

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.YaYa" parent="@android:style/Theme.Material.NoActionBar" />
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.YaYa" parent="@android:style/Theme.Material.Light.NoActionBar" />
</resources>

View File

@ -6,11 +6,14 @@ kotlin.daemon.jvmargs=-Xmx3072M
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
org.gradle.caching=true org.gradle.caching=true
org.gradle.parallel=true
org.gradle.daemon=true
#Android #Android
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.useAndroidX=true android.useAndroidX=true
android.uniquePackageNames=false android.uniquePackageNames=false
android.dependency.useConstraints=true android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false android.r8.strictFullModeForKeepRules=true
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
android.enableR8.fullMode=true

View File

@ -14,7 +14,8 @@ junit = "4.13.2"
kotlin = "2.3.21" kotlin = "2.3.21"
material3 = "1.10.0-alpha05" material3 = "1.10.0-alpha05"
kotlinx-datetime = "0.8.0" kotlinx-datetime = "0.8.0"
tyme4kt = "1.4.4" tyme4kt = "1.4.5"
sketch = "4.4.0"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 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-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" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.11.0" }
tyme4kt = { module = "cn.6tail:tyme4kt", version.ref = "tyme4kt" } 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] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@ -46,6 +46,9 @@ kotlin {
implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.tyme4kt) implementation(libs.tyme4kt)
implementation(libs.sketch.compose)
implementation(libs.sketch.animated.gif)
implementation(libs.sketch.compose.resources)
} }
commonTest.dependencies { commonTest.dependencies {
implementation(libs.kotlin.test) implementation(libs.kotlin.test)

View File

@ -2,6 +2,18 @@ package plus.rua.project
import android.os.Trace 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() actual fun composeTraceEndSection() {
try {
Trace.endSection()
} catch (_: RuntimeException) {
// Trace API 在 host test 中未 stub忽略
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -1,18 +1,22 @@
package plus.rua.project package plus.rua.project
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import plus.rua.project.ui.CalendarMonthView import plus.rua.project.ui.CalendarMonthView
/** /**
* 应用入口 Composable包裹 CalendarMonthView 并提供 MaterialTheme * 应用入口 Composable根据系统主题切换明暗 ColorScheme 包裹 CalendarMonthView
*/ */
@Composable @Composable
@Preview(name = "Calendar App") @Preview(name = "Calendar App")
fun App() { fun App() {
MaterialTheme { val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme = colorScheme) {
CalendarMonthView(modifier = Modifier) CalendarMonthView(modifier = Modifier)
} }
} }

View File

@ -106,7 +106,8 @@ class CalendarViewModel(
} }
/** /**
* 切换年视图仅在展开态可用 * 切换年视图折叠态下保持折叠`isCollapsed` 不变
* 月视图层以折叠形态参与缩放转场从年视图返回时仍是周视图
* *
* 切换瞬间立即翻转 isYearView让对应方向的目标视图立刻接管渲染 * 切换瞬间立即翻转 isYearView让对应方向的目标视图立刻接管渲染
* 当前视图被直接移除动画只作用在目标视图的 scale/alpha * 当前视图被直接移除动画只作用在目标视图的 scale/alpha
@ -114,13 +115,6 @@ class CalendarViewModel(
fun toggleYearView() { fun toggleYearView() {
yearViewJob?.cancel() yearViewJob?.cancel()
yearViewJob = coroutineScope.launch { yearViewJob = coroutineScope.launch {
// 折叠态先展开回月视图,再切换年视图
if (isCollapsed) {
_collapseAnimatable.animateTo(
0f, spring(dampingRatio = 0.8f, stiffness = 400f)
)
isCollapsed = false
}
if (isYearView) { if (isYearView) {
// 年 → 月:先启动动画(年视图开始淡出),等一帧后翻转 isYearView月视图开始组合 // 年 → 月:先启动动画(年视图开始淡出),等一帧后翻转 isYearView月视图开始组合
composeTraceBeginSection("YearView→MonthView") composeTraceBeginSection("YearView→MonthView")
@ -186,8 +180,9 @@ class CalendarViewModel(
* @param delta 拖拽增量已归一化到 [0,1] 区间 * @param delta 拖拽增量已归一化到 [0,1] 区间
*/ */
fun onDrag(delta: Float) { fun onDrag(delta: Float) {
val old = _collapseAnimatable.value
coroutineScope.launch { coroutineScope.launch {
val new = (_collapseAnimatable.value + delta).coerceIn(0f, 1f) val new = (old + delta).coerceIn(0f, 1f)
_collapseAnimatable.snapTo(new) _collapseAnimatable.snapTo(new)
} }
} }
@ -203,9 +198,9 @@ class CalendarViewModel(
coroutineScope.launch { coroutineScope.launch {
val progress = _collapseAnimatable.value val progress = _collapseAnimatable.value
val shouldCollapse = when { val shouldCollapse = when {
velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> true // 快速上滑→折叠 velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> true
velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> false // 快速下滑→展开 velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> false
else -> progress > COLLAPSE_THRESHOLD // 慢速按 progress 判断 else -> progress > COLLAPSE_THRESHOLD
} }
if (shouldCollapse) { if (shouldCollapse) {
_collapseAnimatable.animateTo( _collapseAnimatable.animateTo(
@ -228,8 +223,9 @@ class CalendarViewModel(
* @param delta 拖拽增量已归一化到 [0,1] 区间 * @param delta 拖拽增量已归一化到 [0,1] 区间
*/ */
fun onExpandDrag(delta: Float) { fun onExpandDrag(delta: Float) {
val old = _collapseAnimatable.value
coroutineScope.launch { coroutineScope.launch {
val new = (_collapseAnimatable.value + delta).coerceIn(0f, 1f) val new = (old + delta).coerceIn(0f, 1f)
_collapseAnimatable.snapTo(new) _collapseAnimatable.snapTo(new)
} }
} }
@ -245,9 +241,9 @@ class CalendarViewModel(
coroutineScope.launch { coroutineScope.launch {
val progress = _collapseAnimatable.value val progress = _collapseAnimatable.value
val shouldExpand = when { val shouldExpand = when {
velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> true // 快速下滑→展开 velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> true
velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> false // 快速上滑→保持折叠 velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> false
else -> progress < 1f - COLLAPSE_THRESHOLD // 慢速按 progress 判断 else -> progress < 1f - COLLAPSE_THRESHOLD
} }
if (shouldExpand) { if (shouldExpand) {
_collapseAnimatable.animateTo( _collapseAnimatable.animateTo(

View File

@ -0,0 +1,23 @@
package plus.rua.project.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.github.panpf.sketch.AsyncImage
/**
* 显示动画 GIF 图片
*
* @param modifier 应用于图片的 Modifier
* @param contentDescription 无障碍描述
*/
@Composable
fun AnimatedGif(
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
AsyncImage(
uri = "compose.resource://files/puppy_1.gif",
contentDescription = contentDescription,
modifier = modifier,
)
}

View File

@ -2,14 +2,20 @@ package plus.rua.project.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -17,12 +23,20 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.datetime.LocalDate
import plus.rua.project.CalendarViewModel import plus.rua.project.CalendarViewModel
/** /**
* 底部卡片折叠状态下支持垂直拖拽触发折叠动画 * 底部卡片折叠状态下支持垂直拖拽触发折叠动画
* *
* 卡片顶部显示拖拽把手下方展示选中日期信息
* 左侧为相对今天的天数描述A和公历日期B
* 右侧为农历日期C
*
* @param viewModel 日历 ViewModel用于读取折叠状态和驱动拖拽 * @param viewModel 日历 ViewModel用于读取折叠状态和驱动拖拽
* @param selectedDate 当前选中的日期
* @param today 今天的日期
* @param dragRangePx 拖拽手势映射范围像素progress 01 对应手指移动此距离 * @param dragRangePx 拖拽手势映射范围像素progress 01 对应手指移动此距离
* 应设为折叠时日历实际高度变化量 (weeks-1)×rowHeight使拖拽跟手 * 应设为折叠时日历实际高度变化量 (weeks-1)×rowHeight使拖拽跟手
* @param modifier 外部布局修饰符 * @param modifier 外部布局修饰符
@ -30,10 +44,17 @@ import plus.rua.project.CalendarViewModel
@Composable @Composable
fun BottomCard( fun BottomCard(
viewModel: CalendarViewModel, viewModel: CalendarViewModel,
selectedDate: LocalDate,
today: LocalDate,
dragRangePx: Float, dragRangePx: Float,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val relativeDesc = relativeDayDescription(selectedDate, today)
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
val solarDesc = "${selectedDate.monthNumber}${selectedDate.day}"
val lunarDesc = formatLunarDate(selectedDate)
Surface( Surface(
modifier = modifier modifier = modifier
@ -80,16 +101,52 @@ fun BottomCard(
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
shadowElevation = 4.dp shadowElevation = 4.dp
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// 拖拽把手
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp, bottom = 8.dp) .padding(top = 8.dp, bottom = 8.dp)
.clip(RoundedCornerShape(2.dp)) .clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
.fillMaxWidth(0.15f) .fillMaxWidth(0.15f)
.height(4.dp) .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
) )
} }
} }
}
} }

View File

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@ -311,6 +312,8 @@ fun CalendarMonthView(
if (cardHeightPx > 0) { if (cardHeightPx > 0) {
BottomCard( BottomCard(
viewModel = viewModel, viewModel = viewModel,
selectedDate = viewModel.selectedDate,
today = today,
dragRangePx = dragRangePx, dragRangePx = dragRangePx,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -392,6 +395,7 @@ fun CalendarMonthView(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.padding(start = 24.dp, bottom = 32.dp), .padding(start = 24.dp, bottom = 32.dp),
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) { ) {

View File

@ -1,7 +1,9 @@
package plus.rua.project.ui package plus.rua.project.ui
import com.tyme.solar.SolarDay
import kotlinx.datetime.DatePeriod import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus import kotlinx.datetime.minus
import kotlinx.datetime.number import kotlinx.datetime.number
import kotlinx.datetime.plus import kotlinx.datetime.plus
@ -10,7 +12,7 @@ import kotlinx.datetime.plus
const val START_PAGE = Int.MAX_VALUE / 2 const val START_PAGE = Int.MAX_VALUE / 2
/** 折叠判定阈值:折叠时 progress > 此值触发,展开时 progress < (1-此值) 触发 */ /** 折叠判定阈值:折叠时 progress > 此值触发,展开时 progress < (1-此值) 触发 */
const val COLLAPSE_THRESHOLD = 0.25f const val COLLAPSE_THRESHOLD = 0.08f
/** 滑动偏移插值阈值abs(offsetFraction) > 此值时启用插值 */ /** 滑动偏移插值阈值abs(offsetFraction) > 此值时启用插值 */
const val OFFSET_FRACTION_THRESHOLD = 0.01f const val OFFSET_FRACTION_THRESHOLD = 0.01f
@ -130,3 +132,40 @@ fun pageToWeekMonday(page: Int, initial: LocalDate): LocalDate {
val offset = page - START_PAGE val offset = page - START_PAGE
return initial.plus(DatePeriod(days = offset * 7)) 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 无替代 APIkotlinx-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()}"
}

View File

@ -5,7 +5,6 @@ import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
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.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -274,10 +273,8 @@ fun DayCell(
} }
val shiftLabel = if (shiftKind == ShiftKind.WORK) "" else "" val shiftLabel = if (shiftKind == ShiftKind.WORK) "" else ""
val shiftAlpha = if (isCurrentMonth) 1f else 0.38f val shiftAlpha = if (isCurrentMonth) 1f else 0.38f
// 右上角(默认)沿用法定调休视觉:surface 背景 + 彩色文字; // 右上角(默认)无背景,文字直接浮在单元格上;
// 左上角(showLegalHoliday=true 时)用实心胶囊,与右上角法定调休区分。 // 左上角(showLegalHoliday=true 时)用实心胶囊,与右上角法定调休区分。
val shiftBgColor =
if (showLegalHoliday) shiftAccentColor else MaterialTheme.colorScheme.surface
val shiftFgColor = if (showLegalHoliday) shiftOnAccentColor else shiftAccentColor val shiftFgColor = if (showLegalHoliday) shiftOnAccentColor else shiftAccentColor
val shiftAlignment = if (showLegalHoliday) Alignment.TopStart else Alignment.TopEnd val shiftAlignment = if (showLegalHoliday) Alignment.TopStart else Alignment.TopEnd
val shiftPadding = if (showLegalHoliday) { val shiftPadding = if (showLegalHoliday) {
@ -295,7 +292,6 @@ fun DayCell(
.align(shiftAlignment) .align(shiftAlignment)
.zIndex(1f) .zIndex(1f)
.then(shiftPadding) .then(shiftPadding)
.background(shiftBgColor.copy(alpha = shiftAlpha), CircleShape)
.padding(horizontal = 2.dp) .padding(horizontal = 2.dp)
) )
} }
@ -310,7 +306,6 @@ fun DayCell(
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.zIndex(1f) .zIndex(1f)
.padding(top = 1.dp, end = 2.dp) .padding(top = 1.dp, end = 2.dp)
.background(MaterialTheme.colorScheme.surface, CircleShape)
.padding(horizontal = 2.dp) .padding(horizontal = 2.dp)
) )
} }

View File

@ -112,7 +112,11 @@ fun YearGridView(
listOf( listOf(
(month to true) to textMeasurer.measure( (month to true) to textMeasurer.measure(
text, text,
TextStyle(fontSize = 10.sp, color = colors.titleSelected, fontWeight = FontWeight.Bold) TextStyle(
fontSize = 10.sp,
color = colors.titleSelected,
fontWeight = FontWeight.Bold
)
), ),
(month to false) to textMeasurer.measure( (month to false) to textMeasurer.measure(
text, text,

View File

@ -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]")
}
}
}

View File

@ -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())
}
}

View File

@ -0,0 +1,170 @@
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")
}
}
}