refactor: Wave 1 — 构建清理、测试修复、文档修正、a11y 改进
- build: 清理版本目录死依赖(appcompat、core、material3) - build: 精简 ProGuard keep 规则,仅保留热点路径和第三方库 - refactor: 重命名 PredictiveBackHandler → AppPredictiveBackHandler - test: formatLunarDate 精确断言 + 闰月测试 - test: 统一测试命名风格(下划线),Unconfined → StandardTestDispatcher - docs: 修正文档为纯 Android + Jetpack Compose 描述 - feat(a11y): 添加可访问性语义标注(WeekdayHeader heading、BottomCard 拖拽描述) - feat: 为 DayCell、MonthHeader、WeekdayHeader、BottomCard 添加 @Preview Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2bd7d5ee19
commit
bf28008d17
39
CLAUDE.md
39
CLAUDE.md
@ -4,47 +4,32 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
YaYa is a calendar app built with Kotlin Multiplatform (KMP) + Compose Multiplatform, targeting Android and iOS. The shared UI is written entirely in Compose Multiplatform with Material 3.
|
YaYa is a calendar app built with pure Android + Jetpack Compose, targeting Android only. The UI is written entirely in Jetpack Compose with Material 3.
|
||||||
|
|
||||||
## Build Commands
|
## Build Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build Android debug APK
|
# Build Android debug APK
|
||||||
./gradlew :androidApp:assembleDebug
|
./gradlew :app:assembleDebug
|
||||||
|
|
||||||
# Install Android debug APK to connected device
|
# Install Android debug APK to connected device
|
||||||
./gradlew :androidApp:installDebug
|
./gradlew :app:installDebug
|
||||||
|
|
||||||
# Run all shared module tests
|
# Run core module tests
|
||||||
./gradlew :shared:allTests
|
./gradlew :core:testDebugUnitTest
|
||||||
|
|
||||||
# Run shared module tests on Android host only
|
|
||||||
./gradlew :shared:testAndroidHostTest
|
|
||||||
|
|
||||||
# Run a single test class
|
# Run a single test class
|
||||||
./gradlew :shared:testAndroidHostTest --tests "plus.rua.project.ui.CalendarUtilsTest"
|
./gradlew :core:testDebugUnitTest --tests "plus.rua.project.ui.CalendarUtilsTest"
|
||||||
|
|
||||||
# Generate iOS framework (required before first Xcode open or after clean)
|
|
||||||
./gradlew :shared:generateDummyFramework
|
|
||||||
|
|
||||||
# Build iOS app — open iosApp/iosApp.xcworkspace in Xcode and run from there
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Gradle configuration cache and build cache are enabled by default (`gradle.properties`).
|
Gradle configuration cache and build cache are enabled by default (`gradle.properties`).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**Two-module structure:**
|
**Three-module structure:**
|
||||||
- `:shared` — all Compose UI, ViewModel, and business logic (KMP library)
|
- `:core` — all Compose UI, ViewModel, and business logic (`com.android.library`)
|
||||||
- `:androidApp` — thin Android shell (`MainActivity` → `App()`)
|
- `:app` — thin Android shell (`MainActivity` → `App()`)
|
||||||
|
- `:macrobenchmark` — Macrobenchmark module for Baseline Profile generation
|
||||||
iOS entry point is `MainViewController.kt` in `shared/src/iosMain/`, consumed by the Xcode project in `iosApp/`.
|
|
||||||
|
|
||||||
**Shared source sets:**
|
|
||||||
- `commonMain` — all Compose UI and ViewModel code
|
|
||||||
- `commonTest` — shared tests (run via `:shared:allTests` or `:shared:androidHostTest`)
|
|
||||||
- `androidMain` — Android-specific platform impl + preview tooling
|
|
||||||
- `iosMain` — `ComposeUIViewController` factory
|
|
||||||
|
|
||||||
**Calendar UI composition** (all in `plus.rua.project.ui`):
|
**Calendar UI composition** (all in `plus.rua.project.ui`):
|
||||||
```
|
```
|
||||||
@ -71,7 +56,7 @@ CalendarMonthView ← top-level screen (MonthHeader + WeekdayHeader + p
|
|||||||
|
|
||||||
`CalendarViewModel` holds `selectedDate` and `isCollapsed` state, computes month day grids (6×7=42 cells) and ISO week numbers. Week starts on Monday (ISO 8601).
|
`CalendarViewModel` holds `selectedDate` and `isCollapsed` state, computes month day grids (6×7=42 cells) and ISO week numbers. Week starts on Monday (ISO 8601).
|
||||||
|
|
||||||
**Performance tracing:** `ComposeTrace.kt` provides `composeTraceBeginSection`/`composeTraceEndSection` via expect/actual — Android routes to `android.os.Trace`, iOS is a no-op. Custom markers are inserted at key points (e.g., `MonthView:Compose`, `YearView:Compose`, `VM:collapseProgress`) for Perfetto/Systrace analysis. See `DEVELOPMENT.md` for trace recording and Python parsing scripts.
|
**Performance tracing:** `ComposeTrace.kt` provides `composeTraceBeginSection`/`composeTraceEndSection` wrapping `android.os.Trace`. Custom markers are inserted at key points (e.g., `MonthView:Compose`, `YearView:Compose`, `VM:collapseProgress`) for Perfetto/Systrace analysis. See `DEVELOPMENT.md` for trace recording and Python parsing scripts.
|
||||||
|
|
||||||
## Key Dependencies
|
## Key Dependencies
|
||||||
|
|
||||||
@ -85,7 +70,7 @@ CalendarMonthView ← top-level screen (MonthHeader + WeekdayHeader + p
|
|||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Package: `plus.rua.project` (shared), `plus.rua.project.ui` (UI composables)
|
- Package: `plus.rua.project` (core), `plus.rua.project.ui` (UI composables)
|
||||||
- Version catalog at `gradle/libs.versions.toml` — all dependency versions declared there
|
- Version catalog at `gradle/libs.versions.toml` — all dependency versions declared there
|
||||||
- `@Suppress("DEPRECATION")` used for `monthNumber` access on `kotlinx.datetime.LocalDate` — must include inline comment explaining reason
|
- `@Suppress("DEPRECATION")` used for `monthNumber` access on `kotlinx.datetime.LocalDate` — must include inline comment explaining reason
|
||||||
- UI text is in Chinese (weekday labels, month header format "2026年5月")
|
- UI text is in Chinese (weekday labels, month header format "2026年5月")
|
||||||
|
|||||||
267
DEVELOPMENT.md
267
DEVELOPMENT.md
@ -1,259 +1,5 @@
|
|||||||
# 开发指南
|
# 开发指南
|
||||||
|
|
||||||
## 环境要求
|
|
||||||
|
|
||||||
- JDK 17+
|
|
||||||
- Android Studio (Ladybug 或更新版本)
|
|
||||||
- Xcode 16+ (仅 iOS 构建需要)
|
|
||||||
- Kotlin Multiplatform 插件 (Android Studio 内置)
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
YaYa/
|
|
||||||
├── shared/ # 共享模块 — 所有 UI 和业务逻辑
|
|
||||||
│ ├── src/commonMain/kotlin/ # 跨平台代码
|
|
||||||
│ │ └── plus/rua/project/
|
|
||||||
│ │ ├── App.kt # 应用入口
|
|
||||||
│ │ ├── CalendarViewModel.kt # 日历状态管理
|
|
||||||
│ │ └── ui/
|
|
||||||
│ │ ├── CalendarMonthView.kt # 顶层日历屏幕
|
|
||||||
│ │ ├── CalendarMonthPage.kt # 单月网格页
|
|
||||||
│ │ ├── CalendarPager.kt # 月视图无限分页
|
|
||||||
│ │ ├── WeekPager.kt # 周视图无限分页
|
|
||||||
│ │ ├── DayCell.kt # 单日圆圈组件
|
|
||||||
│ │ ├── MonthHeader.kt # 年月标题 + 周数
|
|
||||||
│ │ ├── WeekdayHeader.kt # 星期标题行
|
|
||||||
│ │ └── BottomCard.kt # 底部拖拽卡片
|
|
||||||
│ ├── src/commonTest/kotlin/ # 共享测试
|
|
||||||
│ ├── src/androidMain/kotlin/ # Android 预览工具
|
|
||||||
│ └── src/iosMain/kotlin/ # iOS ViewController 工厂
|
|
||||||
├── androidApp/ # Android 薄壳 — MainActivity → App()
|
|
||||||
├── iosApp/ # iOS 入口 — Xcode 项目
|
|
||||||
└── gradle/libs.versions.toml # 版本目录 — 统一管理依赖版本
|
|
||||||
```
|
|
||||||
|
|
||||||
## 运行
|
|
||||||
|
|
||||||
### Android
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 命令行构建
|
|
||||||
./gradlew :androidApp:assembleDebug
|
|
||||||
|
|
||||||
# 安装到设备
|
|
||||||
./gradlew :androidApp:installDebug
|
|
||||||
```
|
|
||||||
|
|
||||||
或在 Android Studio 中选择 `androidApp` 配置直接运行。
|
|
||||||
|
|
||||||
### iOS
|
|
||||||
|
|
||||||
1. 先执行一次 Gradle 同步:`./gradlew :shared:generateDummyFramework`
|
|
||||||
2. 在 Xcode 中打开 `iosApp/iosApp.xcworkspace`
|
|
||||||
3. 选择目标设备或模拟器,点击 Run
|
|
||||||
|
|
||||||
> 首次打开可能需要等待 Xcode 索引完成。如果报 framework 错误,重新执行 Gradle 同步即可。
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 运行所有共享模块测试
|
|
||||||
./gradlew :shared:allTests
|
|
||||||
|
|
||||||
# 运行单个测试类 (Android host)
|
|
||||||
./gradlew :shared:androidHostTest --tests "plus.rua.project.ComposeAppCommonTest"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 开发约定
|
|
||||||
|
|
||||||
### 代码组织
|
|
||||||
|
|
||||||
- 所有 Compose UI 和 ViewModel 代码放在 `shared/commonMain`,不按平台拆分
|
|
||||||
- 平台特定代码仅放在对应的 `androidMain` / `iosMain`
|
|
||||||
- UI 组件统一在 `plus.rua.project.ui` 包下
|
|
||||||
|
|
||||||
### Compose 规范
|
|
||||||
|
|
||||||
- `Modifier` 参数始终放在最后
|
|
||||||
- 回调参数使用 `on` 前缀:`onDateClick`、`onMonthChanged`
|
|
||||||
- 公开 `@Composable` 函数需要 KDoc 注释(详见 `COMMENTS.md`)
|
|
||||||
|
|
||||||
### 日期处理
|
|
||||||
|
|
||||||
- 统一使用 `kotlinx-datetime`,禁止使用 `java.util.Calendar`
|
|
||||||
- 周起始为周一 (ISO 8601)
|
|
||||||
- `monthNumber` 访问需要 `@Suppress("DEPRECATION")` 并附行内注释说明原因
|
|
||||||
|
|
||||||
### UI 文案
|
|
||||||
|
|
||||||
- 界面文字为中文(星期标题 "一二三四五六日",月份格式 "2026年5月")
|
|
||||||
|
|
||||||
### 依赖管理
|
|
||||||
|
|
||||||
- 所有版本声明在 `gradle/libs.versions.toml`,不硬编码
|
|
||||||
- 新增依赖先在版本目录添加条目,再在 `build.gradle.kts` 中引用
|
|
||||||
|
|
||||||
## 架构概览
|
|
||||||
|
|
||||||
```
|
|
||||||
CalendarMonthView (顶层屏幕)
|
|
||||||
├── MonthHeader 年月标签 + ISO 周数
|
|
||||||
├── WeekdayHeader 固定星期行
|
|
||||||
├── CalendarPager 月视图无限分页 (Int.MAX_VALUE 页)
|
|
||||||
│ └── CalendarMonthPage 6×7 DayCell 网格,折叠时压缩非选中行
|
|
||||||
│ └── DayCell 单日圆圈,选中/今日状态
|
|
||||||
├── WeekPager 周视图无限分页 (折叠态)
|
|
||||||
│ └── DayCell
|
|
||||||
└── BottomCard 拖拽手柄,驱动折叠/展开手势
|
|
||||||
```
|
|
||||||
|
|
||||||
**折叠动画:** `CalendarViewModel.collapseProgress` 控制 0f(月)↔1f(周) 过渡。`BottomCard` 捕获垂直拖拽,释放时超过 50% 则弹簧动画吸附到最近状态。完全折叠后 `WeekPager` 替代 `CalendarPager` 实现高效单周分页。
|
|
||||||
|
|
||||||
**分页映射:** 两个 Pager 均使用 `Int.MAX_VALUE` 页数,中心页为 `Int.MAX_VALUE / 2`。页码到日期为算术转换,无索引列表。两者均跳过初始 `snapshotFlow` 发射 (`.drop(1)`) 以保留首次渲染时的"今日"选中。
|
|
||||||
|
|
||||||
## 性能排查(Perfetto / Systrace)
|
|
||||||
|
|
||||||
项目使用 `composeTraceBeginSection` / `composeTraceEndSection` 在关键代码段插入 trace marker,Android 上会被记录到系统 trace 中。iOS 为空操作。
|
|
||||||
|
|
||||||
已有的 trace section:
|
|
||||||
|
|
||||||
- `MonthView:Compose` / `YearView:Compose` — 顶层重组耗时
|
|
||||||
- `YearView→MonthView` / `MonthView→YearView` — 年视图切换动画
|
|
||||||
- `YearGridView:$year` / `generateMiniMonthDays:$year-$month` — 年网格渲染
|
|
||||||
- `getMonthDays:$year-$month` — 月网格数据生成
|
|
||||||
|
|
||||||
### 分析折叠器卡顿的方法
|
|
||||||
|
|
||||||
1. **录制 trace**:Android Studio → Profiler → CPU → 选择 "Trace Java Methods" 或命令行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
adb shell perfetto -c - --txt \<<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 抢占或手指短暂离屏。
|
|
||||||
|
|
||||||
## Baseline Profile
|
## Baseline Profile
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -293,3 +39,16 @@ Baseline Profile 自动生成器。
|
|||||||
3. 日期选择 → 周视图折叠/展开
|
3. 日期选择 → 周视图折叠/展开
|
||||||
4. 关于页 → 开源许可页
|
4. 关于页 → 开源许可页
|
||||||
5. 返回主界面
|
5. 返回主界面
|
||||||
|
|
||||||
|
## 模拟器
|
||||||
|
|
||||||
|
```
|
||||||
|
emulator -avd Pixel_10 \
|
||||||
|
-no-snapshot \
|
||||||
|
-no-boot-anim \
|
||||||
|
-gpu host \
|
||||||
|
-accel on \
|
||||||
|
-cores 4 \
|
||||||
|
-memory 4096 \
|
||||||
|
-partition-size 2048
|
||||||
|
```
|
||||||
|
|||||||
95
core/proguard-rules.pro
vendored
95
core/proguard-rules.pro
vendored
@ -29,95 +29,6 @@
|
|||||||
public static java.util.List generateMonthDays(...);
|
public static java.util.List generateMonthDays(...);
|
||||||
}
|
}
|
||||||
|
|
||||||
# CalendarMonthView
|
# ========== 第三方库保留 ==========
|
||||||
-keepclassmembers class plus.rua.project.ui.CalendarMonthViewKt {
|
-keep class kotlinx.datetime.** { *; }
|
||||||
public static void CalendarMonthView(...);
|
-keep class cn.tyme.** { *; }
|
||||||
}
|
|
||||||
|
|
||||||
# CalendarPager
|
|
||||||
-keepclassmembers class plus.rua.project.ui.CalendarPagerKt {
|
|
||||||
public static void CalendarPager(...);
|
|
||||||
}
|
|
||||||
|
|
||||||
# WeekPager
|
|
||||||
-keepclassmembers class plus.rua.project.ui.WeekPagerKt {
|
|
||||||
public static void WeekPager(...);
|
|
||||||
}
|
|
||||||
|
|
||||||
# BottomCard
|
|
||||||
-keepclassmembers class plus.rua.project.ui.BottomCardKt {
|
|
||||||
public static void BottomCard(...);
|
|
||||||
}
|
|
||||||
|
|
||||||
# CalendarViewModel
|
|
||||||
-keepclassmembers class plus.rua.project.CalendarViewModel {
|
|
||||||
public <init>(...);
|
|
||||||
public kotlinx.datetime.LocalDate getSelectedDate();
|
|
||||||
public java.util.List getMonthDays(...);
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 全量业务类保留 ==========
|
|
||||||
# 保留所有业务类名和方法名,确保 profile 通配规则始终匹配
|
|
||||||
|
|
||||||
# CalendarUtils 所有方法
|
|
||||||
-keepclassmembers class plus.rua.project.ui.CalendarUtilsKt {
|
|
||||||
public static *;
|
|
||||||
}
|
|
||||||
|
|
||||||
# MonthHeader
|
|
||||||
-keepclassmembers class plus.rua.project.ui.MonthHeaderKt {
|
|
||||||
public static *;
|
|
||||||
}
|
|
||||||
|
|
||||||
# WeekdayHeader
|
|
||||||
-keepclassmembers class plus.rua.project.ui.WeekdayHeaderKt {
|
|
||||||
public static *;
|
|
||||||
}
|
|
||||||
|
|
||||||
# YearGridView / YearHeader / MiniMonth
|
|
||||||
-keepclassmembers class plus.rua.project.ui.YearGridViewKt {
|
|
||||||
public static *;
|
|
||||||
}
|
|
||||||
|
|
||||||
# AboutScreen
|
|
||||||
-keepclassmembers class plus.rua.project.ui.AboutScreenKt {
|
|
||||||
public static *;
|
|
||||||
}
|
|
||||||
|
|
||||||
# LicensesScreen / LicenseItem
|
|
||||||
-keepclassmembers class plus.rua.project.ui.LicensesScreenKt {
|
|
||||||
public static *;
|
|
||||||
}
|
|
||||||
-keepclassmembers class plus.rua.project.ui.LicensesKt {
|
|
||||||
public static *;
|
|
||||||
}
|
|
||||||
|
|
||||||
# AnimatedGif
|
|
||||||
-keepclassmembers class plus.rua.project.ui.AnimatedGifKt {
|
|
||||||
public static *;
|
|
||||||
}
|
|
||||||
|
|
||||||
# CalendarViewModel$CalendarDay
|
|
||||||
-keepclassmembers class plus.rua.project.CalendarViewModel$CalendarDay {
|
|
||||||
public *;
|
|
||||||
}
|
|
||||||
|
|
||||||
# ShiftPattern
|
|
||||||
-keepclassmembers class plus.rua.project.ShiftPattern {
|
|
||||||
public *;
|
|
||||||
}
|
|
||||||
|
|
||||||
# AppInfo
|
|
||||||
-keepclassmembers class plus.rua.project.AppInfo {
|
|
||||||
public static *;
|
|
||||||
}
|
|
||||||
|
|
||||||
# ComposeTrace
|
|
||||||
-keepclassmembers class plus.rua.project.ComposeTraceKt {
|
|
||||||
public static *;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Platform
|
|
||||||
-keepclassmembers class plus.rua.project.PlatformKt {
|
|
||||||
public static *;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -47,7 +47,7 @@ fun getAppVersion(): String {
|
|||||||
* @param onCancel 手势取消回调(滑动距离不足,回弹)
|
* @param onCancel 手势取消回调(滑动距离不足,回弹)
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PredictiveBackHandler(
|
fun AppPredictiveBackHandler(
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
onProgress: (Float) -> Unit = {},
|
onProgress: (Float) -> Unit = {},
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
|||||||
@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
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.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@ -117,6 +120,9 @@ fun BottomCard(
|
|||||||
.fillMaxWidth(0.15f)
|
.fillMaxWidth(0.15f)
|
||||||
.height(4.dp)
|
.height(4.dp)
|
||||||
.align(Alignment.CenterHorizontally)
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = "拖拽以展开或折叠日历"
|
||||||
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
// A / B / C 信息行
|
// A / B / C 信息行
|
||||||
@ -176,3 +182,16 @@ fun BottomCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun BottomCardPreview() {
|
||||||
|
val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined)
|
||||||
|
val viewModel = CalendarViewModel(scope)
|
||||||
|
BottomCard(
|
||||||
|
viewModel = viewModel,
|
||||||
|
selectedDate = kotlinx.datetime.LocalDate(2026, 5, 21),
|
||||||
|
today = kotlinx.datetime.LocalDate(2026, 5, 21),
|
||||||
|
dragRangePx = 300f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import androidx.compose.ui.semantics.semantics
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
@ -274,3 +275,17 @@ fun DayCell(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun DayCellPreview() {
|
||||||
|
DayCell(
|
||||||
|
date = LocalDate(2026, 5, 21),
|
||||||
|
isCurrentMonth = true,
|
||||||
|
isSelected = true,
|
||||||
|
isToday = true,
|
||||||
|
shiftKind = ShiftKind.WORK,
|
||||||
|
showLegalHoliday = false,
|
||||||
|
onClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
@ -108,3 +109,15 @@ fun MonthHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun MonthHeaderPreview() {
|
||||||
|
MonthHeader(
|
||||||
|
year = 2026,
|
||||||
|
month = 5,
|
||||||
|
weekNumber = 21,
|
||||||
|
showToday = true,
|
||||||
|
onToday = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
private val WEEKDAY_LABELS = listOf("一", "二", "三", "四", "五", "六", "日")
|
private val WEEKDAY_LABELS = listOf("一", "二", "三", "四", "五", "六", "日")
|
||||||
@ -19,7 +22,12 @@ private val WEEKDAY_LABELS = listOf("一", "二", "三", "四", "五", "六", "
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun WeekdayHeader(modifier: Modifier = Modifier) {
|
fun WeekdayHeader(modifier: Modifier = Modifier) {
|
||||||
Row(modifier = modifier.fillMaxWidth().padding(vertical = 12.dp)) {
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp)
|
||||||
|
.semantics { heading() }
|
||||||
|
) {
|
||||||
WEEKDAY_LABELS.forEach { label ->
|
WEEKDAY_LABELS.forEach { label ->
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
@ -31,3 +39,9 @@ fun WeekdayHeader(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun WeekdayHeaderPreview() {
|
||||||
|
WeekdayHeader()
|
||||||
|
}
|
||||||
|
|||||||
@ -39,6 +39,8 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import com.tyme.lunar.LunarYear
|
import com.tyme.lunar.LunarYear
|
||||||
import kotlinx.datetime.DatePeriod
|
import kotlinx.datetime.DatePeriod
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@ -168,6 +170,7 @@ fun YearGridView(
|
|||||||
val sharedKey = "month_grid_${year}_$month"
|
val sharedKey = "month_grid_${year}_$month"
|
||||||
with(sharedTransitionScope) {
|
with(sharedTransitionScope) {
|
||||||
MiniMonth(
|
MiniMonth(
|
||||||
|
year = year,
|
||||||
month = month,
|
month = month,
|
||||||
isSelected = month == selectedMonth,
|
isSelected = month == selectedMonth,
|
||||||
today = today,
|
today = today,
|
||||||
@ -205,6 +208,7 @@ fun YearGridView(
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun MiniMonth(
|
private fun MiniMonth(
|
||||||
|
year: Int,
|
||||||
month: Int,
|
month: Int,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
@ -230,7 +234,10 @@ private fun MiniMonth(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(2.dp)
|
.padding(2.dp)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(vertical = 2.dp),
|
.padding(vertical = 2.dp)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = "$year 年 $month 月"
|
||||||
|
},
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Canvas(modifier = Modifier.fillMaxWidth().height(totalHeight)) {
|
Canvas(modifier = Modifier.fillMaxWidth().height(totalHeight)) {
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
package plus.rua.project
|
package plus.rua.project
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestDispatcher
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@ -23,14 +27,15 @@ private class StateTestFixedClock(private val instant: Instant) : Clock {
|
|||||||
* 动画完成的最终状态(例如 [CalendarViewModel.isCollapsed] 在 spring
|
* 动画完成的最终状态(例如 [CalendarViewModel.isCollapsed] 在 spring
|
||||||
* 动画结束后的取值)需要 MonotonicFrameClock 驱动,不在本测试集合范围内。
|
* 动画结束后的取值)需要 MonotonicFrameClock 驱动,不在本测试集合范围内。
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class CalendarViewModelStateTest {
|
class CalendarViewModelStateTest {
|
||||||
|
|
||||||
// 固定 today = 2026/5/15
|
// 固定 today = 2026/5/15
|
||||||
private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z")
|
private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z")
|
||||||
private val testClock = StateTestFixedClock(fixedInstant)
|
private val testClock = StateTestFixedClock(fixedInstant)
|
||||||
|
|
||||||
private fun createViewModel(): CalendarViewModel {
|
private fun createViewModel(dispatcher: TestDispatcher = StandardTestDispatcher()): CalendarViewModel {
|
||||||
val scope = CoroutineScope(Dispatchers.Unconfined)
|
val scope = CoroutineScope(dispatcher)
|
||||||
return CalendarViewModel(coroutineScope = scope, clock = testClock)
|
return CalendarViewModel(coroutineScope = scope, clock = testClock)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,71 +260,92 @@ class CalendarViewModelStateTest {
|
|||||||
assertFalse(vm.showLegalHoliday)
|
assertFalse(vm.showLegalHoliday)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- onDrag: 折叠拖拽(同步路径:snapTo)----
|
// ---- onDrag: 折叠拖拽(异步路径:launch + snapTo)----
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onDrag_positiveDelta_increasesProgress() {
|
fun onDrag_positiveDelta_increasesProgress() = runTest {
|
||||||
val vm = createViewModel()
|
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||||
|
val vm = createViewModel(dispatcher = dispatcher)
|
||||||
vm.onDrag(0.3f)
|
vm.onDrag(0.3f)
|
||||||
|
advanceUntilIdle()
|
||||||
assertEquals(0.3f, vm.collapseProgress, 0.001f)
|
assertEquals(0.3f, vm.collapseProgress, 0.001f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onDrag_accumulatesAcrossCalls() {
|
fun onDrag_accumulatesAcrossCalls() = runTest {
|
||||||
val vm = createViewModel()
|
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||||
|
val vm = createViewModel(dispatcher = dispatcher)
|
||||||
vm.onDrag(0.2f)
|
vm.onDrag(0.2f)
|
||||||
|
advanceUntilIdle()
|
||||||
vm.onDrag(0.3f)
|
vm.onDrag(0.3f)
|
||||||
|
advanceUntilIdle()
|
||||||
assertEquals(0.5f, vm.collapseProgress, 0.001f)
|
assertEquals(0.5f, vm.collapseProgress, 0.001f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onDrag_clampsAtOne() {
|
fun onDrag_clampsAtOne() = runTest {
|
||||||
val vm = createViewModel()
|
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||||
|
val vm = createViewModel(dispatcher = dispatcher)
|
||||||
vm.onDrag(0.8f)
|
vm.onDrag(0.8f)
|
||||||
|
advanceUntilIdle()
|
||||||
vm.onDrag(0.8f)
|
vm.onDrag(0.8f)
|
||||||
|
advanceUntilIdle()
|
||||||
assertEquals(1f, vm.collapseProgress, 0.001f)
|
assertEquals(1f, vm.collapseProgress, 0.001f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onDrag_clampsAtZeroWhenNegativeFromZero() {
|
fun onDrag_clampsAtZeroWhenNegativeFromZero() = runTest {
|
||||||
val vm = createViewModel()
|
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||||
|
val vm = createViewModel(dispatcher = dispatcher)
|
||||||
vm.onDrag(-0.3f)
|
vm.onDrag(-0.3f)
|
||||||
|
advanceUntilIdle()
|
||||||
assertEquals(0f, vm.collapseProgress, 0.001f)
|
assertEquals(0f, vm.collapseProgress, 0.001f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onDrag_negativeAfterPositive_canDecrease() {
|
fun onDrag_negativeAfterPositive_canDecrease() = runTest {
|
||||||
val vm = createViewModel()
|
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||||
|
val vm = createViewModel(dispatcher = dispatcher)
|
||||||
vm.onDrag(0.5f)
|
vm.onDrag(0.5f)
|
||||||
|
advanceUntilIdle()
|
||||||
vm.onDrag(-0.2f)
|
vm.onDrag(-0.2f)
|
||||||
|
advanceUntilIdle()
|
||||||
assertEquals(0.3f, vm.collapseProgress, 0.001f)
|
assertEquals(0.3f, vm.collapseProgress, 0.001f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- onExpandDrag: 展开拖拽 ----
|
// ---- onExpandDrag: 展开拖拽 ----
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onExpandDrag_updatesProgress() {
|
fun onExpandDrag_updatesProgress() = runTest {
|
||||||
val vm = createViewModel()
|
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||||
|
val vm = createViewModel(dispatcher = dispatcher)
|
||||||
// 先把 progress 推到 1
|
// 先把 progress 推到 1
|
||||||
vm.onDrag(1f)
|
vm.onDrag(1f)
|
||||||
|
advanceUntilIdle()
|
||||||
assertEquals(1f, vm.collapseProgress, 0.001f)
|
assertEquals(1f, vm.collapseProgress, 0.001f)
|
||||||
// 展开方向:delta 为负
|
// 展开方向:delta 为负
|
||||||
vm.onExpandDrag(-0.4f)
|
vm.onExpandDrag(-0.4f)
|
||||||
|
advanceUntilIdle()
|
||||||
assertEquals(0.6f, vm.collapseProgress, 0.001f)
|
assertEquals(0.6f, vm.collapseProgress, 0.001f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onExpandDrag_clampsAtZero() {
|
fun onExpandDrag_clampsAtZero() = runTest {
|
||||||
val vm = createViewModel()
|
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||||
|
val vm = createViewModel(dispatcher = dispatcher)
|
||||||
vm.onDrag(0.5f)
|
vm.onDrag(0.5f)
|
||||||
|
advanceUntilIdle()
|
||||||
vm.onExpandDrag(-1f)
|
vm.onExpandDrag(-1f)
|
||||||
|
advanceUntilIdle()
|
||||||
assertEquals(0f, vm.collapseProgress, 0.001f)
|
assertEquals(0f, vm.collapseProgress, 0.001f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun onExpandDrag_clampsAtOne() {
|
fun onExpandDrag_clampsAtOne() = runTest {
|
||||||
val vm = createViewModel()
|
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||||
|
val vm = createViewModel(dispatcher = dispatcher)
|
||||||
vm.onExpandDrag(2f)
|
vm.onExpandDrag(2f)
|
||||||
|
advanceUntilIdle()
|
||||||
assertEquals(1f, vm.collapseProgress, 0.001f)
|
assertEquals(1f, vm.collapseProgress, 0.001f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
package plus.rua.project
|
package plus.rua.project
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@ -14,49 +15,51 @@ private class FixedClock(private val instant: Instant) : Clock {
|
|||||||
override fun now(): Instant = instant
|
override fun now(): Instant = instant
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class CalendarViewModelTest {
|
class CalendarViewModelTest {
|
||||||
|
|
||||||
private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z")
|
private val fixedInstant = Instant.parse("2026-05-15T00:00:00Z")
|
||||||
private val testClock = FixedClock(fixedInstant)
|
private val testClock = FixedClock(fixedInstant)
|
||||||
private fun createViewModel(): CalendarViewModel {
|
private fun createViewModel(): CalendarViewModel {
|
||||||
val scope = CoroutineScope(Dispatchers.Unconfined)
|
val dispatcher = StandardTestDispatcher()
|
||||||
|
val scope = CoroutineScope(dispatcher)
|
||||||
return CalendarViewModel(coroutineScope = scope, clock = testClock)
|
return CalendarViewModel(coroutineScope = scope, clock = testClock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- getIsoWeekNumber ----
|
// ---- getIsoWeekNumber ----
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getIsoWeekNumber_regularDate() {
|
fun getIsoWeekNumber_regular_date() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
assertEquals(20, vm.getIsoWeekNumber(LocalDate(2026, 5, 15)))
|
assertEquals(20, vm.getIsoWeekNumber(LocalDate(2026, 5, 15)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getIsoWeekNumber_jan1() {
|
fun getIsoWeekNumber_jan_1() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
assertEquals(1, vm.getIsoWeekNumber(LocalDate(2026, 1, 1)))
|
assertEquals(1, vm.getIsoWeekNumber(LocalDate(2026, 1, 1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getIsoWeekNumber_dec31() {
|
fun getIsoWeekNumber_dec_31() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
assertEquals(53, vm.getIsoWeekNumber(LocalDate(2026, 12, 31)))
|
assertEquals(53, vm.getIsoWeekNumber(LocalDate(2026, 12, 31)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getIsoWeekNumber_week52_boundary() {
|
fun getIsoWeekNumber_week_52_boundary() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
assertEquals(52, vm.getIsoWeekNumber(LocalDate(2025, 12, 28)))
|
assertEquals(52, vm.getIsoWeekNumber(LocalDate(2025, 12, 28)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getIsoWeekNumber_mondayStartsWeek() {
|
fun getIsoWeekNumber_monday_starts_week() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
assertEquals(20, vm.getIsoWeekNumber(LocalDate(2026, 5, 11)))
|
assertEquals(20, vm.getIsoWeekNumber(LocalDate(2026, 5, 11)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getIsoWeekNumber_week53_year() {
|
fun getIsoWeekNumber_week_53_year() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
assertEquals(53, vm.getIsoWeekNumber(LocalDate(2020, 12, 31)))
|
assertEquals(53, vm.getIsoWeekNumber(LocalDate(2020, 12, 31)))
|
||||||
}
|
}
|
||||||
@ -64,7 +67,7 @@ class CalendarViewModelTest {
|
|||||||
// ---- getMonthDays ----
|
// ---- getMonthDays ----
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getMonthDays_returnsCorrectSize() {
|
fun getMonthDays_returns_correct_size() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
// May 2026: 5 rows × 7 = 35 cells
|
// May 2026: 5 rows × 7 = 35 cells
|
||||||
val days = vm.getMonthDays(2026, 5)
|
val days = vm.getMonthDays(2026, 5)
|
||||||
@ -72,7 +75,7 @@ class CalendarViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getMonthDays_may2026_startsOnThursday() {
|
fun getMonthDays_may_2026_starts_on_thursday() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val days = vm.getMonthDays(2026, 5)
|
val days = vm.getMonthDays(2026, 5)
|
||||||
assertFalse(days[0].isCurrentMonth)
|
assertFalse(days[0].isCurrentMonth)
|
||||||
@ -82,7 +85,7 @@ class CalendarViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getMonthDays_may2026_firstDayIsMay1() {
|
fun getMonthDays_may_2026_first_day_is_may_1() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val days = vm.getMonthDays(2026, 5)
|
val days = vm.getMonthDays(2026, 5)
|
||||||
assertTrue(days[4].isCurrentMonth)
|
assertTrue(days[4].isCurrentMonth)
|
||||||
@ -92,7 +95,7 @@ class CalendarViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getMonthDays_may2026_lastDayIsMay31() {
|
fun getMonthDays_may_2026_last_day_is_may_31() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val days = vm.getMonthDays(2026, 5)
|
val days = vm.getMonthDays(2026, 5)
|
||||||
val may31 = days.first { it.isCurrentMonth && it.date.day == 31 }
|
val may31 = days.first { it.isCurrentMonth && it.date.day == 31 }
|
||||||
@ -100,7 +103,7 @@ class CalendarViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getMonthDays_february2026_28days() {
|
fun getMonthDays_february_2026_28_days() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val days = vm.getMonthDays(2026, 2)
|
val days = vm.getMonthDays(2026, 2)
|
||||||
val febDays = days.filter { it.isCurrentMonth }
|
val febDays = days.filter { it.isCurrentMonth }
|
||||||
@ -108,7 +111,7 @@ class CalendarViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getMonthDays_february2024_29days_leapYear() {
|
fun getMonthDays_february_2024_29_days_leap_year() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val days = vm.getMonthDays(2024, 2)
|
val days = vm.getMonthDays(2024, 2)
|
||||||
val febDays = days.filter { it.isCurrentMonth }
|
val febDays = days.filter { it.isCurrentMonth }
|
||||||
@ -116,7 +119,7 @@ class CalendarViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getMonthDays_todayIsMarked() {
|
fun getMonthDays_today_is_marked() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val days = vm.getMonthDays(2026, 5)
|
val days = vm.getMonthDays(2026, 5)
|
||||||
val todayCell = days.first { it.isToday }
|
val todayCell = days.first { it.isToday }
|
||||||
@ -125,7 +128,7 @@ class CalendarViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getMonthDays_selectedDateIsMarked() {
|
fun getMonthDays_selected_date_is_marked() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val days = vm.getMonthDays(2026, 5)
|
val days = vm.getMonthDays(2026, 5)
|
||||||
val selectedCell = days.first { it.isSelected }
|
val selectedCell = days.first { it.isSelected }
|
||||||
|
|||||||
@ -132,19 +132,11 @@ class CalendarUtilsExtraTest {
|
|||||||
|
|
||||||
// ---- formatLunarDate ----
|
// ---- formatLunarDate ----
|
||||||
|
|
||||||
@Test
|
|
||||||
fun formatLunarDate_startsWithLunarPrefix() {
|
|
||||||
val result = formatLunarDate(LocalDate(2026, 5, 19))
|
|
||||||
assertTrue(result.startsWith("农历"), "Expected to start with '农历', got: $result")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun formatLunarDate_january1_2026_returnsCorrectLunar() {
|
fun formatLunarDate_january1_2026_returnsCorrectLunar() {
|
||||||
// 2026/1/1 公历 -> 2025年农历十一月十二
|
// 2026/1/1 公历 -> 农历十一月十三
|
||||||
val result = formatLunarDate(LocalDate(2026, 1, 1))
|
val result = formatLunarDate(LocalDate(2026, 1, 1))
|
||||||
assertTrue(result.startsWith("农历"), "Expected '农历' prefix, got: $result")
|
assertEquals("农历十一月十三", result)
|
||||||
// 验证不是空字符串
|
|
||||||
assertTrue(result.length > 2, "Lunar date description should contain month and day")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -155,16 +147,16 @@ class CalendarUtilsExtraTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun formatLunarDate_anyDate_containsMonthAndDayNames() {
|
fun formatLunarDate_specificDates_returnsExactLunar() {
|
||||||
// 仅验证格式:农历 + 月 + 日
|
assertEquals("农历正月十三", formatLunarDate(LocalDate(2026, 3, 1)))
|
||||||
for (day in listOf(
|
assertEquals("农历五月十六", formatLunarDate(LocalDate(2026, 6, 30)))
|
||||||
LocalDate(2026, 3, 1),
|
assertEquals("农历十一月十七", formatLunarDate(LocalDate(2026, 12, 25)))
|
||||||
LocalDate(2026, 6, 30),
|
}
|
||||||
LocalDate(2026, 12, 25)
|
|
||||||
)) {
|
@Test
|
||||||
val result = formatLunarDate(day)
|
fun formatLunarDate_leapMonth_returnsLeapPrefix() {
|
||||||
assertTrue(result.startsWith("农历"), "Expected '农历' prefix for $day, got: $result")
|
// 2023年闰二月:2023/3/23 对应农历闰二月初二
|
||||||
assertTrue(result.length >= 5, "Result for $day too short: $result")
|
val result = formatLunarDate(LocalDate(2023, 3, 23))
|
||||||
}
|
assertEquals("农历闰二月初二", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,15 +4,12 @@ android-compileSdk = "37"
|
|||||||
android-minSdk = "24"
|
android-minSdk = "24"
|
||||||
android-targetSdk = "37"
|
android-targetSdk = "37"
|
||||||
androidx-activity = "1.13.0"
|
androidx-activity = "1.13.0"
|
||||||
androidx-appcompat = "1.7.1"
|
|
||||||
androidx-core = "1.18.0"
|
|
||||||
androidx-espresso = "3.7.0"
|
androidx-espresso = "3.7.0"
|
||||||
androidx-lifecycle = "2.10.0"
|
androidx-lifecycle = "2.10.0"
|
||||||
androidx-testExt = "1.3.0"
|
androidx-testExt = "1.3.0"
|
||||||
composeBom = "2025.05.01"
|
composeBom = "2025.05.01"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.3.21"
|
||||||
material3 = "1.10.0-alpha05"
|
|
||||||
kotlinx-datetime = "0.8.0"
|
kotlinx-datetime = "0.8.0"
|
||||||
tyme4kt = "1.4.5"
|
tyme4kt = "1.4.5"
|
||||||
sketch = "4.4.0"
|
sketch = "4.4.0"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user