Merge branch 'DefectingCat:main' into main
This commit is contained in:
commit
c262572eee
1
.gitignore
vendored
1
.gitignore
vendored
@ -21,3 +21,4 @@ node_modules/
|
|||||||
# OMC runtime state
|
# OMC runtime state
|
||||||
.omc/
|
.omc/
|
||||||
logs/
|
logs/
|
||||||
|
.claude/
|
||||||
139
DEVELOPMENT.md
139
DEVELOPMENT.md
@ -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 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 \<<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 抢占或手指短暂离屏。
|
||||||
|
|||||||
@ -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
20
androidApp/proguard-rules.pro
vendored
Normal 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>;
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
4
androidApp/src/main/res/values-night/themes.xml
Normal file
4
androidApp/src/main/res/values-night/themes.xml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.YaYa" parent="@android:style/Theme.Material.NoActionBar" />
|
||||||
|
</resources>
|
||||||
4
androidApp/src/main/res/values/themes.xml
Normal file
4
androidApp/src/main/res/values/themes.xml
Normal 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>
|
||||||
@ -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
|
||||||
|
|||||||
@ -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" }
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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;忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
shared/src/commonMain/composeResources/drawable/puppy_1.gif
Normal file
BIN
shared/src/commonMain/composeResources/drawable/puppy_1.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
shared/src/commonMain/composeResources/files/puppy_1.gif
Normal file
BIN
shared/src/commonMain/composeResources/files/puppy_1.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 从 0→1 对应手指移动此距离。
|
* @param dragRangePx 拖拽手势映射范围(像素),progress 从 0→1 对应手指移动此距离。
|
||||||
* 应设为折叠时日历实际高度变化量 (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 无替代 API,kotlinx-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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -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 无替代 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()}"
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user