Compare commits

...

33 Commits

Author SHA1 Message Date
xfy
b1064f95b7 feat(date-recorder): use camera and crop icons for record/crop buttons
- DateRecorder FAB: Icons.Filled.Add -> Icons.Filled.PhotoCamera
  (与 onOpenCamera / '拍照记录' 语义一致)
- PhotoEditor crop toggle: 纯文字 -> Icons.Filled.Crop / CropFree 图标
  (Crop 关闭态, CropFree 开启态, contentDescription 保留无障碍语义)
2026-07-17 16:31:39 +08:00
xfy
81aeb8e894 fix(date-recorder): split handwriting strokes on finger lift
endStroke() was a no-op, so addStrokePoint() kept appending every new
pointer-down to the previous stroke (its only segmentation cue was
points.isEmpty()). A second drag after lift got joined into the first,
and toPath() drew them as a single polyline.

Add isFinished flag to HandStroke and implement real endStroke() that
marks the current stroke finished. withAddedPoint() now starts a new
segment when the previous stroke is null, empty, or finished.
2026-07-17 16:01:44 +08:00
xfy
50da18460f fix(date-recorder): correct photo EXIF orientation and fix capture crash
- PhotoProcessor: read EXIF orientation and rotate bitmap on load so
  camera JPEGs display upright
- CameraScreen: set target rotation so CameraX writes correct EXIF
- CameraScreen: fix RejectedExecutionException by not shutting down the
  capture executor on dispose (CameraX may still post to it after)
- CameraScreen: route capture callback through main handler for thread
  safety before triggering Activity navigation
- CameraScreen: defer camera binding until PreviewView is attached to
  avoid repeated Surface abandon/recreate and camera rebind loops
- CameraScreen: use an overlay (not component swap) for the capturing
  state so the bound ImageCapture is not torn down mid-capture
- deps: add androidx.exifinterface for EXIF reading
2026-07-17 15:36:36 +08:00
xfy
b2942a3ca9 chre: lint CLAUDE.md to AGENTS.md 2026-07-17 13:25:13 +08:00
xfy
2e61fea21b chore: add .zcode to git ginore 2026-07-17 13:24:46 +08:00
xfy
a1c71c2b93 chore: remove useless docs 2026-07-17 13:24:18 +08:00
xfy
8963b6162b feat(date-recorder): photo journal tool with camera, editor, and album grid
新增"日期记录器"工具:相册式记录管理,支持拍照→编辑→记录信息的完整流程。

数据层:
- Room 2.8.4 首个数据库(KSP 2.3.10),DateRecord 实体含标题/备注/拍摄日期/关联日期
- DateRecordConverters 处理 kotlinx-datetime ↔ ISO 字符串
- DateRecorderRepository 封装 DAO + filesDir 照片文件管理

UI 层:
- CameraScreen: CameraX 1.5.3 应用内预览,运行时权限请求,前后摄切换
- PhotoEditorScreen: 纯 Compose 自实现旋转/裁剪/手写三 Tab 编辑器
- DateRecorderScreen: LazyVerticalGrid 相册网格 + 多选 + 6 种排序 + 批量删除
- RecordEditScreen / RecordDetailScreen: 记录信息编辑与详情查看

基础设施:
- CAMERA 权限 + FileProvider(filesDir 存储,免存储权限)
- 5 个 Activity 延续项目 Activity+Intent 滑动转场模式
- 2 个单测:排序逻辑 6 种组合 + 降采样计算
2026-07-17 13:21:45 +08:00
xfy
c665adfeff release: v1.3.0 2026-07-09 17:21:57 +08:00
xfy
afea8bf109 feat: refactor shift pattern with atomic rephase and swipeable calendar
班次设置页重构与体验增强:

数据模型重构:
- phaseBreaks + override 两独立字段 → 原子 RephaseFlip(date, flippedTo, rephaseFrom)
- 解决幽灵重排/撤销误删/断点链不级联三大问题

体验改进:
- 角标颜色按类别配对(onPrimary/onError/onTertiary)
- 迷你月历动态行数(复用 getMonthGridInfo)
- 恢复默认改 Snackbar 撤销(首次引入 SnackbarHost)
- 锚点日期与周期行右侧对齐修复
- 点击年月标题弹 DatePicker 选月份跳转
- HorizontalPager 滑动翻月 + 高度插值平滑过渡
2026-07-09 17:16:45 +08:00
xfy
2a3089bf47 feat(shift): smooth height interpolation for mini calendar swipe
滑动翻月时高度平滑过渡,不再跳变:
- 复用主日历的 interpolatedWeeks 行数插值逻辑
  (currentPageOffsetFraction 在当前页与目标页行数间 lerp)
- 行高由 BoxWithConstraints 宽度推算(格宽=宽/7,aspectRatio(1f) 使行高=格宽)
- HorizontalPager 套 Modifier.height(px).clipToBounds(),跟随手指实时变高
- 迷你月历简化版:无折叠动画,只保留展开态 gridHeightPx = rowH * weeks
2026-07-09 17:14:54 +08:00
xfy
7b1290dd66 feat(shift): swipeable mini calendar via HorizontalPager
迷你月历接入 HorizontalPager,支持左右滑动翻月:
- 复用主日历 START_PAGE/pageToYearMonth/yearMonthToPage 工具
- viewYear/viewMonth 由 pagerState.currentPage 派生
- 箭头按钮改为 animateScrollToPage
- DatePicker 选月后 animateScrollToPage 跳转
- 网格提取为 MonthGrid 单页组件
2026-07-09 17:07:38 +08:00
xfy
1cf950817e feat(shift): tap year-month title to pick date in mini calendar
点击迷你月历顶部"年 月"标题弹出 DatePicker,
选定日期后跳转到对应月份,比连点箭头快。
2026-07-09 16:55:22 +08:00
xfy
62ed8bd09a fix(shift): align anchor date with cycle row and trim top padding
- 顶部内边距过高:Scaffold padding 已含 TopAppBar 间距,
  Column 去掉多余 vertical=8.dp,避免叠加
- 锚点日期右侧不齐:TextButton 自带内边距导致右边缘内缩,
  改为 Text+clickable 与"周期"行纯 Text 结构一致,右侧对齐
2026-07-09 16:46:38 +08:00
xfy
c68944bd55 feat(shift): undoable reset-to-default via Snackbar
恢复默认改为 Snackbar 撤销模式:
- 点恢复默认立即生效 + 显示 5 秒"撤销"Snackbar
- 点撤销恢复之前的 pattern
- 删除 AlertDialog 确认框(更现代,防误操作)
- 首次在项目引入 SnackbarHost
2026-07-09 16:38:57 +08:00
xfy
4b098442c4 fix(shift): badge color contrast and dynamic calendar rows
- 角标文字色按背景类别配对:班=onPrimary,休=onError,起=onTertiary
- 迷你月历复用 getMonthGridInfo 动态算行数(4/5/6),不再固定 6 行
2026-07-09 16:36:36 +08:00
xfy
f8df108bf3 refactor(shift): replace phaseBreaks with atomic RephaseFlip
数据模型重构:把"翻转+重排"从两个独立字段(override+phaseBreak)
改为单一原子结构 RephaseFlip(date, flippedTo, rephaseFrom)。

- 删除 PhaseBreak / phaseBreaks / cycleOffset(UI 未使用 offset!=0)
- kindAt:overrides → rephaseFlip 当天 → 活跃锚点(从 rephaseFrom 取)
- Storage:KEY_BREAKS → KEY_REPHASE,编码 翻转日:值:重排起点
- ShiftCalendarGrid:toggleFlipAndRephase 产出原子记录,撤销按 date 精确匹配
- toggleOverride 加保护:rephaseFlip 翻转日被点时整体移除,防幽灵重排

解决问题:撤销误删独立断点、单独点翻转留幽灵、连续长按断点链不级联。
2026-07-09 16:33:29 +08:00
xfy
d8014b5154 test(shift): add safety-net tests for long-press rephase scenarios
锁定重构前行为:单次长按重排、连续两次长按、撤销。
作为 RephaseFlip 重构的回归基线。
2026-07-09 16:25:23 +08:00
xfy
66a054f2d8 feat(shift): long-press flips and rephases subsequent days
长按语义从"设相位断点"改为"翻转该天并从次日起重排":
- 翻转当天 override + 次日插入 PhaseBreak(重排起点)
- 后续按 cycle 自动顺延,匹配用户直觉
- 再次长按同一天可撤销(移除 override + 关联断点)
- 角标"断"改为"起",文案同步更新
2026-07-09 16:14:41 +08:00
xfy
14a3239b39 feat: shift pattern settings page
个人轮班设置页:独立 Activity + SharedPreferences 持久化 + onResume 立即生效。

数据模型支持:
- 基础周期(锚点 + 班次序列)
- 单日翻转 overrides(调班)
- 相位断点 phaseBreaks(从某天起重排周期相位)

UI:
- FAB 菜单"班次设置"入口
- 基础周期预设(1班1休/2班2休/3班3休/4班4休)+ 锚点 DatePicker
- 迷你月历(点=翻转班休,长按=设/清断点)
- 恢复默认

实现:TDD,6 任务逐个 spec + 代码质量审查。
2026-07-07 09:36:44 +08:00
xfy
7f080f2d90 fix(shift): add color legend and restore manifest trailing newline
- Add legend text (班/休/断点 color meanings) below mini calendar
- Restore trailing newline in AndroidManifest.xml
2026-07-06 19:10:27 +08:00
xfy
32d66f0a42 fix(shift): subscribe shiftPattern to trigger recomposition on refresh 2026-07-06 19:09:26 +08:00
xfy
a5795b16dc feat(shift): add ShiftPatternActivity, Screen, and mini calendar grid 2026-07-06 18:52:16 +08:00
xfy
d528b98cde feat(shift): wire VM factory, onResume reload, settings menu entry 2026-07-06 18:41:07 +08:00
xfy
e08243d0fa test(shift): cover refreshShiftPattern reload after storage change 2026-07-06 18:37:47 +08:00
xfy
ef55ffcf64 feat(shift): inject ShiftPatternStorage into CalendarViewModel 2026-07-06 18:34:12 +08:00
xfy
1f1db17384 fix(shift): persist name field, guard empty cycle, test corrupt-data path 2026-07-06 18:30:40 +08:00
xfy
e4834a52c9 feat(shift): add ShiftPatternStorage for persistence 2026-07-06 18:24:28 +08:00
xfy
eca6c61058 test(shift): cover non-zero cycleOffset and multiple phase breaks; add kindAt KDoc 2026-07-06 18:20:48 +08:00
xfy
778d5dc8be feat(shift): support overrides and phase breaks in ShiftPattern 2026-07-06 18:14:24 +08:00
xfy
5afac5dd04 docs: add shift pattern settings design spec
个人轮班设置页设计:独立 Activity + SharedPreferences + onResume 重读。
数据模型支持单日 override + 相位断点重排,完整覆盖调班场景。
2026-07-06 16:43:03 +08:00
xfy
08a6016479 chore(deps): bump stable dependencies
- composeBom 2026.05.01 -> 2026.06.01
- androidx-lifecycle 2.10.0 -> 2.11.0
- androidx-uiautomator 2.3.0 -> 2.4.0
- tyme4kt 1.4.5 -> 1.5.0
- spotless 8.5.1 -> 8.8.0

Verified via :app:assembleDebug and :core:testDebugUnitTest.
2026-07-06 14:26:48 +08:00
xfy
db31b9eaed chore: add project-level yayacal-release skill 2026-06-18 15:21:48 +08:00
xfy
fae7654452 release: v1.2.0 2026-06-18 15:07:09 +08:00
53 changed files with 5445 additions and 400 deletions

View File

@ -0,0 +1,74 @@
---
name: yayacal-release
description: Use when the user asks to release a new version of the yayacal Android calendar app
---
# YaYa Release
## Overview
One-shot local release workflow for the yayacal Android app. All steps run on the developer machine using `gh` CLI.
## When to Use
- User says something like "发布 1.2.0", "release v1.3.0", "发一个新版本".
- Working in `/Users/issuser/Developer/xfy/yayacal`.
## Release Steps
1. **Confirm version number** with the user if not provided.
2. **Update version numbers**:
- `gradle.properties`: `app.version.base=x.y.z`
- `app/build.gradle.kts`: increment `versionCode` by 1
3. **Update `CHANGELOG.md`**:
- Compare commits since the last tag: `git log <last-tag>..HEAD --pretty=format:"%s"`
- Group into Added / Changed / Fixed
- Insert `## [x.y.z] - YYYY-MM-DD` below `## [Unreleased]`
- Append `[x.y.z]: https://github.com/xfy/yayacal/releases/tag/vx.y.z` at the bottom
4. **Commit** with the exact message:
```bash
git add CHANGELOG.md gradle.properties app/build.gradle.kts
git commit -m "release: vx.y.z"
```
5. **Tag and push** (lightweight tag, matching previous releases):
```bash
git tag vx.y.z
git push origin main --tags
```
6. **Build release APK**:
```bash
./gradlew :app:assembleRelease
```
7. **Create GitHub Release** with the current version's CHANGELOG as body:
```bash
python3 - <<'PY'
import re
content = open('CHANGELOG.md').read()
m = re.search(r'## \[x.y.z\] - .*?\n(.*?)\n## \[', content, re.DOTALL)
open('/tmp/vx.y.z-notes.md', 'w').write(m.group(1).strip())
PY
gh release create vx.y.z \
app/build/outputs/apk/release/app-release.apk \
--title "YaYa vx.y.z" \
--notes-file /tmp/vx.y.z-notes.md
```
## Quick Reference
| Item | Value |
|---|---|
| Version base | `gradle.properties``app.version.base` |
| Version code | `app/build.gradle.kts``versionCode` |
| APK path | `app/build/outputs/apk/release/app-release.apk` |
| Commit message | `release: vx.y.z` |
| Tag format | lightweight `vx.y.z` |
| Release title | `YaYa vx.y.z` |
| Signing | Keep existing debug signing; do **not** add release keystore logic |
## Common Mistakes
- **Wrong commit message**: Do not use `chore(release): ...` or `build: ...`. Use exactly `release: vx.y.z`.
- **Annotated tag**: Previous releases use lightweight tags; do not add `-a`.
- **Empty release body**: Always extract the current version's CHANGELOG section and pass it to `--notes-file`.
- **Releasing from a dirty tree**: Run `git status` first; only release from a clean `main` branch.
- **Forgetting to push the tag**: `git push origin main --tags` is required.

1
.gitignore vendored
View File

@ -25,3 +25,4 @@ logs/
docs/superpowers/*
!docs/superpowers/specs/
.worktrees/
.zcode/

View File

@ -0,0 +1,137 @@
## 目标
修复班次设置页的 6 个问题:
1. 长按撤销误删巧合重合的独立断点
2. 长按产生的 override 与 phaseBreak 解耦,单独点翻转留幽灵重排
3. 连续长按产生断点链,撤销老断点不级联
4. `kindAt` 每格每次重组重算(无 remember 缓存)
5. "恢复默认"无撤销,立即落盘
6. 角标颜色硬编码 `onPrimary`、固定 6 行
根因:问题 1-3 都是"长按产生的 override + phaseBreak 是两个独立数据,但语义上是原子操作"。彻底解法是重构数据模型。
## 总体方案
**引入 `RephaseFlip` 原子结构**,把"翻转某天 + 从次日起重排"绑定成一个不可分割的数据单元。`overrides`(纯单日翻转)和 `phaseBreaks` 两字段保留,但长按操作改为产出 `RephaseFlip` 而非拆成两个独立字段。
### 新数据模型
```kotlin
// 新增:原子化的"翻转并重排"操作记录
data class RephaseFlip(
val date: LocalDate, // 被翻转的天
val flippedTo: ShiftKind, // 翻转后的值
val rephaseFrom: LocalDate // 重排起点(= date + 1)
)
data class ShiftPattern(
val anchorDate: LocalDate,
val cycle: List<ShiftKind>,
val overrides: Map<LocalDate, ShiftKind> = emptyMap(), // 保留:纯单日翻转(点)
val rephaseFlips: List<RephaseFlip> = emptyList(), // 新增:翻转并重排(长按)
val name: String = "默认"
) {
fun kindAt(date: LocalDate): ShiftKind? {
if (cycle.isEmpty()) return null
// 1. 纯单日翻转优先
overrides[date]?.let { return it }
// 2. rephaseFlips 中被翻转的当天
rephaseFlips.find { it.date == date }?.let { return it.flippedTo }
// 3. 找活跃锚点:rephaseFlips 的 rephaseFrom <= 当天 的最大那个;无则基础锚点
val (anchor, offset) = activeAnchor(date)
...
}
}
```
**关键变化**:
- `phaseBreaks` 字段**删除**,替换为 `rephaseFlips`
- `activeAnchor` 改为从 `rephaseFlips``rephaseFrom` 作锚点
- kindAt 优先级:overrides → rephaseFlip 当天 → 活跃锚点 cycle
**撤销语义变干净**:长按 7/10 产生 `RephaseFlip(7/10, OFF, 7/11)`。再次长按 7/10 → 按 `date == 7/10` 精确匹配整个原子记录删除,**不会误删**别的(解决问题 1)。单独点 7/10 翻转时,如果存在 `rephaseFlip.date == 7/10`,提示或阻止(解决问题 2)。
### 存储格式
App 未正式发布(git 历史显示个人项目,SharedPreferences 刚引入)。**不做向后兼容迁移**:
- 存储格式改:`KEY_BREAKS``KEY_REPHASE`,编码 `日期:flippedTo:rephaseFrom`(`1/0:ISO日期`)
- 旧数据(含 `KEY_BREAKS` 的)在 `load()` 时解析 `KEY_REPHASE` 失败返回 null → 回退 `DEFAULT_PATTERN`(load 已有 try/catch)
- 用户唯一数据是默认 2班2休,丢失无感知
### 测试策略(先建安全网)
**Task 0 优先**:在改模型前,先用端到端测试锁定当前所有行为(kindAt 的全部输出),确保重构后这些断言仍然成立。新增针对长按场景(翻转+重排、撤销、连续长按)的测试,先在旧模型上跑过(记录当前行为),再重构后验证不回归。
## 实施步骤(7 个 Task,TDD)
### Task 0: 建立测试安全网(不改产品代码)
在改任何产品代码前,补充测试覆盖当前行为,作为重构的回归基线:
- `ShiftPatternTest`:补充"长按翻转+重排"端到端断言(用 override+phaseBreak 组合模拟当前长按产出,断言后续序列)。包括:单次长按、连续两次长按、撤销场景。
- `ShiftPatternStorageTest`:补充往返测试覆盖 phaseBreak。
- 这些测试在旧模型上**必须先通过**,记录为"重构前行为"。
### Task 1: 重构 ShiftPattern 数据模型(RephaseFlip)
- 新增 `RephaseFlip` data class
- `ShiftPattern` 删除 `phaseBreaks`,加 `rephaseFlips: List<RephaseFlip>`
- 重写 `kindAt`:overrides → rephaseFlip 当天 → 活跃锚点(从 rephaseFlips.rephaseFrom 取)
- 重写 `activeAnchor`:从 `rephaseFlips``rephaseFrom <= date` 的最大
- 删除 `PhaseBreak`
- **所有 Task 0 测试必须通过**(行为不变)
- 补充新测试:rephaseFlip 撤销精确匹配、多个 rephaseFlip 共存
### Task 2: 重构 ShiftPatternStorage 存储格式
- `KEY_BREAKS``KEY_REPHASE`
- save/load 改为序列化 `rephaseFlips`(`日期:flippedTo:rephaseFrom`)
- `parseRephase` 替代 `parseBreaks`
- 往返测试更新
### Task 3: 重写 ShiftCalendarGrid 交互逻辑
- `toggleFlipAndRephase`:产出 `RephaseFlip(date, flippedTo, date+1)`,撤销按 `date` 精确匹配
- `togglePhaseBreak` 删除
- `toggleOverride` 加保护:若该天存在 rephaseFlip,移除整个 rephaseFlip(防止幽灵重排,解决问题 2)
- 角标 `isRephaseStart` 改为检测 `rephaseFlips.any { it.rephaseFrom == date }`
- ShiftDayCell 加 `remember(pattern, date) { pattern.kindAt(date) }` 缓存(解决问题 4)
### Task 4: 修复角标颜色(问题 6)
- 角标文字色按类别:班=`onPrimary`、休=`onError`、起=`onTertiary`
- 对齐 DayCell 风格:9sp、TopEnd、CircleShape 胶囊背景
### Task 5: 修复固定行数(问题 7)
- `ShiftCalendarGrid` 改用 `getMonthGridInfo(viewYear, viewMonth).rows` 动态算行数
- 删除硬编码 `(0 until 6)`,改为 `(0 until rows)`
### Task 6: "恢复默认"加撤销(问题 5)
- 引入 `SnackbarHost`(项目首次使用,需在 Scaffold 加 `snackbarHost` 参数)
- 点恢复默认 → 存旧 pattern 到临时变量 → 显示 Snackbar "已恢复默认,撤销" 5 秒
- 点撤销 → 恢复旧 pattern
- 不再用 AlertDialog 确认(Snackbar 撤销比确认对话框更现代,且防误操作)
### Task 7: 全量验证
- `./gradlew :app:assembleDebug :core:testDebugUnitTest` 通过
- `./gradlew spotlessApply`
- 手动验证清单(模拟器):长按翻转重排、再次长按撤销、连续长按、恢复默认撤销
## 改动文件清单
| 文件 | Task | 改动 |
|------|------|------|
| `core/.../ShiftPattern.kt` | 1 | 删 PhaseBreak,加 RephaseFlip,重写 kindAt |
| `core/.../ShiftPatternStorage.kt` | 2 | 存储格式改 |
| `core/.../ui/ShiftCalendarGrid.kt` | 3,4,5 | 交互逻辑+角标颜色+动态行数 |
| `core/.../ui/ShiftPatternScreen.kt` | 6 | Snackbar 撤销 |
| `core/test/.../ShiftPatternTest.kt` | 0,1 | 安全网+重构后验证 |
| `core/test/.../ShiftPatternStorageTest.kt` | 0,2 | 往返测试更新 |
## 风险
- **kindAt 行为变化**:`rephaseFlips``rephaseFrom` 与旧 `phaseBreaks.date` 在"长按场景"下语义等价(都是次日),但"纯 phaseBreak"(offset!=0)不再支持。当前 UI 不产生 offset!=0 的断点(长按总是 offset=0),所以无实际影响。Task 0 测试会验证。
- **存储不兼容**:旧 `KEY_BREAKS` 数据被忽略,用户重置为默认。已确认可接受(个人项目,数据可重建)。
- **Snackbar 首次引入**:需确认 Material3 Scaffold 的 snackbarHost 用法正确,不破坏现有布局。

View File

@ -7,12 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.3.0] - 2026-07-09
### Added
- 班次设置页:新增独立设置页,支持编辑基础周期(锚点日期 + 预设方案 1班1休/2班2休/3班3休/4班4休
- 班次设置页:迷你月历支持点击翻转班/休(单日)、长按翻转并从次日起重排后续周期。
- 班次设置页:迷你月历支持左右滑动翻月,点击年月标题弹 DatePicker 快速跳转月份。
- 班次设置页:滑动翻月时高度插值平滑过渡,复用主日历的行数插值机制。
- 班次设置页:恢复默认改为 Snackbar 撤销模式5 秒内可恢复。
### Changed
- 班次数据模型重构:`phaseBreaks` + `override` 两独立字段合并为原子结构 `RephaseFlip(date, flippedTo, rephaseFrom)`,解决撤销误删、幽灵重排、断点链不级联问题。
- 个人轮班设置持久化到 SharedPreferences设置返回主界面后 `onResume` 立即生效。
- 迷你月历角标颜色按类别配对(班=onPrimary休=onError重排起点=onTertiary
- 迷你月历动态计算行数4/5/6 行自适应),不再固定 6 行。
- 依赖升级Compose BOM 2026.06.01、Lifecycle 2.11.0、UiAutomator 2.4.0、tyme4kt 1.5.0、Spotless 8.8.0。
### Fixed
- 修正 `:macrobenchmark` 将 Startup Profile 误当作 Baseline Profile 使用的问题。
`updateBaselineProfile` Task 现在会同时复制 `*-baseline-prof.txt``core/src/main/baseline-prof.txt`
以及 `*-startup-prof.txt``core/src/main/baselineProfiles/startup-prof.txt`,使 AOT 编译优化与 DEX layout 优化同时生效。
- 同步更新所有相关文档(`AGENTS.md``README.md``DEVELOPMENT.md``CLAUDE.md`
`macrobenchmark/AGENTS.md``BaselineProfileGenerator.kt`),明确区分 Baseline Profile 与 Startup Profile。
- 班次设置页:修复 `shiftPattern` StateFlow 未被 `collectAsState` 订阅导致设置返回后日历不立即刷新。
- 班次设置页:修复锚点日期 TextButton 内边距导致与周期行右侧不对齐。
- 班次设置页:修复顶部内边距比四周偏大的叠加问题。
## [1.2.0] - 2026-06-18
### Added
- 启动页:新增自定义 `SplashActivity` 与启动主题,集成 `core-splashscreen``reportFullyDrawn`
- 月视图:年月标题支持日期选择器快速跳转。
- 关于页:连续点击版本号 7 次触发「小狗乐园」全屏视频彩蛋。
- 日期图标新增玫瑰节Rose Day与生日皇冠动画。
- 日期检查器:添加数据持久化、空状态提示、恢复默认按钮,以及生产日期/保质期非法日期禁选。
- 图标:新增 API 26+ Adaptive Icon。
- Baseline Profile拆分并同时集成 Baseline Profile 与 Startup Profile 到 `:core`
- Macrobenchmark新增 StartupBenchmark 冷启动耗时测试。
### Changed
- 应用图标:调整小黄鸭占比,优化 Adaptive Icon 前景尺寸。
- 资源整理GIF 目录重命名为 `animations``AnimatedGif` 改为 `AnimatedWebp`WebP 文件列表由 BuildConfig 注入以消除硬编码。
- 构建清理:移除无效 ProGuard 规则、调试日志trace 标记解耦 ViewModel。
- UI 统一日期检查器、FAB 菜单、返回图标等手绘 Canvas 图标替换为 Material Icons。
- 关于页背景图改为 WebP 格式。
- 版本号统一由 `gradle.properties` 管理。
### Fixed
- 启动页:修复 SplashScreen API 在某些设备上的崩溃/黑屏问题。
- 图标:修复 Adaptive Icon 前景加载失败及含背景问题。
- 日期检查器:修复历史负保质期数据导致列表异常,修复右滑删除卡住及 FAB 遮挡底部问题。
## [1.1.0] - 2026-06-02
@ -247,5 +292,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
[1.3.0]: https://github.com/xfy/yayacal/releases/tag/v1.3.0
[1.2.0]: https://github.com/xfy/yayacal/releases/tag/v1.2.0
[1.1.0]: https://github.com/xfy/yayacal/releases/tag/v1.1.0
[1.0.0]: https://github.com/xfy/yayacal/releases/tag/v1.0.0

View File

@ -1,80 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
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
```bash
# Build Android debug APK
./gradlew :app:assembleDebug
# Install Android debug APK to connected device
./gradlew :app:installDebug
# Run core module tests
./gradlew :core:testDebugUnitTest
# Run a single test class
./gradlew :core:testDebugUnitTest --tests "plus.rua.project.ui.CalendarUtilsTest"
```
Gradle configuration cache and build cache are enabled by default (`gradle.properties`).
## Architecture
**Three-module structure:**
- `:core` — all Compose UI, ViewModel, and business logic (`com.android.library`)
- `:app` — thin Android shell (`MainActivity``App()`)
- `:macrobenchmark` — Macrobenchmark module for Baseline Profile / Startup Profile generation
**Calendar UI composition** (all in `plus.rua.project.ui`):
```
CalendarMonthView ← top-level screen (MonthHeader + WeekdayHeader + pager + BottomCard)
├── MonthHeader ← year/month label + ISO week number
├── WeekdayHeader ← fixed "一二三四五六日" row
├── CalendarPager ← HorizontalPager with Int.MAX_VALUE pages (month view)
│ └── CalendarMonthPage ← 6×7 grid of DayCell with collapse animation
│ └── DayCell ← single day circle with selection/today states
├── WeekPager ← HorizontalPager for single-week view (collapsed state)
│ └── DayCell
├── YearGridView ← 4×3 mini-month grid with year navigation (year view)
│ └── MiniMonth ← compact month: title + weekday row + day numbers
└── BottomCard ← drag handle card, drives collapse/expand gestures
```
`ShiftPattern` (in `plus.rua.project`) defines personal shift cycles (WORK/OFF) independent of public holidays. Uses modular arithmetic: `(date - anchorDate) mod cycle.size`.
`CalendarUtils` (in `plus.rua.project.ui`) holds pager constants (`START_PAGE = Int.MAX_VALUE/2`, `COLLAPSE_THRESHOLD = 0.25f`) and page↔date arithmetic (`pageToYearMonth`, `yearMonthToPage`, `pageToWeekMonday`).
**Collapse/expand animation:** `CalendarMonthView` supports month↔week transition via `CalendarViewModel.collapseProgress` (0f=month, 1f=week). `BottomCard` captures vertical drag gestures and calls `viewModel.onDrag()`/`onExpandDrag()`. When progress crosses 50% on release, a spring animation snaps to the nearest state. `CalendarMonthPage` compresses non-selected weeks toward zero height during collapse. When fully collapsed, `WeekPager` replaces `CalendarPager` for efficient single-week paging.
**Pager page mapping:** Both `CalendarPager` and `WeekPager` use `Int.MAX_VALUE` pages centered at `Int.MAX_VALUE / 2`. Page-to-date conversion is arithmetic — no index-based list. `CalendarPager` maps pages to yearMonth; `WeekPager` maps pages to week-Monday dates. Both skip the initial `snapshotFlow` emission (`.drop(1)`) to preserve the "today" selection on first render.
`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` 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
- Kotlin 2.3.21, Compose Multiplatform 1.11.0, Material 3 1.10.0-alpha05
- `kotlinx-datetime` 0.8.0 for all date logic (no java.util.Calendar)
- `tyme4kt` for Chinese traditional calendar (lunar dates, solar terms, festivals)
- `sketch` 4.4.0 for animated GIF display (`AsyncImage` with `sketch-animated-gif`)
- AGP 9.2.1, compileSdk/targetSdk 37, minSdk 24
- JVM target: 17
- R8 full mode enabled (`android.enableR8.fullMode=true`)
## Conventions
- Package: `plus.rua.project` (core), `plus.rua.project.ui` (UI composables)
- 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
- UI text is in Chinese (weekday labels, month header format "2026年5月")
- Public `@Composable` functions require KDoc per `COMMENTS.md`
- `Modifier` parameter always last in composable signatures
- Callback parameters use `on` prefix (`onDateClick`, `onMonthChanged`)
- **Clickable list items:** Use `Card(onClick = ...)` with `CardDefaults.cardElevation(defaultElevation = 0.dp)` instead of `Modifier.clickable()` on `Box`/`Row`. This ensures consistent Material 3 press-state feedback (ripple + background color change) across all interactive list/menu items. See `LicensesScreen`, `ToolsScreen`, and `CalendarMonthView.MenuItem` for reference.

1
CLAUDE.md Symbolic link
View File

@ -0,0 +1 @@
AGENTS.md

View File

@ -1,230 +0,0 @@
# YaYa 注释规范
基于 [KDoc](https://kotlinlang.org/docs/kotlin-doc.html) 规范与 Compose 语义化约定,结合项目实际情况制定。
## 核心原则
1. **注释解释「为什么」,而非「做什么」**——代码本身应能说明做什么,注释补充意图、约束和决策原因
2. **宁可没有注释,也不要废话注释**——当前项目风格是自描述代码 + 极少注释,这是好的基线
3. **公共 API 必须有 KDoc内部实现按需注释**
## 何时必须写注释
### 1. Public Composable 函数——KDoc 必需
所有 `public``@Composable` 函数必须写 KDoc说明用途、参数含义和回调触发时机。
```kotlin
/**
* 月度日历视图,支持周/月切换和折叠动画。
*
* @param selectedDate 当前选中日期
* @param today 今天的日期,用于高亮标记
* @param onDateClick 日期点击回调
* @param collapseProgress 折叠进度0f=展开1f=折叠
* @param modifier 外部布局修饰符
*/
@Composable
fun CalendarMonthPage(
year: Int,
month: Int,
selectedDate: LocalDate,
today: LocalDate,
onDateClick: (LocalDate) -> Unit,
collapseProgress: Float,
modifier: Modifier = Modifier
)
```
**约定:**
- `Modifier` 参数放最后KDoc 中注明"外部布局修饰符"
- 回调参数用 `on` 前缀命名(`onDateClick` 而非 `dateClick`KDoc 说明触发时机
- `Float` 进度/比例参数注明取值范围和含义
### 2. 非显而易见的算法和计算
```kotlin
// 6行×7列=42格覆盖跨月首尾周保证网格完整
return (0 until 42).map { i -> ... }
// 折叠时选中行上方行上移、下方行下移,模拟"挤压"效果
val offsetY = when {
isAboveSelected -> -progress * 200f
isBelowSelected -> progress * 200f
else -> 0f
}
```
### 3. Magic Number
用命名常量 + 注释,而非裸数字:
```kotlin
// 好
val MaxPagerPages = Int.MAX_VALUE // 无限分页,中心页为起始月
val CenterPage = MaxPagerPages / 2
// 坏
HorizontalPager(pageCount = 2147483647) { ... }
```
### 4. Workaround / Hack / 平台限制
必须注明原因和追踪信息:
```kotlin
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
currentMonth = weekMonday.monthNumber
```
### 5. 状态与副作用的业务意图
`remember``LaunchedEffect``DisposableEffect` 等解释业务意图,而非技术实现:
```kotlin
// 仅在首次展开时记录完整日历高度,折叠后不再覆盖
if (!viewModel.isCollapsed && viewModel.collapseProgress < 0.01f) {
expandedCalendarHeightPx = size.height
}
```
### 6. 动画参数与业务状态的映射
```kotlin
// collapseProgress: 0f=月视图(6行), 1f=周视图(1行)
// 折叠偏移量 = 进度 × 展开高度的5/6保留1行可见
val collapseOffsetPx = -(viewModel.collapseProgress * expandedCalendarHeightPx * 5f / 6f).toInt()
```
## 何时不需要注释
### 1. 代码本身已足够清晰
```kotlin
// 坏——废话注释
// 设置水平 padding 为 16dp
modifier = Modifier.padding(horizontal = 16.dp)
// 好——无需注释
modifier = Modifier.padding(horizontal = 16.dp)
```
### 2. 函数名已说明用途
```kotlin
// 坏
// 获取 ISO 周号
fun getIsoWeekNumber(date: LocalDate): Int
// 好——函数名已足够
fun getIsoWeekNumber(date: LocalDate): Int
```
### 3. 简单的属性赋值和数据类
```kotlin
// 不需要注释
data class DayData(
val date: LocalDate,
val isCurrentMonth: Boolean
)
```
## KDoc 格式规范
### Composable 函数模板
```kotlin
/**
* 一句话描述组件用途。
*
* 可选:补充说明重组行为、副作用等。
*
* @param param1 参数说明
* @param param2 参数说明
* @param modifier 外部布局修饰符
*/
@Composable
fun MyComponent(
param1: Type,
param2: Type,
modifier: Modifier = Modifier
) { ... }
```
### ViewModel / 工具函数模板
```kotlin
/**
* 一句话描述功能。
*
* @param input 输入说明
* @return 返回值说明
*/
fun calculateSomething(input: Int): Int { ... }
```
### 文件级注释
仅在文件包含多个紧密关联的组件且关系不直观时使用,大多数文件不需要:
```kotlin
/**
* 日历分页组件,包含月视图和周视图的 HorizontalPager 实现。
*/
```
## 项目特定约定
| 场景 | 规范 |
|------|------|
| `@Suppress("DEPRECATION")` | 必须附带行内注释说明原因(当前主要用于 `monthNumber` |
| Pager 页码映射 | `pageToYearMonth()` / `yearMonthToPage()` 等算术映射需注释公式逻辑 |
| 折叠动画参数 | 注释 `collapseProgress` 的取值范围和物理含义 |
| 尺寸测量px | 注释为何需要 px 而非 dp动画偏移量需要像素精度 |
| `remember` key | 当 key 列表不直观时,注释为何选择这些 key |
| `Int.MAX_VALUE` 分页 | 注释无限分页的设计意图和中心页计算方式 |
## Preview 注释
Preview 函数不需要 KDoc`@Preview` 注解应提供有意义的 `name`
```kotlin
@Preview(name = "Month View - Expanded", showBackground = true)
@Preview(name = "Month View - Collapsed", showBackground = true)
@Composable
private fun CalendarMonthViewPreview() { ... }
```
## 反模式清单
```kotlin
// ❌ 翻译代码
// 遍历每一周
weeks.forEachIndexed { ... }
// ❌ 过时注释
// TODO: 后续优化(已完成但未删除)
// ❌ 注释掉的代码
// val oldImplementation = ...
// ❌ 用 // 替代 KDoc
// 这个函数渲染日历
@Composable fun Calendar() { ... }
// ❌ 无原因的 Suppress
@Suppress("DEPRECATION")
fun foo() { ... }
```
## 检查清单
- [ ] Public Composable 是否有 KDoc
- [ ] KDoc 是否说明了参数含义和回调触发时机?
- [ ] `Modifier` 参数是否在最后?
- [ ] 非显而易见的计算是否有行内注释?
- [ ] Magic Number 是否有命名常量 + 注释?
- [ ] `@Suppress` 是否有原因注释?
- [ ] 动画/进度参数是否注明了取值范围?
- [ ] 是否存在废话注释、过时注释或注释掉的代码?

View File

@ -1,60 +0,0 @@
# 发布流程
## 1. 更新 CHANGELOG.md
按倒序(新版在前)在 `[Unreleased]` 下方添加新版本条目,格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)。
底部添加版本链接:
```
[x.y.z]: https://github.com/DefectingCat/yayacal/releases/tag/vx.y.z
```
## 2. 更新版本号
编辑 `gradle.properties`
- `app.version.base` 改为新版本号(如 `1.2.0`
编辑 `app/build.gradle.kts`
- `versionCode` 递增 `+1`
> `app.version.base` 优先于 `build.gradle.kts` 中的默认值,因此以 `gradle.properties` 为准。
## 3. 构建 Release APK
```bash
./gradlew :app:assembleRelease
```
产物路径:`app/build/outputs/apk/release/app-release.apk`
## 4. 提交、打 Tag、推送
```bash
git add CHANGELOG.md gradle.properties app/build.gradle.kts
git commit -m "release: vx.y.z"
git tag vx.y.z
git push origin main --tags
```
## 5. 创建 GitHub Release
```bash
gh release create vx.y.z \
app/build/outputs/apk/release/app-release.apk \
--title "YaYa vx.y.z" \
--notes-file CHANGELOG.md
```
`--notes-file` 会读取 CHANGELOG.md 全文作为 Release body。
## 检查清单
- [ ] CHANGELOG.md 新版本条目已添加(倒序,新版在前)
- [ ] CHANGELOG.md 底部链接已添加
- [ ] `gradle.properties``app.version.base``app/build.gradle.kts``versionCode` 已更新
- [ ] Release APK 构建成功
- [ ] Git tag 已推送
- [ ] GitHub Release 已创建且包含 APK

View File

@ -27,7 +27,7 @@ android {
applicationId = "plus.rua.project"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 2
versionCode = 4
versionName = appVersionName
}

View File

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 日期记录器:相机拍摄;照片存 filesDirminSdk 24 免存储权限 -->
<uses-permission android:name="android.permission.CAMERA" />
<application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
@ -48,6 +51,42 @@
<activity
android:name=".DateCheckerActivity"
android:exported="false" />
<activity
android:name=".DateRecorderActivity"
android:exported="false" />
<activity
android:name=".CameraActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".PhotoEditorActivity"
android:exported="false" />
<activity
android:name=".RecordEditActivity"
android:exported="false" />
<activity
android:name=".RecordDetailActivity"
android:exported="false" />
<activity
android:name=".ShiftPatternActivity"
android:exported="false" />
<!-- 日期记录器FileProvider用于相机临时文件 URI 共享 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
</manifest>

View File

@ -0,0 +1,36 @@
package plus.rua.project
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import plus.rua.project.ui.CameraScreen
import plus.rua.project.ui.DateRecorderNav
import plus.rua.project.ui.theme.YaYaTheme
/**
* 相机拍摄页 Activity
*
* 拍照成功后把临时照片路径透传给记录编辑页M3 之后会先经过编辑器页
*/
class CameraActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YaYaTheme {
CameraScreen(
onBack = { finishWithSlideBack() },
onPhotoCaptured = { tempPath ->
// 拍照后先进入编辑器,编辑完成后再进入记录编辑页
startActivityWithSlide(
Intent(this, PhotoEditorActivity::class.java).apply {
putExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH, tempPath)
}
)
finishWithSlideBack()
}
)
}
}
}
}

View File

@ -0,0 +1,32 @@
package plus.rua.project
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import plus.rua.project.ui.DateRecorderNav
import plus.rua.project.ui.DateRecorderScreen
import plus.rua.project.ui.theme.YaYaTheme
class DateRecorderActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YaYaTheme {
DateRecorderScreen(
onBack = { finishWithSlideBack() },
onOpenCamera = {
startActivityWithSlide(Intent(this, CameraActivity::class.java))
},
onOpenRecord = { id ->
startActivityWithSlide(
Intent(this, RecordDetailActivity::class.java).apply {
putExtra(DateRecorderNav.EXTRA_RECORD_ID, id)
}
)
}
)
}
}
}
}

View File

@ -21,6 +21,9 @@ class MainActivity : BaseActivity() {
},
onNavigateToTools = {
startActivityWithSlide(Intent(this, ToolsActivity::class.java))
},
onNavigateToShiftSettings = {
startActivityWithSlide(Intent(this, ShiftPatternActivity::class.java))
}
)
}

View File

@ -0,0 +1,40 @@
package plus.rua.project
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import plus.rua.project.ui.DateRecorderNav
import plus.rua.project.ui.PhotoEditorScreen
import plus.rua.project.ui.theme.YaYaTheme
/**
* 照片编辑页 Activity
*
* 接收源照片路径[DateRecorderNav.EXTRA_TEMP_PHOTO_PATH]来自相机或详情页
* 编辑完成后跳转记录编辑页携带最终照片路径
*/
class PhotoEditorActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val sourcePath = intent?.getStringExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH)
requireNotNull(sourcePath) { "PhotoEditorActivity 必须接收 EXTRA_TEMP_PHOTO_PATH" }
setContent {
YaYaTheme {
PhotoEditorScreen(
onBack = { finishWithSlideBack() },
onSaved = { finalPath ->
startActivityWithSlide(
Intent(this, RecordEditActivity::class.java).apply {
putExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH, finalPath)
}
)
finishWithSlideBack()
},
sourcePath = sourcePath
)
}
}
}
}

View File

@ -0,0 +1,47 @@
package plus.rua.project
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import plus.rua.project.ui.DateRecorderNav
import plus.rua.project.ui.RecordDetailScreen
import plus.rua.project.ui.theme.YaYaTheme
/**
* 记录详情页 Activity
*
* 接收记录 ID[DateRecorderNav.EXTRA_RECORD_ID]
* 提供"编辑信息" RecordEditActivity"编辑照片" PhotoEditorActivity入口
*/
class RecordDetailActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val recordId = intent?.getLongExtra(DateRecorderNav.EXTRA_RECORD_ID, -1L)
?.takeIf { it >= 0 }
requireNotNull(recordId) { "RecordDetailActivity 必须接收 EXTRA_RECORD_ID" }
setContent {
YaYaTheme {
RecordDetailScreen(
onBack = { finishWithSlideBack() },
recordId = recordId,
onEditInfo = { id ->
startActivityWithSlide(
Intent(this, RecordEditActivity::class.java).apply {
putExtra(DateRecorderNav.EXTRA_RECORD_ID, id)
}
)
},
onEditPhoto = { photoPath ->
startActivityWithSlide(
Intent(this, PhotoEditorActivity::class.java).apply {
putExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH, photoPath)
}
)
}
)
}
}
}
}

View File

@ -0,0 +1,34 @@
package plus.rua.project
import android.os.Bundle
import androidx.activity.compose.setContent
import plus.rua.project.ui.DateRecorderNav
import plus.rua.project.ui.RecordEditScreen
import plus.rua.project.ui.theme.YaYaTheme
/**
* 记录编辑页 Activity
*
* 两种入口
* - 新建Intent extra [DateRecorderNav.EXTRA_TEMP_PHOTO_PATH] 携带照片路径
* - 编辑Intent extra [DateRecorderNav.EXTRA_RECORD_ID] 携带记录 ID
*/
class RecordEditActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val photoPath = intent?.getStringExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH)
val recordId = intent?.getLongExtra(DateRecorderNav.EXTRA_RECORD_ID, -1L)
?.takeIf { it >= 0 }
setContent {
YaYaTheme {
RecordEditScreen(
onBack = { finishWithSlideBack() },
photoPath = photoPath,
recordId = recordId
)
}
}
}
}

View File

@ -0,0 +1,18 @@
package plus.rua.project
import android.os.Bundle
import androidx.activity.compose.setContent
import plus.rua.project.ui.ShiftPatternScreen
import plus.rua.project.ui.theme.YaYaTheme
class ShiftPatternActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YaYaTheme {
ShiftPatternScreen(onBack = { finishWithSlideBack() })
}
}
}
}

View File

@ -16,6 +16,9 @@ class ToolsActivity : BaseActivity() {
onBack = { finishWithSlideBack() },
onNavigateToDateChecker = {
startActivityWithSlide(Intent(this, DateCheckerActivity::class.java))
},
onNavigateToDateRecorder = {
startActivityWithSlide(Intent(this, DateRecorderActivity::class.java))
}
)
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- 日期记录器:照片存放在 filesDir/Pictures/date_recorder/ -->
<files-path
name="date_recorder"
path="Pictures/date_recorder/" />
</paths>

View File

@ -3,6 +3,12 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.ksp)
alias(libs.plugins.room)
}
room {
schemaDirectory("$projectDir/schemas")
}
android {
@ -89,6 +95,17 @@ dependencies {
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
// 日期记录器Room 持久化 + CameraX 相机
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.camera.core)
implementation(libs.camera.camera2)
implementation(libs.camera.lifecycle)
implementation(libs.camera.view)
implementation(libs.androidx.exifinterface)
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.kotlin.get()}")
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.room.testing)
}

View File

@ -0,0 +1,66 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "a70493440299ed07224c71df1b7b58c3",
"entities": [
{
"tableName": "date_records",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `note` TEXT NOT NULL, `shootDate` TEXT NOT NULL, `linkedDate` TEXT, `photoPath` TEXT NOT NULL, `createdAt` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "note",
"columnName": "note",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shootDate",
"columnName": "shootDate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "linkedDate",
"columnName": "linkedDate",
"affinity": "TEXT"
},
{
"fieldPath": "photoPath",
"columnName": "photoPath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a70493440299ed07224c71df1b7b58c3')"
]
}
}

View File

@ -59,7 +59,8 @@ data class CalendarUiState(
* @param clock 时钟源默认系统时钟测试时可注入固定时钟
*/
class CalendarViewModel(
private val clock: Clock = Clock.System
private val clock: Clock = Clock.System,
private val shiftStorage: ShiftPatternStorage? = null
) : ViewModel() {
private val today: LocalDate = clock.todayIn(TimeZone.currentSystemDefault())
@ -112,17 +113,30 @@ class CalendarViewModel(
/**
* 个人轮班与法定节假日完全独立,不受调休影响
* MVP 默认:2026-05-15 ,2 2 休循环后续接入设置页与持久化
* [shiftStorage] 加载;storage 为空或未存时回退到 [DEFAULT_PATTERN]
*/
private val _shiftPattern = MutableStateFlow<ShiftPattern?>(
ShiftPattern(
private val _shiftPattern = MutableStateFlow(loadShiftPattern())
val shiftPattern: StateFlow<ShiftPattern?> = _shiftPattern.asStateFlow()
private fun loadShiftPattern(): ShiftPattern =
shiftStorage?.load() ?: DEFAULT_PATTERN
/**
* 设置页返回后调用, storage 重新加载,立即生效
*/
fun refreshShiftPattern() {
_shiftPattern.value = loadShiftPattern()
}
fun shiftKindAt(date: LocalDate): ShiftKind? = shiftPattern.value?.kindAt(date)
companion object {
/** 默认轮班:2026-05-15 起,2 班 2 休循环。 */
val DEFAULT_PATTERN = ShiftPattern(
anchorDate = LocalDate(2026, 5, 15),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF)
)
)
val shiftPattern: StateFlow<ShiftPattern?> = _shiftPattern.asStateFlow()
fun shiftKindAt(date: LocalDate): ShiftKind? = shiftPattern.value?.kindAt(date)
}
/**
* 是否在右上角显示法定调休角标默认禁用,此时右上角让位给个人排班

View File

@ -0,0 +1,30 @@
package plus.rua.project
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.LocalDate
import kotlin.time.Instant
/**
* 日期记录器的一条记录
*
* 每条记录对应一张照片及相关的文字信息照片文件路径相对于应用 filesDir
*
* @param id 自增主键新建时传 0
* @param title 标题
* @param note 备注正文
* @param shootDate 拍摄日期
* @param linkedDate 关联到日历的某一天可为空表示不关联
* @param photoPath 照片文件相对路径相对 filesDir
* @param createdAt 记录创建时间
*/
@Entity(tableName = "date_records")
data class DateRecord(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val note: String,
val shootDate: LocalDate,
val linkedDate: LocalDate?,
val photoPath: String,
val createdAt: Instant
)

View File

@ -0,0 +1,31 @@
package plus.rua.project
import androidx.room.TypeConverter
import kotlinx.datetime.LocalDate
import kotlin.time.Instant
/**
* Room kotlinx-datetime 类型转换器
*
* Room 默认不识别 [LocalDate] [Instant]这里统一以 ISO 字符串持久化
* - [LocalDate] "2026-07-17"
* - [Instant] "2026-07-17T08:30:00Z"
*
* ISO 格式可读可排序可跨版本演进无需关心 epoch 精度
*/
class DateRecordConverters {
@TypeConverter
fun fromLocalDate(date: LocalDate?): String? = date?.toString()
@TypeConverter
fun toLocalDate(value: String?): LocalDate? =
value?.takeIf { it.isNotBlank() }?.let { LocalDate.parse(it) }
@TypeConverter
fun fromInstant(instant: Instant?): String? = instant?.toString()
@TypeConverter
fun toInstant(value: String?): Instant? =
value?.takeIf { it.isNotBlank() }?.let { Instant.parse(it) }
}

View File

@ -0,0 +1,57 @@
package plus.rua.project
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
/**
* 日期记录器的数据库访问对象
*
* 所有读操作返回 [Flow]UI 层订阅后自动响应数据库变化
*/
@Dao
interface DateRecordDao {
/**
* ID 查询单条记录的 Flow用于详情页订阅
*
* @param id 记录主键
* @return ID 的记录 Flow不存在时 emit null
*/
@Query("SELECT * FROM date_records WHERE id = :id")
fun getByIdFlow(id: Long): Flow<DateRecord?>
/**
* 查询全部记录默认按拍摄日期降序
*
* 最终排序在 Repository / UI 层根据用户选择重新应用此处仅保证一个稳定默认序
*/
@Query("SELECT * FROM date_records ORDER BY shootDate DESC, id DESC")
fun getAllFlow(): Flow<List<DateRecord>>
/**
* 插入一条新记录
*
* @return 新记录的自增 ID
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(record: DateRecord): Long
/**
* 更新一条已有记录
*/
@Update
suspend fun update(record: DateRecord)
/**
* ID 列表批量删除支持多选删除场景
*
* @param ids 待删除记录的主键列表
* @return 实际删除的行数
*/
@Query("DELETE FROM date_records WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<Long>): Int
}

View File

@ -0,0 +1,42 @@
package plus.rua.project
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
/**
* 日期记录器的 Room 数据库
*
* 应用内单例通过 [fromContext] 获取实例Schema 导出到 `core/schemas/` 以追踪版本演进
*/
@Database(
entities = [DateRecord::class],
version = 1,
exportSchema = true
)
@TypeConverters(DateRecordConverters::class)
abstract class DateRecordDatabase : RoomDatabase() {
abstract fun dateRecordDao(): DateRecordDao
companion object {
@Volatile
private var INSTANCE: DateRecordDatabase? = null
/**
* 获取数据库单例
*
* @param context 任意 Context内部取 applicationContext
*/
fun fromContext(context: Context): DateRecordDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
DateRecordDatabase::class.java,
"date_recorder.db"
).build().also { INSTANCE = it }
}
}
}

View File

@ -0,0 +1,71 @@
package plus.rua.project
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* 记录详情页 UI 状态
*
* @param loading 加载中
* @param record 当前记录不存在时为 null
* @param photoUri 用于 AsyncImage 显示的 URI 字符串
* @param deleted 删除已完成UI 据此触发返回
*/
data class DateRecordDetailUiState(
val loading: Boolean = true,
val record: DateRecord? = null,
val photoUri: String? = null,
val deleted: Boolean = false
)
/**
* 记录详情页 ViewModel订阅单条记录并支持删除
*
* @param repository 数据仓库
* @param recordId 记录 ID
*/
class DateRecordDetailViewModel(
private val repository: DateRecorderRepository,
private val recordId: Long
) : ViewModel() {
private val _uiState = MutableStateFlow(DateRecordDetailUiState())
val uiState: StateFlow<DateRecordDetailUiState> = _uiState.asStateFlow()
init { load() }
private fun load() {
viewModelScope.launch {
val record = repository.observeById(recordId).first()
if (record != null) {
_uiState.value = DateRecordDetailUiState(
loading = false,
record = record,
photoUri = "file://${repository.absoluteFileOf(record.photoPath).absolutePath}"
)
} else {
_uiState.value = DateRecordDetailUiState(loading = false)
}
}
}
/** 返回当前照片绝对路径,供"编辑照片"跳转使用。 */
fun currentPhotoPath(): String? = _uiState.value.record?.let {
repository.absoluteFileOf(it.photoPath).absolutePath
}
/** 删除当前记录及其照片文件。 */
fun delete() {
val record = _uiState.value.record ?: return
viewModelScope.launch {
repository.deleteWithPhoto(record)
_uiState.update { it.copy(deleted = true) }
}
}
}

View File

@ -0,0 +1,113 @@
package plus.rua.project
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import java.io.File
/**
* 日期记录器的数据仓库封装 DAO 访问与照片文件管理
*
* 照片统一存放在 `filesDir/Pictures/date_recorder/`数据库中保存相对路径
* 删除记录时同步删除对应照片文件
*
* @param dao 记录 DAO
* @param filesDir 应用 filesDir照片根目录
*/
class DateRecorderRepository(
private val dao: DateRecordDao,
private val filesDir: File
) {
/** 照片存放的子目录(相对 filesDir */
private val photoDir: File by lazy {
File(filesDir, PHOTO_DIR_NAME).apply { if (!exists()) mkdirs() }
}
/**
* 订阅全部记录UI 层据此渲染相册网格
*/
fun observeAll(): Flow<List<DateRecord>> = dao.getAllFlow()
/**
* 订阅单条记录用于详情页
*
* @param id 记录主键
*/
fun observeById(id: Long): Flow<DateRecord?> = dao.getByIdFlow(id)
/**
* 新增一条记录
*
* @param record 待插入记录id DB 自增
* @return 新记录的 ID
*/
suspend fun insert(record: DateRecord): Long = dao.insert(record)
/**
* 更新一条记录编辑信息/替换照片后调用
*/
suspend fun update(record: DateRecord) = dao.update(record)
/**
* ID 批量删除记录并同步删除对应照片文件
*
* @param records 待删除记录列表需要各自的 photoPath 来清理文件
*/
suspend fun deleteWithPhotos(records: List<DateRecord>) = withContext(Dispatchers.IO) {
records.forEach { deletePhotoFile(it.photoPath) }
dao.deleteByIds(records.map { it.id })
}
/**
* 删除单条记录及其照片文件
*
* @param record 待删除记录需要其 photoPath 来清理文件
*/
suspend fun deleteWithPhoto(record: DateRecord) = withContext(Dispatchers.IO) {
deletePhotoFile(record.photoPath)
dao.deleteByIds(listOf(record.id))
}
/**
* 生成一个新的照片文件不含扩展名调用方写入内容后回传相对路径
*
* @return 照片文件路径相对 filesDir
*/
fun createPhotoFile(): File {
val name = "rec_${System.currentTimeMillis()}"
return File(photoDir, "$name.jpg")
}
/**
* 将照片文件的绝对路径转换为相对 filesDir 的路径用于 DB 持久化
*/
fun relativePathOf(file: File): String = file.absolutePath
.removePrefix(filesDir.absolutePath)
.removePrefix(File.separator)
/**
* DB 中的相对路径转换为绝对路径 File
*/
fun absoluteFileOf(relativePath: String): File = File(filesDir, relativePath)
/**
* 删除指定相对路径的照片文件文件不存在时静默忽略
*/
private fun deletePhotoFile(relativePath: String) {
runCatching { absoluteFileOf(relativePath).delete() }
}
companion object {
const val PHOTO_DIR_NAME = "Pictures/date_recorder"
/**
* Context 构造 Repository内部自建 DAO
*/
fun fromContext(context: Context): DateRecorderRepository {
val db = DateRecordDatabase.fromContext(context)
return DateRecorderRepository(db.dateRecordDao(), context.filesDir)
}
}
}

View File

@ -0,0 +1,186 @@
package plus.rua.project
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* 日期记录器列表的排序方式
*
* @param field 排序字段
* @param ascending 是否升序false 表示降序
*/
data class RecordSortOrder(
val field: RecordSortField,
val ascending: Boolean
) {
companion object {
/** 默认按拍摄日期降序(最新的在前) */
val DEFAULT = RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = false)
}
}
/** 排序字段选项。 */
enum class RecordSortField {
/** 拍摄日期 */
SHOOT_DATE,
/** 关联日期(无关联日期的记录排末尾) */
LINKED_DATE,
/** 记录创建时间 */
CREATED_AT
}
/**
* 日期记录器主界面 UI 状态
*
* @param records 排序后的记录列表
* @param sortOrder 当前排序方式
* @param isLoading 首次加载中
* @param selectionMode 是否处于多选模式
* @param selectedIds 当前选中的记录 ID 集合
*/
data class DateRecorderUiState(
val records: List<DateRecord> = emptyList(),
val sortOrder: RecordSortOrder = RecordSortOrder.DEFAULT,
val isLoading: Boolean = true,
val selectionMode: Boolean = false,
val selectedIds: Set<Long> = emptySet()
) {
/** 是否已选中全部记录(供"全选/取消全选"切换显示) */
val allSelected: Boolean get() = records.isNotEmpty() && selectedIds.size == records.size
}
/**
* 日期记录器列表的 ViewModel
*
* 订阅 [DateRecorderRepository] 的全部记录 Flow并按用户选择的排序方式排序后暴露
*
* @param repository 数据仓库
*/
class DateRecorderViewModel(
private val repository: DateRecorderRepository
) : ViewModel() {
private val _sortOrder = MutableStateFlow(RecordSortOrder.DEFAULT)
val sortOrder: StateFlow<RecordSortOrder> = _sortOrder.asStateFlow()
private val _selectionMode = MutableStateFlow(false)
private val _selectedIds = MutableStateFlow<Set<Long>>(emptySet())
/**
* 聚合的 UI 状态仓库记录 Flow + 排序 Flow + 多选 Flow 合并排序
*/
val uiState: StateFlow<DateRecorderUiState> = run {
kotlinx.coroutines.flow.combine(
repository.observeAll(),
_sortOrder,
_selectionMode,
_selectedIds
) { records, order, selectionMode, selectedIds ->
DateRecorderUiState(
records = sortRecords(records, order),
sortOrder = order,
isLoading = false,
selectionMode = selectionMode,
selectedIds = selectedIds
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = DateRecorderUiState()
)
}
/**
* 切换排序方式
*
* @param order 新的排序方式
*/
fun setSortOrder(order: RecordSortOrder) {
_sortOrder.value = order
}
/**
* 进入/退出多选模式进入时清空已有选择
*/
fun toggleSelectionMode() {
_selectionMode.value = !_selectionMode.value
_selectedIds.value = emptySet()
}
/**
* 切换某条记录的选中状态
*/
fun toggleSelection(id: Long) {
_selectedIds.value = _selectedIds.value.let { current ->
if (id in current) current - id else current + id
}
}
/**
* 全选 / 取消全选
*/
fun toggleSelectAll() {
val records = uiState.value.records
_selectedIds.value = if (uiState.value.allSelected) {
emptySet()
} else {
records.map { it.id }.toSet()
}
}
/**
* 删除当前选中的所有记录含照片文件完成后退出多选模式
*/
fun deleteSelected() {
val selectedIds = _selectedIds.value
if (selectedIds.isEmpty()) return
val toDelete = uiState.value.records.filter { it.id in selectedIds }
viewModelScope.launch {
repository.deleteWithPhotos(toDelete)
_selectionMode.value = false
_selectedIds.value = emptySet()
}
}
/**
* ID 批量删除记录含照片文件
*
* @param records 待删除记录列表
*/
fun deleteRecords(records: List<DateRecord>) {
viewModelScope.launch {
repository.deleteWithPhotos(records)
}
}
private fun sortRecords(
records: List<DateRecord>,
order: RecordSortOrder
): List<DateRecord> = sortDateRecords(records, order)
companion object {
/**
* 按指定方式排序记录列表抽为 companion 静态方法以便单元测试
*/
fun sortDateRecords(
records: List<DateRecord>,
order: RecordSortOrder
): List<DateRecord> {
val comparator: Comparator<DateRecord> = when (order.field) {
RecordSortField.SHOOT_DATE -> compareBy { it.shootDate }
RecordSortField.LINKED_DATE -> compareBy(nullsLast()) { it.linkedDate }
RecordSortField.CREATED_AT -> compareBy { it.createdAt }
}
val ordered = if (order.ascending) comparator else comparator.reversed()
return records.sortedWith(ordered.then(compareBy { it.id }))
}
}
}

View File

@ -0,0 +1,112 @@
package plus.rua.project
import android.graphics.Bitmap
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
/**
* 手写笔触一条由多个采样点组成的路径附带颜色与粗细
*
* @param color 笔触颜色
* @param widthPx 笔触线宽像素
* @param points 采样点序列基于图片显示坐标系
* @param isFinished 该笔触是否已结束手指已抬起下一条笔触必须另起新段
* 不能再追加到 [points] 否则两次手写会被 [toPath] 用一条折线连在一起
*/
data class HandStroke(
val color: Color,
val widthPx: Float,
val points: List<Offset> = emptyList(),
val isFinished: Boolean = false
)
/**
* 照片编辑器状态
*
* 持有原始 Bitmap inSampleSize 降采样后当前旋转角度0/90/180/270
* 裁剪矩形基于旋转后图片坐标系null 表示不裁剪手写笔触列表
*
* @param sourceBitmap 降采样后的源图显示用
* @param sourceAbsolutePath 源图绝对路径保存时回放笔触用原图尺寸
* @param rotationDegrees 累计旋转角度始终为 90 的倍数
* @param cropLeft 裁剪左边界比例 [0,1]null 表示未启用裁剪
* @param cropTop 裁剪上边界比例 [0,1]
* @param cropRight 裁剪右边界比例 [0,1]
* @param cropBottom 裁剪下边界比例 [0,1]
* @param strokes 手写笔触列表显示坐标系
* @param strokeColor 当前笔触颜色
* @param strokeWidthPx 当前笔触线宽
*/
data class PhotoEditorState(
val sourceBitmap: Bitmap,
val sourceAbsolutePath: String,
val rotationDegrees: Int = 0,
val cropLeft: Float? = null,
val cropTop: Float = 0f,
val cropRight: Float? = null,
val cropBottom: Float = 1f,
val strokes: List<HandStroke> = emptyList(),
val strokeColor: Color = Color.Red,
val strokeWidthPx: Float = 8f
) {
/** 当前是否已启用裁剪 */
val cropEnabled: Boolean get() = cropLeft != null && cropRight != null
/** 当前旋转后的 Bitmap不含裁剪/手写),用于显示预览 */
val rotatedBitmap: Bitmap
get() = if (rotationDegrees % 360 == 0) sourceBitmap
else PhotoProcessor.rotate(sourceBitmap, rotationDegrees)
}
/**
* 笔触绘制配置 Canvas drawPath 使用
*/
fun strokeDraw(widthPx: Float) = Stroke(
width = widthPx,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
/**
* [HandStroke] 的采样点转换为 Compose [Path]
*/
fun HandStroke.toPath(): Path {
if (points.isEmpty()) return Path()
val path = Path()
path.moveTo(points[0].x, points[0].y)
for (i in 1 until points.size) {
path.lineTo(points[i].x, points[i].y)
}
return path
}
/**
* 在当前状态下追加一个手写采样点返回新状态
*
* 若上一条笔触不存在为空或已结束[HandStroke.isFinished]则另起新段
* 否则把点追加到上一条笔触的末尾
*/
fun PhotoEditorState.withAddedPoint(offset: Offset): PhotoEditorState {
val last = strokes.lastOrNull()
val newStrokes = if (last == null || last.points.isEmpty() || last.isFinished) {
strokes + HandStroke(strokeColor, strokeWidthPx, listOf(offset))
} else {
strokes.dropLast(1) + last.copy(points = last.points + offset)
}
return copy(strokes = newStrokes)
}
/**
* 结束当前正在绘制的笔触返回新状态若没有进行中的笔触则原样返回
*/
fun PhotoEditorState.withEndedStroke(): PhotoEditorState {
val last = strokes.lastOrNull() ?: return this
if (!last.isFinished && last.points.isNotEmpty()) {
return copy(strokes = strokes.dropLast(1) + last.copy(isFinished = true))
}
return this
}

View File

@ -0,0 +1,166 @@
package plus.rua.project
import android.graphics.Bitmap
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
/**
* 照片编辑器 UI 状态
*
* @param loading 加载源图中
* @param editorState 已加载的编辑状态加载完成前为 null
* @param error 加载失败信息
* @param displayWidth 图片显示区域宽度笔触坐标系基准 UI 回传
* @param displayHeight 图片显示区域高度
* @param saving 保存中
* @param savedPath 保存完成后的最终图片绝对路径UI 据此触发跳转
*/
data class PhotoEditorUiState(
val loading: Boolean = true,
val editorState: PhotoEditorState? = null,
val error: String? = null,
val displayWidth: Float = 0f,
val displayHeight: Float = 0f,
val saving: Boolean = false,
val savedPath: String? = null
)
/**
* 照片编辑器 ViewModel持有旋转/裁剪/手写状态并负责落盘
*
* @param sourcePath 源图绝对路径
*/
class PhotoEditorViewModel(
private val sourcePath: String
) : ViewModel() {
private val _uiState = MutableStateFlow(PhotoEditorUiState())
val uiState: StateFlow<PhotoEditorUiState> = _uiState.asStateFlow()
init { loadSource() }
private fun loadSource() {
viewModelScope.launch(Dispatchers.IO) {
runCatching {
val bmp = PhotoProcessor.loadSampled(sourcePath)
PhotoEditorState(
sourceBitmap = bmp,
sourceAbsolutePath = sourcePath
)
}.onSuccess { state ->
_uiState.value = PhotoEditorUiState(loading = false, editorState = state)
}.onFailure { e ->
_uiState.value = PhotoEditorUiState(
loading = false,
error = "加载图片失败:${e.message}"
)
}
}
}
/**
* 更新显示区域尺寸用于笔触坐标归一化与落盘缩放
*/
fun updateDisplaySize(w: Float, h: Float) {
_uiState.update { it.copy(displayWidth = w, displayHeight = h) }
}
/** 顺时针/逆时针旋转 90° 的倍数。 */
fun rotate(delta: Int) {
update { it.copy(rotationDegrees = it.rotationDegrees + delta) }
}
/** 开启/关闭裁剪。开启时使用默认居中 4:3 裁剪框。 */
fun toggleCrop() {
update {
if (it.cropEnabled) {
it.copy(cropLeft = null, cropRight = null, cropTop = 0f, cropBottom = 1f)
} else {
it.copy(cropLeft = 0.1f, cropTop = 0.1f, cropRight = 0.9f, cropBottom = 0.9f)
}
}
}
/** 更新裁剪框比例。 */
fun updateCrop(left: Float, top: Float, right: Float, bottom: Float) {
update {
it.copy(
cropLeft = left.coerceIn(0f, 1f),
cropTop = top.coerceIn(0f, 1f),
cropRight = right.coerceIn(0f, 1f),
cropBottom = bottom.coerceIn(0f, 1f)
)
}
}
/** 追加一个手写笔触采样点。 */
fun addStrokePoint(offset: Offset) {
update { it.withAddedPoint(offset) }
}
/** 结束当前笔触(抬起手指)。 */
fun endStroke() {
update { it.withEndedStroke() }
}
/** 撤销最近一条笔触。 */
fun undoStroke() {
update { it.copy(strokes = it.strokes.dropLast(1)) }
}
/** 设置笔触颜色。 */
fun setStrokeColor(color: Color) {
update { it.copy(strokeColor = color) }
}
/** 设置笔触线宽。 */
fun setStrokeWidth(widthPx: Float) {
update { it.copy(strokeWidthPx = widthPx) }
}
/** 保存编辑结果到新文件。 */
fun save() {
val state = _uiState.value
val editor = state.editorState ?: return
_uiState.update { it.copy(saving = true) }
viewModelScope.launch(Dispatchers.IO) {
runCatching {
val destFile = File(editor.sourceAbsolutePath).let { src ->
File(src.parentFile, "edited_${System.currentTimeMillis()}.jpg")
}
PhotoProcessor.render(
source = editor.sourceBitmap,
rotationDegrees = editor.rotationDegrees,
cropLeft = editor.cropLeft,
cropTop = editor.cropTop,
cropRight = editor.cropRight,
cropBottom = editor.cropBottom,
strokes = editor.strokes,
displayWidth = state.displayWidth,
displayHeight = state.displayHeight,
destFile = destFile
)
}.onSuccess { path ->
_uiState.update { it.copy(saving = false, savedPath = path) }
}.onFailure { e ->
_uiState.update { it.copy(saving = false, error = "保存失败:${e.message}") }
}
}
}
private inline fun update(block: (PhotoEditorState) -> PhotoEditorState) {
_uiState.update { current ->
current.copy(editorState = current.editorState?.let(block))
}
}
}

View File

@ -0,0 +1,202 @@
package plus.rua.project
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas as AndroidCanvas
import android.graphics.Color as AndroidColor
import android.graphics.Matrix
import android.graphics.Paint
import androidx.compose.ui.graphics.Color
import androidx.exifinterface.media.ExifInterface
import java.io.File
import java.io.FileOutputStream
/**
* 照片处理工具加载旋转裁剪合成手写笔触并落盘
*
* 所有方法均为纯函数不持有状态便于测试与复用
*/
object PhotoProcessor {
/**
* 按目标显示宽度降采样加载 Bitmap避免大图 OOM并应用 EXIF 旋转方向
*
* 相机拍摄的 JPEG 常带有 EXIF orientation 标记 90/180/270
* BitmapFactory.decodeFile 默认忽略它导致显示方向错误
* 这里读取 EXIF 并在加载后旋转到位返回的 Bitmap 即为视觉正向
*
* @param path 图片绝对路径
* @param reqWidth 期望显示宽度像素实际宽度会 >= reqWidth 的最小 2 次幂采样
* @return 降采样并校正方向后的 Bitmap加载失败抛异常
*/
fun loadSampled(path: String, reqWidth: Int = 1080): Bitmap {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, options)
options.inSampleSize = calculateInSampleSize(options.outWidth, reqWidth)
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFile(path, options)
?: error("无法加载图片: $path")
val rotation = readExifRotation(path)
return if (rotation == 0) bitmap else rotate(bitmap, rotation)
}
/**
* 读取 JPEG EXIF orientation 标签返回对应的顺时针旋转角度
* JPEG 或无 EXIF 时返回 0
*/
private fun readExifRotation(path: String): Int {
return runCatching {
val orientation = ExifInterface(path)
.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90
ExifInterface.ORIENTATION_ROTATE_180 -> 180
ExifInterface.ORIENTATION_ROTATE_270 -> 270
else -> 0
}
}.getOrDefault(0)
}
private fun calculateInSampleSize(srcWidth: Int, reqWidth: Int): Int =
calculateInSampleSizePublic(srcWidth, reqWidth)
/**
* 降采样倍数计算对测试可见
*
* 选择使 `srcWidth / sample <= reqWidth * 2` 的最小 2 次幂
* 保证显示清晰度的同时避免大图 OOM
*/
internal fun calculateInSampleSizePublic(srcWidth: Int, reqWidth: Int): Int {
var sample = 1
while (srcWidth / sample > reqWidth * 2) sample *= 2
return sample
}
/**
* 旋转 Bitmap
*
* @param bitmap 源图
* @param degrees 旋转角度正向任意值内部取模 360
* @return 旋转后的新 Bitmap0° 时返回原对象
*/
fun rotate(bitmap: Bitmap, degrees: Int): Bitmap {
val normalized = degrees % 360
if (normalized == 0) return bitmap
val matrix = Matrix().apply { postRotate(normalized.toFloat()) }
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
/**
* 按比例裁剪 Bitmap
*
* @param bitmap 源图
* @param left 左边界比例 [0,1]
* @param top 上边界比例 [0,1]
* @param right 右边界比例 [0,1]
* @param bottom 下边界比例 [0,1]
* @return 裁剪后的新 Bitmap
*/
fun crop(
bitmap: Bitmap,
left: Float,
top: Float,
right: Float,
bottom: Float
): Bitmap {
val x = (left.coerceIn(0f, 1f) * bitmap.width).toInt()
val y = (top.coerceIn(0f, 1f) * bitmap.height).toInt()
val w = ((right - left).coerceIn(0f, 1f) * bitmap.width).toInt()
val h = ((bottom - top).coerceIn(0f, 1f) * bitmap.height).toInt()
return Bitmap.createBitmap(bitmap, x, y, w, h, null, false)
}
/**
* 将编辑结果旋转 + 裁剪 + 手写笔触合成为最终 Bitmap 并写入 [destFile]
*
* 笔触坐标基于 [displayWidth]×[displayHeight] 的显示坐标系
* 内部按 `最终Bitmap尺寸 / 显示尺寸` 缩放后绘制保证落盘精度
*
* @param source Bitmap
* @param rotationDegrees 累计旋转角度
* @param cropLeft 裁剪左比例null 表示不裁剪
* @param cropTop 裁剪上比例
* @param cropRight 裁剪右比例null 表示不裁剪
* @param cropBottom 裁剪下比例
* @param strokes 手写笔触列表
* @param displayWidth 显示区域宽度笔触坐标系基准
* @param displayHeight 显示区域高度笔触坐标系基准
* @param destFile 输出文件
* @return 输出文件绝对路径
*/
fun render(
source: Bitmap,
rotationDegrees: Int,
cropLeft: Float?,
cropTop: Float,
cropRight: Float?,
cropBottom: Float,
strokes: List<HandStroke>,
displayWidth: Float,
displayHeight: Float,
destFile: File
): String {
// 1. 旋转
var result = rotate(source, rotationDegrees)
// 2. 裁剪
if (cropLeft != null && cropRight != null) {
result = crop(result, cropLeft, cropTop, cropRight, cropBottom)
}
// 3. 合成手写笔触
if (strokes.isNotEmpty()) {
result = drawStrokes(result, strokes, displayWidth, displayHeight)
}
// 4. 落盘JPEG 90% 质量)
FileOutputStream(destFile).use { out ->
result.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
return destFile.absolutePath
}
private fun drawStrokes(
bitmap: Bitmap,
strokes: List<HandStroke>,
displayWidth: Float,
displayHeight: Float
): Bitmap {
val mutable = bitmap.copy(Bitmap.Config.ARGB_8888, true)
val canvas = AndroidCanvas(mutable)
// 笔触坐标从显示尺寸映射到 Bitmap 尺寸
val scaleX = bitmap.width / displayWidth
val scaleY = bitmap.height / displayHeight
val paint = Paint().apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
isAntiAlias = true
}
strokes.forEach { stroke ->
if (stroke.points.size < 2) return@forEach
paint.color = stroke.color.toArgb()
paint.strokeWidth = stroke.widthPx * ((scaleX + scaleY) / 2f)
val path = android.graphics.Path()
val first = stroke.points[0]
path.moveTo(first.x * scaleX, first.y * scaleY)
for (i in 1 until stroke.points.size) {
val p = stroke.points[i]
path.lineTo(p.x * scaleX, p.y * scaleY)
}
canvas.drawPath(path, paint)
}
return mutable
}
private fun Color.toArgb(): Int = AndroidColor.argb(
(alpha * 255).toInt(),
(red * 255).toInt(),
(green * 255).toInt(),
(blue * 255).toInt()
)
}

View File

@ -0,0 +1,157 @@
package plus.rua.project
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import java.io.File
import kotlin.time.Clock
import kotlin.time.Instant
/**
* 记录编辑页面 UI 状态
*
* @param loading 加载已有记录中仅编辑模式
* @param title 标题
* @param note 备注
* @param shootDate 拍摄日期
* @param linkedDate 关联日期可空
* @param photoUri 用于 AsyncImage 显示的 URI 字符串file:// 绝对路径或 content://
* @param photoAbsolutePath 照片绝对路径保存时持久化相对路径
* @param canSave 是否可保存标题非空且照片就绪
* @param finished 保存完成UI 据此触发返回
* @param isExistingRecord 是否为编辑已有记录模式
*/
data class RecordEditUiState(
val loading: Boolean = true,
val title: String = "",
val note: String = "",
val shootDate: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
val linkedDate: LocalDate? = null,
val photoUri: String? = null,
val photoAbsolutePath: String? = null,
val canSave: Boolean = false,
val finished: Boolean = false,
val isExistingRecord: Boolean = false
)
/**
* 记录编辑页面 ViewModel处理新建与编辑两种模式
*
* @param repository 数据仓库
* @param photoPath 新建模式下的照片绝对路径来自相机/编辑器编辑模式为 null
* @param recordId 编辑模式下的已有记录 ID新建模式为 null
*/
class RecordEditViewModel(
private val repository: DateRecorderRepository,
private val photoPath: String?,
private val recordId: Long?
) : ViewModel() {
private val _uiState = MutableStateFlow(RecordEditUiState())
val uiState: StateFlow<RecordEditUiState> = _uiState.asStateFlow()
init {
if (recordId != null) {
loadExistingRecord(recordId)
} else {
initNewRecord()
}
}
private fun initNewRecord() {
requireNotNull(photoPath) { "新建模式必须提供 photoPath" }
val file = File(photoPath)
_uiState.value = RecordEditUiState(
loading = false,
photoUri = "file://${file.absolutePath}",
photoAbsolutePath = file.absolutePath,
canSave = false,
isExistingRecord = false
)
}
private fun loadExistingRecord(id: Long) {
viewModelScope.launch {
val record = repository.observeById(id).first()
if (record != null) {
val absFile = repository.absoluteFileOf(record.photoPath)
_uiState.value = RecordEditUiState(
loading = false,
title = record.title,
note = record.note,
shootDate = record.shootDate,
linkedDate = record.linkedDate,
photoUri = "file://${absFile.absolutePath}",
photoAbsolutePath = absFile.absolutePath,
canSave = true,
isExistingRecord = true
)
} else {
// 记录不存在(已被删除),直接结束
_uiState.update { it.copy(loading = false, finished = true) }
}
}
}
fun onTitleChange(value: String) {
_uiState.update { it.copy(title = value, canSave = value.isNotBlank() && it.photoAbsolutePath != null) }
}
fun onNoteChange(value: String) {
_uiState.update { it.copy(note = value) }
}
fun onShootDateChange(date: LocalDate) {
_uiState.update { it.copy(shootDate = date) }
}
fun onLinkedDateChange(date: LocalDate) {
_uiState.update { it.copy(linkedDate = date) }
}
fun onClearLinkedDate() {
_uiState.update { it.copy(linkedDate = null) }
}
fun save() {
val state = _uiState.value
if (!state.canSave || state.photoAbsolutePath == null) return
viewModelScope.launch {
val relPath = repository.relativePathOf(File(state.photoAbsolutePath))
if (state.isExistingRecord && recordId != null) {
repository.update(
DateRecord(
id = recordId,
title = state.title,
note = state.note,
shootDate = state.shootDate,
linkedDate = state.linkedDate,
photoPath = relPath,
createdAt = Instant.fromEpochMilliseconds(System.currentTimeMillis())
)
)
} else {
repository.insert(
DateRecord(
id = 0,
title = state.title,
note = state.note,
shootDate = state.shootDate,
linkedDate = state.linkedDate,
photoPath = relPath,
createdAt = Instant.fromEpochMilliseconds(System.currentTimeMillis())
)
)
}
_uiState.update { it.copy(finished = true) }
}
}
}

View File

@ -8,26 +8,68 @@ import kotlinx.datetime.daysUntil
*/
enum class ShiftKind { WORK, OFF }
/**
* "翻转并重排"的原子记录:翻转 [date] 当天为 [flippedTo],
* 并从 [rephaseFrom] 起重排周期相位(后续按 cycle 重新顺延)
*
* date rephaseFrom 通常相邻(rephaseFrom = date 的次日),
* 作为单一操作的两面绑定存储,撤销时整体删除,不会留下孤立数据
*
* @param date 被翻转的天
* @param flippedTo 翻转后的班次
* @param rephaseFrom 重排起点(含当天),该天起按 cycle[0] 重新循环
*/
data class RephaseFlip(
val date: LocalDate,
val flippedTo: ShiftKind,
val rephaseFrom: LocalDate
)
/**
* 个人轮班周期
*
* 与法定节假日完全独立:周期内某天是 WORK 还是 OFF,只看
* `(date - anchorDate) mod cycle.size` cycle 中的取值,不受任何节假日/调休影响
* 与法定节假日完全独立某天的班/休由以下顺序决定:
* 1. [overrides] 命中该天, override (单日翻转,仅当天);
* 2. 否则若 [rephaseFlips] 中有该天作为翻转日的记录, [RephaseFlip.flippedTo];
* 3. 否则取该天"活跃锚点"(最近的 rephaseFlips.rephaseFrom 或基础 anchorDate)
* 起算的 cycle 索引
*
* @param anchorDate 周期基准日,对应 cycle[0]
* @param anchorDate 基础周期基准日,对应 cycle[0]
* @param cycle 一个周期内的班次序列,例如 [WORK, WORK, OFF, OFF] 表示 "2 班 2 休"
* @param name 方案名,用于后续多套方案场景
* @param overrides 单日翻转映射(仅当天,不影响后续),key 为日期
* @param rephaseFlips 翻转并重排记录列表;每条翻转某天并从次日起重排周期
* @param name 方案名
*/
data class ShiftPattern(
val anchorDate: LocalDate,
val cycle: List<ShiftKind>,
val overrides: Map<LocalDate, ShiftKind> = emptyMap(),
val rephaseFlips: List<RephaseFlip> = emptyList(),
val name: String = "默认"
) {
/**
* 返回 [date] 当天的班次优先级:overrides rephaseFlip 当天 活跃锚点的 cycle 索引
* cycle 为空时返回 null
*/
fun kindAt(date: LocalDate): ShiftKind? {
if (cycle.isEmpty()) return null
val diff = anchorDate.daysUntil(date)
// 1. 单日翻转优先(仅当天)
overrides[date]?.let { return it }
// 2. rephaseFlips 中被翻转的当天
rephaseFlips.find { it.date == date }?.let { return it.flippedTo }
// 3. 找活跃锚点:rephaseFlips 中 rephaseFrom <= 当天 的最大那个;无则用基础锚点
val anchor = activeAnchor(date)
val diff = anchor.daysUntil(date)
val size = cycle.size
val idx = ((diff % size) + size) % size
return cycle[idx]
}
private fun activeAnchor(date: LocalDate): LocalDate {
return rephaseFlips
.filter { it.rephaseFrom <= date }
.maxByOrNull { it.rephaseFrom }
?.rephaseFrom
?: anchorDate
}
}

View File

@ -0,0 +1,91 @@
package plus.rua.project
import android.content.Context
import android.content.SharedPreferences
import kotlinx.datetime.LocalDate
/**
* 个人轮班设置持久化照抄 [DateCheckerStorage] 模式
*
* 编码格式( JSON 依赖):
* - 锚点:ISO 日期串 "2026-07-08"
* - 周期:逗号分隔的 1/0 "1,1,0,0"(1=WORK,0=OFF)
* - overrides:逗号分隔的 "日期:值" , 1=WORK,0=OFF
* - rephaseFlips:逗号分隔的 "翻转日:值:重排起点" 三元组, 1=WORK,0=OFF
*/
class ShiftPatternStorage(private val prefs: SharedPreferences) {
companion object {
private const val KEY_ANCHOR = "shift_anchor"
private const val KEY_CYCLE = "shift_cycle"
private const val KEY_OVERRIDES = "shift_overrides"
private const val KEY_REPHASE = "shift_rephase"
private const val KEY_NAME = "shift_name"
private const val SEP = ","
fun fromContext(context: Context): ShiftPatternStorage =
ShiftPatternStorage(
context.getSharedPreferences("shift_pattern", Context.MODE_PRIVATE)
)
}
fun save(pattern: ShiftPattern) {
val cycleStr = pattern.cycle.joinToString(SEP) { if (it == ShiftKind.WORK) "1" else "0" }
val overridesStr = pattern.overrides.entries
.joinToString(SEP) { "${it.key}:${if (it.value == ShiftKind.WORK) 1 else 0}" }
val rephaseStr = pattern.rephaseFlips
.joinToString(SEP) {
"${it.date}:${if (it.flippedTo == ShiftKind.WORK) 1 else 0}:${it.rephaseFrom}"
}
prefs.edit()
.putString(KEY_ANCHOR, pattern.anchorDate.toString())
.putString(KEY_CYCLE, cycleStr)
.putString(KEY_OVERRIDES, overridesStr)
.putString(KEY_REPHASE, rephaseStr)
.putString(KEY_NAME, pattern.name)
.apply()
}
fun load(): ShiftPattern? {
val anchorStr = prefs.getString(KEY_ANCHOR, null) ?: return null
val cycleStr = prefs.getString(KEY_CYCLE, null) ?: return null
return try {
val anchor = LocalDate.parse(anchorStr)
val cycle = if (cycleStr.isBlank()) {
emptyList()
} else {
cycleStr.split(SEP).map { if (it.trim() == "1") ShiftKind.WORK else ShiftKind.OFF }
}
val overrides = parseOverrides(prefs.getString(KEY_OVERRIDES, null))
val rephaseFlips = parseRephase(prefs.getString(KEY_REPHASE, null))
ShiftPattern(anchor, cycle, overrides, rephaseFlips, name = prefs.getString(KEY_NAME, null) ?: "默认")
} catch (e: Exception) {
null
}
}
private fun parseOverrides(s: String?): Map<LocalDate, ShiftKind> {
if (s.isNullOrBlank()) return emptyMap()
return s.split(SEP).associate { pair ->
val parts = pair.split(":")
LocalDate.parse(parts[0]) to (if (parts[1].trim() == "1") ShiftKind.WORK else ShiftKind.OFF)
}
}
private fun parseRephase(s: String?): List<RephaseFlip> {
if (s.isNullOrBlank()) return emptyList()
return s.split(SEP).map { triple ->
// 格式:翻转日:值:重排起点(ISO 日期不含冒号,split 安全)
val colonIdx = triple.indexOf(':')
val lastColon = triple.lastIndexOf(':')
val date = LocalDate.parse(triple.substring(0, colonIdx))
val flippedTo = if (triple.substring(colonIdx + 1, lastColon).trim() == "1") ShiftKind.WORK else ShiftKind.OFF
val rephaseFrom = LocalDate.parse(triple.substring(lastColon + 1))
RephaseFlip(date, flippedTo, rephaseFrom)
}
}
fun clear() {
prefs.edit().clear().apply()
}
}

View File

@ -93,7 +93,15 @@ import plus.rua.project.composeTraceBeginSection
import plus.rua.project.composeTraceEndSection
import kotlin.math.abs
import kotlin.time.Clock
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import plus.rua.project.ShiftPatternStorage
/**
* 日历主界面包含月/周视图切换折叠动画和年视图转场
@ -107,12 +115,34 @@ import androidx.lifecycle.viewmodel.compose.viewModel
fun CalendarMonthView(
modifier: Modifier = Modifier,
onNavigateToAbout: () -> Unit = {},
onNavigateToTools: () -> Unit = {}
onNavigateToTools: () -> Unit = {},
onNavigateToShiftSettings: () -> Unit = {}
) {
val viewModel = viewModel<CalendarViewModel>()
val context = LocalContext.current.applicationContext
val viewModel: CalendarViewModel = viewModel(
factory = viewModelFactory {
initializer {
CalendarViewModel(
clock = Clock.System,
shiftStorage = ShiftPatternStorage.fromContext(context)
)
}
}
)
// 设置页返回后 onResume 重读 storage,立即刷新班次
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) viewModel.refreshShiftPattern()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) }
val uiState by viewModel.uiState.collectAsState()
val shiftPattern by viewModel.shiftPattern.collectAsState()
val selectedDate = uiState.selectedDate
val currentYear = selectedDate.year
val currentMonth = selectedDate.month.number
@ -241,7 +271,7 @@ fun CalendarMonthView(
viewModel.selectDate(date)
}
}
val shiftKindAt = remember(viewModel) {
val shiftKindAt = remember(viewModel, shiftPattern) {
{ date: LocalDate -> viewModel.shiftKindAt(date) }
}
val onRowHeightMeasured = remember {
@ -427,6 +457,14 @@ fun CalendarMonthView(
viewModel.toggleShowLegalHoliday()
}
)
MenuItem(
text = "班次设置",
selected = false,
onClick = {
isMenuExpanded = false
onNavigateToShiftSettings()
}
)
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
@ -589,6 +627,7 @@ private fun BottomCardArea(
val shouldShow = hasLoaded
val uiState by viewModel.uiState.collectAsState()
val shiftPattern by viewModel.shiftPattern.collectAsState()
val shiftKind = viewModel.shiftKindAt(uiState.selectedDate)
if (shouldShow) {

View File

@ -0,0 +1,369 @@
package plus.rua.project.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cameraswitch
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import java.io.File
import java.util.concurrent.Executors
/**
* 相机拍摄页面使用 CameraX 提供应用内预览与拍照
*
* 进入时请求 CAMERA 权限权限通过后绑定预览用户点击快门将照片写入临时文件
* 然后通过 [onPhotoCaptured] 回调把临时文件路径回传 Activity 跳转编辑器页
*
* @param onBack 取消拍摄返回回调
* @param onPhotoCaptured 拍照成功回调参数为临时照片文件绝对路径
* @param modifier 布局修饰符
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CameraScreen(
onBack: () -> Unit,
onPhotoCaptured: (String) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
var hasCameraPermission by remember {
mutableStateOf(context.checkCameraPermission())
}
var isCapturing by remember { mutableStateOf(false) }
var captureError by remember { mutableStateOf<String?>(null) }
// 运行时权限请求 launcher
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted ->
hasCameraPermission = granted
}
// 进入时若无权限则发起请求
LaunchedEffect(Unit) {
if (!hasCameraPermission) {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
.semantics { testTagsAsResourceId = true }
) {
if (!hasCameraPermission) {
PermissionDeniedContent(onBack = onBack)
} else {
// 关键CameraPreview 必须始终留在组合中,不能被 isCapturing 替换。
// 一旦 isCapturing=true 时移除 CameraPreview其 remember 的 imageCapture、
// LaunchedEffect 绑定都会失效,相机 unbindAll + clearPipeline
// 而此时 takePicture 的异步请求还在排队,导致拍照无法完成(第一下点击失效)。
// loading 指示改为叠加覆盖层。
CameraPreview(
context = context,
lifecycleOwner = lifecycleOwner,
isCapturing = isCapturing,
onBack = onBack,
onCaptured = { path ->
isCapturing = false
onPhotoCaptured(path)
},
onError = { msg ->
isCapturing = false
captureError = msg
},
setCapturing = { isCapturing = it }
)
if (isCapturing) {
// 半透明遮罩 + loading覆盖在预览之上但不移除预览
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = Color.White)
}
}
}
captureError?.let { msg ->
Text(
text = msg,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 160.dp)
)
}
}
}
@Composable
private fun CameraPreview(
context: Context,
lifecycleOwner: LifecycleOwner,
isCapturing: Boolean,
onBack: () -> Unit,
onCaptured: (String) -> Unit,
onError: (String) -> Unit,
setCapturing: (Boolean) -> Unit
) {
val imageCapture = remember {
// 设置目标旋转角度,让 CameraX 写入正确的 EXIF orientation
// 配合 PhotoProcessor 读取 EXIF 后即可得到正向图片。
ImageCapture.Builder()
.setTargetRotation(context.getDisplayRotation())
.build()
}
// 注意:不要在 onDispose 中 shutdown executor。
// CameraX 1.5 的 takePicture 在 onImageSaved 之后内部仍可能向 executor 提交收尾任务,
// 过早 shutdown 会导致 RejectedExecutionException 崩溃。应用进程结束时线程池自然回收。
val executor = remember { Executors.newSingleThreadExecutor() }
// 主线程 Handler用于把拍照回调从 executor 线程切回 UI 线程后再触发跳转/状态更新
val mainHandler = remember { android.os.Handler(android.os.Looper.getMainLooper()) }
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
// 持有 PreviewView 引用:由 AndroidView.factory 创建后暴露给绑定逻辑。
// 不用 remember{PreviewView(context)} 自建 —— AndroidView 需要自己管理 View 生命周期
// attach/detach/Surface 创建),外部 remember 的 View 与 AndroidView 内部生命周期冲突,
// 会导致 Surface 反复 abandon/recreate进而相机反复 bind/unbind。
var previewView by remember { mutableStateOf<PreviewView?>(null) }
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
PreviewView(ctx).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
// 等真正 attach 到窗口、Surface 就绪后再绑定相机
post {
previewView = this
}
}
}
// 不在 update 里重新绑定相机,避免每次重组触发解绑重绑
)
// 镜头切换时重新绑定。
// 注意previewView 首次就绪也会触发(从 null → PreviewView这是预期的首次绑定。
LaunchedEffect(lensFacing, previewView) {
val pv = previewView ?: return@LaunchedEffect
bindCameraUseCases(
context = context,
lifecycleOwner = lifecycleOwner,
previewView = pv,
imageCapture = imageCapture,
lensFacing = lensFacing
)
}
// 顶部返回按钮
IconButton(
onClick = onBack,
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp)
.testTag("camera_back")
) {
Icon(
imageVector = Icons.Filled.ChevronLeft,
contentDescription = "返回",
tint = Color.White
)
}
// 底部控制栏:切换镜头(左)/ 快门(中)
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(bottom = 48.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) {
CameraSelector.LENS_FACING_FRONT
} else {
CameraSelector.LENS_FACING_BACK
}
},
modifier = Modifier.testTag("camera_switch")
) {
Icon(
imageVector = Icons.Filled.Cameraswitch,
contentDescription = "切换镜头",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
// 快门按钮:外圈白色环 + 内圈白色实心。
// 关键拍照中isCapturing=true禁用防止连点导致启动多个 PhotoEditor。
IconButton(
enabled = !isCapturing,
onClick = {
val photoFile = createTempPhotoFile(context)
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
setCapturing(true)
imageCapture.takePicture(
outputOptions,
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
// 拍照回调运行在 executor 线程,必须切回主线程
// 再触发 Activity 跳转与状态更新,避免线程安全问题
mainHandler.post { onCaptured(photoFile.absolutePath) }
}
override fun onError(exception: ImageCaptureException) {
Log.e(TAG, "onError: 拍照失败 code=${exception.imageCaptureError} msg=${exception.message}", exception)
val msg = "拍照失败:${exception.message}"
mainHandler.post { onError(msg) }
}
}
)
},
modifier = Modifier
.size(72.dp)
.background(Color.White.copy(alpha = 0.3f), CircleShape)
.testTag("camera_shutter")
) {
Box(
modifier = Modifier
.size(60.dp)
.background(Color.White, CircleShape)
)
}
}
}
}
@Composable
private fun PermissionDeniedContent(onBack: () -> Unit) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "需要相机权限才能拍照",
color = Color.White,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "请在系统设置中授权后重试",
color = Color.White,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
private fun Context.getDisplayRotation(): Int {
val display = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
display
} else {
@Suppress("DEPRECATION") //getDisplay 在 API30+ 弃用,旧版本必须用此 API
(getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager).defaultDisplay
}
return when (display?.rotation) {
android.view.Surface.ROTATION_90 -> 90
android.view.Surface.ROTATION_180 -> 180
android.view.Surface.ROTATION_270 -> 270
else -> 0
}
}
private const val TAG = "DateRecorder/Camera"
private fun bindCameraUseCases(
context: Context,
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
imageCapture: ImageCapture,
lensFacing: Int
) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
runCatching {
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val selector = CameraSelector.Builder()
.requireLensFacing(lensFacing)
.build()
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
selector,
preview,
imageCapture
)
}
}, ContextCompat.getMainExecutor(context))
}
private fun Context.checkCameraPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
private fun createTempPhotoFile(context: Context): File {
val dir = File(context.filesDir, "Pictures/date_recorder").apply { mkdirs() }
return File(dir, "tmp_${System.currentTimeMillis()}.jpg")
}

View File

@ -0,0 +1,18 @@
package plus.rua.project.ui
/**
* 日期记录器跨 Activity 导航协议
*
* 由于项目采用 Activity + Intent 滑动转场 Compose Navigation各页面之间
* 通过 Intent extra 传递照片文件路径与记录 ID此处集中定义 extra key避免散落硬编码
*/
object DateRecorderNav {
/** 拍照后的临时照片文件绝对路径(相机页 → 编辑器页) */
const val EXTRA_TEMP_PHOTO_PATH = "extra_temp_photo_path"
/** 编辑器输出的最终照片文件绝对路径(编辑器页 → 记录编辑页) */
const val EXTRA_FINAL_PHOTO_PATH = "extra_final_photo_path"
/** 已有记录的 ID详情页 → 编辑器页 / 记录编辑页) */
const val EXTRA_RECORD_ID = "extra_record_id"
}

View File

@ -0,0 +1,388 @@
package plus.rua.project.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Sort
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.github.panpf.sketch.AsyncImage
import plus.rua.project.DateRecorderRepository
import plus.rua.project.DateRecorderViewModel
import plus.rua.project.DateRecord
import plus.rua.project.RecordSortField
import plus.rua.project.RecordSortOrder
/**
* 日期记录器主界面以相册形式展示所有记录
*
* 功能
* - 右下角浮动按钮拍摄新照片创建记录
* - 点击单条记录进入详情页
* - TopAppBar 右侧"排序"菜单6 种排序组合
* - TopAppBar 右侧"多选"按钮进入多选态可批量删除
*
* @param onBack 返回回调
* @param onOpenCamera 打开相机新建记录回调
* @param onOpenRecord 打开指定记录详情回调参数为记录 ID
* @param modifier 布局修饰符
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateRecorderScreen(
onBack: () -> Unit,
onOpenCamera: () -> Unit,
onOpenRecord: (Long) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current.applicationContext
val viewModel: DateRecorderViewModel = viewModel(
factory = viewModelFactory {
initializer { DateRecorderViewModel(DateRecorderRepository.fromContext(context)) }
}
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val repo = remember { DateRecorderRepository.fromContext(context) }
var showSortMenu by remember { mutableStateOf(false) }
var showBatchDeleteDialog by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier.semantics { testTagsAsResourceId = true },
topBar = {
TopAppBar(
title = {
Text(
text = if (uiState.selectionMode) {
"已选 ${uiState.selectedIds.size}"
} else {
"日期记录器"
},
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
)
},
navigationIcon = {
IconButton(onClick = {
if (uiState.selectionMode) viewModel.toggleSelectionMode()
else onBack()
}) {
Icon(
imageVector = if (uiState.selectionMode) Icons.Filled.Close
else Icons.Filled.ChevronLeft,
contentDescription = if (uiState.selectionMode) "退出多选" else "返回"
)
}
},
actions = {
if (uiState.selectionMode) {
IconButton(onClick = viewModel::toggleSelectAll) {
Icon(Icons.Filled.Checklist, contentDescription = "全选")
}
} else if (!uiState.isLoading && uiState.records.isNotEmpty()) {
// 排序菜单
Box {
IconButton(onClick = { showSortMenu = true }) {
Icon(Icons.Filled.Sort, contentDescription = "排序")
}
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
sortMenuItems().forEach { item ->
DropdownMenuItem(
text = { Text(item.label) },
onClick = {
viewModel.setSortOrder(item.order)
showSortMenu = false
},
leadingIcon = if (uiState.sortOrder == item.order) {
{ Icon(Icons.Filled.Check, contentDescription = null) }
} else null
)
}
}
}
IconButton(onClick = viewModel::toggleSelectionMode) {
Icon(Icons.Filled.Checklist, contentDescription = "多选")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
},
bottomBar = {
if (uiState.selectionMode) {
BottomAppBar {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = viewModel::toggleSelectAll) {
Text(if (uiState.allSelected) "取消全选" else "全选")
}
TextButton(
onClick = { showBatchDeleteDialog = true },
enabled = uiState.selectedIds.isNotEmpty()
) {
Icon(
Icons.Filled.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
"删除(${uiState.selectedIds.size})",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
}
},
floatingActionButton = {
if (!uiState.selectionMode) {
FloatingActionButton(
onClick = onOpenCamera,
modifier = Modifier.testTag("date_recorder_fab"),
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(Icons.Filled.PhotoCamera, contentDescription = "拍照记录")
}
}
},
containerColor = MaterialTheme.colorScheme.surface
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
when {
uiState.isLoading -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
uiState.records.isEmpty() -> EmptyState()
else -> RecordGrid(
records = uiState.records,
selectedIds = uiState.selectedIds,
selectionMode = uiState.selectionMode,
photoRoot = repo,
onOpenRecord = onOpenRecord,
onToggleSelection = viewModel::toggleSelection
)
}
}
}
if (showBatchDeleteDialog) {
AlertDialog(
onDismissRequest = { showBatchDeleteDialog = false },
title = { Text("删除记录") },
text = { Text("确定删除选中的 ${uiState.selectedIds.size} 条记录吗?此操作不可撤销。") },
confirmButton = {
TextButton(onClick = {
showBatchDeleteDialog = false
viewModel.deleteSelected()
}) { Text("删除", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showBatchDeleteDialog = false }) { Text("取消") }
}
)
}
}
private data class SortMenuItem(val order: RecordSortOrder, val label: String)
private fun sortMenuItems(): List<SortMenuItem> = listOf(
SortMenuItem(RecordSortOrder(RecordSortField.SHOOT_DATE, false), "拍摄日期 · 新→旧"),
SortMenuItem(RecordSortOrder(RecordSortField.SHOOT_DATE, true), "拍摄日期 · 旧→新"),
SortMenuItem(RecordSortOrder(RecordSortField.LINKED_DATE, false), "关联日期 · 新→旧"),
SortMenuItem(RecordSortOrder(RecordSortField.LINKED_DATE, true), "关联日期 · 旧→新"),
SortMenuItem(RecordSortOrder(RecordSortField.CREATED_AT, false), "创建时间 · 新→旧"),
SortMenuItem(RecordSortOrder(RecordSortField.CREATED_AT, true), "创建时间 · 旧→新")
)
@Composable
private fun RecordGrid(
records: List<DateRecord>,
selectedIds: Set<Long>,
selectionMode: Boolean,
photoRoot: DateRecorderRepository,
onOpenRecord: (Long) -> Unit,
onToggleSelection: (Long) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(records, key = { it.id }) { record ->
RecordCard(
record = record,
photoUri = "file://${photoRoot.absoluteFileOf(record.photoPath).absolutePath}",
isSelected = record.id in selectedIds,
selectionMode = selectionMode,
onClick = {
if (selectionMode) onToggleSelection(record.id)
else onOpenRecord(record.id)
}
)
}
}
}
@Composable
private fun RecordCard(
record: DateRecord,
photoUri: String,
isSelected: Boolean,
selectionMode: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
modifier = modifier.fillMaxWidth()
) {
Box {
Column {
AsyncImage(
uri = photoUri,
contentDescription = record.title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
)
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = record.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1
)
Text(
text = "${record.shootDate}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// 多选态下的复选角标
if (selectionMode) {
SelectionBadge(
isSelected = isSelected,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
}
}
}
}
@Composable
private fun SelectionBadge(isSelected: Boolean, modifier: Modifier = Modifier) {
val bg = if (isSelected) MaterialTheme.colorScheme.primary
else Color.Black.copy(alpha = 0.3f)
Box(
modifier = modifier
.clip(CircleShape)
.background(bg)
.padding(4.dp)
) {
if (isSelected) {
Icon(
Icons.Filled.Check,
contentDescription = "已选中",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(16.dp)
)
}
}
}
@Composable
private fun EmptyState() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "还没有记录",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "点击右下角按钮拍摄第一条记录",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp)
)
}
}

View File

@ -0,0 +1,463 @@
package plus.rua.project.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.Crop
import androidx.compose.material.icons.filled.CropFree
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.RotateLeft
import androidx.compose.material.icons.filled.RotateRight
import androidx.compose.material.icons.filled.Undo
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import plus.rua.project.PhotoEditorState
import plus.rua.project.PhotoEditorViewModel
import plus.rua.project.toPath
/**
* 照片编辑页面提供旋转 / 裁剪 / 手写三种编辑能力
*
* 编辑完成后通过 [onSaved] 回调返回最终照片绝对路径 Activity 跳转记录编辑页
*
* @param onBack 取消编辑返回回调
* @param onSaved 保存成功回调参数为最终照片绝对路径
* @param sourcePath 源照片绝对路径来自相机或详情页
* @param modifier 布局修饰符
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PhotoEditorScreen(
onBack: () -> Unit,
onSaved: (String) -> Unit,
sourcePath: String,
modifier: Modifier = Modifier
) {
val viewModel: PhotoEditorViewModel = viewModel(
factory = viewModelFactory {
initializer { PhotoEditorViewModel(sourcePath) }
}
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
var activeTab by remember { mutableStateOf(EditTab.ROTATE) }
val savedPath = state.savedPath
if (savedPath != null) {
LaunchedEffect(savedPath) { onSaved(savedPath) }
}
Scaffold(
modifier = modifier.semantics { testTagsAsResourceId = true },
topBar = {
TopAppBar(
title = {
Text(
"编辑照片",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ChevronLeft, contentDescription = "返回")
}
},
actions = {
IconButton(
onClick = viewModel::save,
enabled = state.editorState != null && !state.saving,
modifier = Modifier.testTag("editor_save")
) {
Icon(Icons.Filled.Done, contentDescription = "保存")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
},
containerColor = MaterialTheme.colorScheme.surface
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp)
) {
when {
state.loading -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
state.error != null -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error
)
}
state.editorState != null -> {
EditorBody(
state = state.editorState!!,
saving = state.saving,
activeTab = activeTab,
onTabChange = { activeTab = it },
onRotate = viewModel::rotate,
onCropToggle = viewModel::toggleCrop,
onCropChange = viewModel::updateCrop,
onAddPoint = viewModel::addStrokePoint,
onEndStroke = viewModel::endStroke,
onUndoStroke = viewModel::undoStroke,
onStrokeColorChange = viewModel::setStrokeColor,
onDisplaySizeChange = viewModel::updateDisplaySize,
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}
private enum class EditTab { ROTATE, CROP, HANDWRITE }
@Composable
private fun EditorBody(
state: PhotoEditorState,
saving: Boolean,
activeTab: EditTab,
onTabChange: (EditTab) -> Unit,
onRotate: (Int) -> Unit,
onCropToggle: () -> Unit,
onCropChange: (Float, Float, Float, Float) -> Unit,
onAddPoint: (Offset) -> Unit,
onEndStroke: () -> Unit,
onUndoStroke: () -> Unit,
onStrokeColorChange: (Color) -> Unit,
onDisplaySizeChange: (Float, Float) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
EditTab.entries.forEachIndexed { index, tab ->
SegmentedButton(
selected = activeTab == tab,
onClick = { onTabChange(tab) },
shape = SegmentedButtonDefaults.itemShape(index, EditTab.entries.size),
icon = {
Icon(
when (tab) {
EditTab.ROTATE -> Icons.Filled.RotateRight
EditTab.CROP -> Icons.Filled.Crop
EditTab.HANDWRITE -> Icons.Filled.Brush
},
contentDescription = null,
modifier = Modifier.size(18.dp)
)
},
label = {
Text(
when (tab) {
EditTab.ROTATE -> "旋转"
EditTab.CROP -> "裁剪"
EditTab.HANDWRITE -> "手写"
}
)
}
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(top = 16.dp),
contentAlignment = Alignment.Center
) {
EditableImage(
state = state,
mode = activeTab,
onCropChange = onCropChange,
onAddPoint = onAddPoint,
onEndStroke = onEndStroke,
onDisplaySizeChange = onDisplaySizeChange
)
if (saving) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
ToolBar(
mode = activeTab,
state = state,
onRotate = onRotate,
onCropToggle = onCropToggle,
onUndoStroke = onUndoStroke,
onStrokeColorChange = onStrokeColorChange,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
)
}
}
@Composable
private fun EditableImage(
state: PhotoEditorState,
mode: EditTab,
onCropChange: (Float, Float, Float, Float) -> Unit,
onAddPoint: (Offset) -> Unit,
onEndStroke: () -> Unit,
onDisplaySizeChange: (Float, Float) -> Unit
) {
val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap }
// 裁剪框拖动手柄CROP 模式下使用)
var dragHandle by remember { mutableStateOf<CropHandle?>(null) }
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(rotatedBmp.width.toFloat() / rotatedBmp.height.toFloat())
.background(Color.Black)
) {
Image(
bitmap = rotatedBmp.asImageBitmap(),
contentDescription = "编辑中的照片",
modifier = Modifier
.fillMaxSize()
.pointerInput(mode) {
if (mode == EditTab.HANDWRITE) {
detectDragGestures(
onDragStart = { offset -> onAddPoint(offset) },
onDrag = { change, _ ->
change.consume()
onAddPoint(change.position)
},
onDragEnd = onEndStroke,
onDragCancel = onEndStroke
)
}
},
contentScale = ContentScale.Fit
)
// 裁剪框覆盖层
if (mode == EditTab.CROP && state.cropEnabled) {
CropOverlay(
state = state,
dragHandle = dragHandle,
onHandleChange = { dragHandle = it },
onCropChange = onCropChange,
modifier = Modifier.fillMaxSize()
)
}
// 手写笔触覆盖层 + 同步显示尺寸
Canvas(modifier = Modifier.fillMaxSize()) {
onDisplaySizeChange(size.width, size.height)
state.strokes.forEach { stroke ->
drawPath(
path = stroke.toPath(),
color = stroke.color,
style = Stroke(
width = stroke.widthPx,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
}
}
}
}
private enum class CropHandle { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, NONE }
/** 裁剪框四元组,支持解构以简化 onDrag 回传。 */
private data class CropRect(
val left: Float,
val top: Float,
val right: Float,
val bottom: Float
)
@Composable
private fun CropOverlay(
state: PhotoEditorState,
dragHandle: CropHandle?,
onHandleChange: (CropHandle?) -> Unit,
onCropChange: (Float, Float, Float, Float) -> Unit,
modifier: Modifier = Modifier
) {
val left = state.cropLeft ?: 0.1f
val right = state.cropRight ?: 0.9f
Canvas(
modifier = modifier.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
val size = this.size
val ox = offset.x / size.width
val oy = offset.y / size.height
onHandleChange(
when {
ox < 0.2f && oy < 0.2f -> CropHandle.TOP_LEFT
ox > 0.8f && oy < 0.2f -> CropHandle.TOP_RIGHT
ox < 0.2f && oy > 0.8f -> CropHandle.BOTTOM_LEFT
ox > 0.8f && oy > 0.8f -> CropHandle.BOTTOM_RIGHT
else -> CropHandle.NONE
}
)
},
onDrag = { change, drag ->
change.consume()
val size = this.size
val dx = drag.x / size.width
val dy = drag.y / size.height
val (nL, nT, nR, nB) = when (dragHandle) {
CropHandle.TOP_LEFT ->
CropRect(left + dx, state.cropTop + dy, right, state.cropBottom)
CropHandle.TOP_RIGHT ->
CropRect(left, state.cropTop + dy, right + dx, state.cropBottom)
CropHandle.BOTTOM_LEFT ->
CropRect(left + dx, state.cropTop, right, state.cropBottom + dy)
CropHandle.BOTTOM_RIGHT ->
CropRect(left, state.cropTop, right + dx, state.cropBottom + dy)
else -> return@detectDragGestures
}
onCropChange(nL, nT, nR, nB)
},
onDragEnd = { onHandleChange(null) },
onDragCancel = { onHandleChange(null) }
)
}
) {
val w = size.width
val h = size.height
val l = left * w
val t = state.cropTop * h
val r = right * w
val b = state.cropBottom * h
// 四周遮罩
drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(0f, 0f), size = Size(l, h))
drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(r, 0f), size = Size(w - r, h))
drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(l, 0f), size = Size(r - l, t))
drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(l, b), size = Size(r - l, h - b))
// 裁剪框白线
drawRect(
color = Color.White,
topLeft = Offset(l, t),
size = Size(r - l, b - t),
style = Stroke(width = 2f)
)
}
}
@Composable
private fun ToolBar(
mode: EditTab,
state: PhotoEditorState,
onRotate: (Int) -> Unit,
onCropToggle: () -> Unit,
onUndoStroke: () -> Unit,
onStrokeColorChange: (Color) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
when (mode) {
EditTab.ROTATE -> {
IconButton(onClick = { onRotate(-90) }) {
Icon(Icons.Filled.RotateLeft, contentDescription = "左转 90°")
}
IconButton(onClick = { onRotate(90) }) {
Icon(Icons.Filled.RotateRight, contentDescription = "右转 90°")
}
}
EditTab.CROP -> {
FilledIconButton(onClick = onCropToggle) {
Icon(
if (state.cropEnabled) Icons.Filled.CropFree else Icons.Filled.Crop,
contentDescription = if (state.cropEnabled) "关闭裁剪" else "开启裁剪"
)
}
}
EditTab.HANDWRITE -> {
IconButton(onClick = onUndoStroke, enabled = state.strokes.isNotEmpty()) {
Icon(Icons.Filled.Undo, contentDescription = "撤销笔触")
}
listOf(Color.Red, Color.Yellow, Color.White, Color.Black).forEach { c ->
val selected = state.strokeColor == c
Box(
modifier = Modifier
.size(28.dp)
.background(c, RoundedCornerShape(14.dp))
.border(
width = if (selected) 3.dp else 1.dp,
color = if (selected) MaterialTheme.colorScheme.primary else Color.Gray,
shape = RoundedCornerShape(14.dp)
)
.pointerInput(c) {
detectTapGestures { onStrokeColorChange(c) }
}
)
}
}
}
}
}

View File

@ -0,0 +1,221 @@
package plus.rua.project.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.github.panpf.sketch.AsyncImage
import plus.rua.project.DateRecordDetailUiState
import plus.rua.project.DateRecordDetailViewModel
import plus.rua.project.DateRecorderRepository
/**
* 记录详情页面展示单条记录的大图与全部信息并提供编辑/删除入口
*
* @param onBack 返回回调
* @param recordId 记录 ID
* @param onEditInfo 编辑记录信息回调跳转记录编辑页
* @param onEditPhoto 编辑照片回调跳转照片编辑页携带当前照片路径
* @param modifier 布局修饰符
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecordDetailScreen(
onBack: () -> Unit,
recordId: Long,
onEditInfo: (Long) -> Unit,
onEditPhoto: (String) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current.applicationContext
val viewModel: DateRecordDetailViewModel = viewModel(
factory = viewModelFactory {
initializer {
DateRecordDetailViewModel(
DateRecorderRepository.fromContext(context),
recordId
)
}
}
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
var showDeleteDialog by remember { mutableStateOf(false) }
// 删除完成后自动返回
if (state.deleted) {
LaunchedEffect(Unit) { onBack() }
}
Scaffold(
modifier = modifier.semantics { testTagsAsResourceId = true },
topBar = {
TopAppBar(
title = {
Text(
state.record?.title ?: "记录详情",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ChevronLeft, contentDescription = "返回")
}
},
actions = {
val record = state.record
if (record != null) {
IconButton(onClick = { onEditInfo(record.id) }) {
Icon(Icons.Filled.Edit, contentDescription = "编辑信息")
}
IconButton(onClick = {
viewModel.currentPhotoPath()?.let(onEditPhoto)
}) {
Icon(Icons.Filled.Image, contentDescription = "编辑照片")
}
IconButton(onClick = { showDeleteDialog = true }) {
Icon(Icons.Filled.Delete, contentDescription = "删除")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
},
containerColor = MaterialTheme.colorScheme.surface
) { innerPadding ->
when {
state.loading -> Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
state.record == null -> Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text("记录不存在", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> DetailContent(
state = state,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
)
}
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("删除记录") },
text = { Text("确定删除「${state.record?.title}」吗?此操作不可撤销。") },
confirmButton = {
TextButton(onClick = {
showDeleteDialog = false
viewModel.delete()
}) { Text("删除", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) { Text("取消") }
}
)
}
}
@Composable
private fun DetailContent(
state: DateRecordDetailUiState,
modifier: Modifier = Modifier
) {
val record = state.record ?: return
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
AsyncImage(
uri = state.photoUri,
contentDescription = record.title,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
)
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(
text = record.title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
InfoRow(label = "拍摄日期", value = "${record.shootDate}")
InfoRow(label = "关联日期", value = record.linkedDate?.toString() ?: "")
if (record.note.isNotBlank()) {
Text(
text = record.note,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp)
)
}
}
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(text = value)
}
}

View File

@ -0,0 +1,317 @@
package plus.rua.project.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.github.panpf.sketch.AsyncImage
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import plus.rua.project.DateRecorderRepository
import plus.rua.project.RecordEditUiState
import plus.rua.project.RecordEditViewModel
import kotlin.time.Instant
/**
* 记录编辑页面用于新建或修改一条日期记录的信息
*
* 两种入口
* 1. 新建[photoPath] 非空来自相机/编辑器表单初始为空
* 2. 编辑[recordId] 非空来自详情页预填已有记录photoPath 从记录读取
*
* @param onBack 返回回调保存或取消后触发
* @param photoPath 新建模式下的最终照片文件绝对路径编辑模式为 null
* @param recordId 编辑模式下的已有记录 ID新建模式为 null
* @param modifier 布局修饰符
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecordEditScreen(
onBack: () -> Unit,
photoPath: String?,
recordId: Long?,
modifier: Modifier = Modifier
) {
val context = LocalContext.current.applicationContext
val viewModel: RecordEditViewModel = viewModel(
factory = viewModelFactory {
initializer {
RecordEditViewModel(
repository = DateRecorderRepository.fromContext(context),
photoPath = photoPath,
recordId = recordId
)
}
}
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
if (state.finished) {
LaunchedEffect(Unit) { onBack() }
}
Scaffold(
modifier = modifier.semantics { testTagsAsResourceId = true },
topBar = {
TopAppBar(
title = {
Text(
if (recordId != null) "编辑记录" else "新建记录",
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.SemiBold
)
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Filled.ChevronLeft,
contentDescription = "返回"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
},
containerColor = MaterialTheme.colorScheme.surface
) { innerPadding ->
if (state.loading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
RecordEditForm(
state = state,
onTitleChange = viewModel::onTitleChange,
onNoteChange = viewModel::onNoteChange,
onShootDateChange = viewModel::onShootDateChange,
onLinkedDateChange = viewModel::onLinkedDateChange,
onClearLinkedDate = viewModel::onClearLinkedDate,
onSave = viewModel::save,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RecordEditForm(
state: RecordEditUiState,
onTitleChange: (String) -> Unit,
onNoteChange: (String) -> Unit,
onShootDateChange: (LocalDate) -> Unit,
onLinkedDateChange: (LocalDate) -> Unit,
onClearLinkedDate: () -> Unit,
onSave: () -> Unit,
modifier: Modifier = Modifier
) {
var showShootDatePicker by remember { mutableStateOf(false) }
var showLinkedDatePicker by remember { mutableStateOf(false) }
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 照片预览
state.photoUri?.let { uri ->
AsyncImage(
uri = uri,
contentDescription = "记录照片",
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(12.dp))
.testTag("record_edit_photo")
)
}
// 标题
OutlinedTextField(
value = state.title,
onValueChange = onTitleChange,
label = { Text("标题") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag("record_edit_title")
)
// 备注
OutlinedTextField(
value = state.note,
onValueChange = onNoteChange,
label = { Text("备注") },
minLines = 3,
modifier = Modifier
.fillMaxWidth()
.testTag("record_edit_note")
)
// 拍摄日期
DatePickerField(
label = "拍摄日期",
date = state.shootDate,
onClick = { showShootDatePicker = true },
modifier = Modifier.testTag("record_edit_shoot_date")
)
// 关联日期(可空)
DatePickerField(
label = "关联日期",
date = state.linkedDate,
placeholder = "不关联",
onClick = { showLinkedDatePicker = true },
onClear = onClearLinkedDate,
modifier = Modifier.testTag("record_edit_linked_date")
)
// 保存按钮
Button(
onClick = onSave,
enabled = state.canSave,
modifier = Modifier
.fillMaxWidth()
.testTag("record_edit_save")
) {
Text("保存")
}
}
if (showShootDatePicker) {
DatePickerModal(
initialDate = state.shootDate,
onConfirm = {
onShootDateChange(it)
showShootDatePicker = false
},
onDismiss = { showShootDatePicker = false }
)
}
if (showLinkedDatePicker) {
DatePickerModal(
initialDate = state.linkedDate ?: state.shootDate,
onConfirm = {
onLinkedDateChange(it)
showLinkedDatePicker = false
},
onDismiss = { showLinkedDatePicker = false }
)
}
}
@Composable
private fun DatePickerField(
label: String,
date: LocalDate?,
placeholder: String = "",
onClick: () -> Unit,
onClear: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
OutlinedButton(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
modifier = modifier.fillMaxWidth()
) {
Text(
text = date?.let { formatLocalDate(it) } ?: placeholder,
modifier = Modifier.fillMaxWidth()
)
if (onClear != null && date != null) {
TextButton(onClick = onClear) { Text("清除") }
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DatePickerModal(
initialDate: LocalDate,
onConfirm: (LocalDate) -> Unit,
onDismiss: () -> Unit
) {
val initialMillis = initialDate.atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds()
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
datePickerState.selectedDateMillis?.let { millis ->
onConfirm(
Instant.fromEpochMilliseconds(millis)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
)
}
}
) { Text("确定") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("取消") }
}
) {
DatePicker(state = datePickerState)
}
}
private fun formatLocalDate(date: LocalDate): String {
return "${date.year}-${date.month.number.toString().padStart(2, '0')}-${date.day.toString().padStart(2, '0')}"
}

View File

@ -0,0 +1,384 @@
package plus.rua.project.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.todayIn
import kotlin.math.abs
import kotlin.time.Clock
import kotlin.time.Instant
import plus.rua.project.RephaseFlip
import plus.rua.project.ShiftKind
import plus.rua.project.ShiftPattern
/**
* 班次设置页用的迷你月历点某天翻转班/(仅当天),长按翻转并从次日起重排周期
*
* 月份可前后翻页,每格显示日期数字与班次角标(//)
* 点击翻转该天的班/ override;长按翻转该天并在次日插入重排起点,
* 后续按 cycle 重新顺延再次长按同一天可撤销
*
* @param pattern 当前轮班配置(只读), [onPatternChange] 修改
* @param onPatternChange 修改后的新 pattern 回调
* @param modifier 外部布局修饰符
*/
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ShiftCalendarGrid(
pattern: ShiftPattern,
onPatternChange: (ShiftPattern) -> Unit,
modifier: Modifier = Modifier
) {
val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) }
val initialYear = remember { today.year }
val initialMonth = remember { today.month.number }
var showMonthPicker by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
// Pager 居中无限页,与主日历一致;viewYear/viewMonth 由当前页派生
val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { Int.MAX_VALUE })
val viewYear by remember {
derivedStateOf { pageToYearMonth(pagerState.currentPage, initialYear, initialMonth).first }
}
val viewMonth by remember {
derivedStateOf { pageToYearMonth(pagerState.currentPage, initialYear, initialMonth).second }
}
val density = LocalDensity.current
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow)
) {
Column(Modifier.padding(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) }
}) {
Icon(Icons.Filled.ChevronLeft, contentDescription = "上个月")
}
Text(
text = "${viewYear}${viewMonth}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.clickable { showMonthPicker = true }
)
IconButton(onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
}) {
Icon(Icons.Filled.ChevronRight, contentDescription = "下个月")
}
}
val weekTitles = listOf("", "", "", "", "", "", "")
Row(Modifier.fillMaxWidth()) {
weekTitles.forEach { w ->
Text(
text = w,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
// 用 BoxWithConstraints 拿宽度,推算行高(格宽=宽/7,aspectRatio(1f) 使行高=格宽)
BoxWithConstraints(Modifier.fillMaxWidth()) {
val cellWidthPx = constraints.maxWidth / 7
val rowHeightPx = cellWidthPx
// 行数插值:滑动时在当前页与目标页行数间线性过渡,避免高度跳变
val interpolatedWeeks by remember {
derivedStateOf {
val fraction = pagerState.currentPageOffsetFraction
if (abs(fraction) > OFFSET_FRACTION_THRESHOLD) {
val cp = pagerState.currentPage
val baseWeeks = calculateWeeksCountForPage(cp, today)
val targetPage = cp + if (fraction > 0) 1 else -1
val targetWeeks = calculateWeeksCountForPage(targetPage, today)
lerp(baseWeeks.toFloat(), targetWeeks.toFloat(), abs(fraction))
} else {
calculateWeeksCountForPage(pagerState.currentPage, today).toFloat()
}
}
}
val gridHeightPx = (rowHeightPx * interpolatedWeeks).toInt()
HorizontalPager(
state = pagerState,
beyondViewportPageCount = 0,
modifier = Modifier
.fillMaxWidth()
.height(with(density) { gridHeightPx.toDp() })
.clipToBounds()
) { page ->
val (year, month) = pageToYearMonth(page, initialYear, initialMonth)
MonthGrid(
year = year,
month = month,
pattern = pattern,
onPatternChange = onPatternChange,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
if (showMonthPicker) {
val initialDate = LocalDate(viewYear, Month(viewMonth), 1)
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = initialDate.toEpochMillis()
)
DatePickerDialog(
onDismissRequest = { showMonthPicker = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis?.let { millis ->
val picked = millis.toLocalDate()
val targetPage = yearMonthToPage(
picked.year, picked.month.number, initialYear, initialMonth
)
coroutineScope.launch { pagerState.animateScrollToPage(targetPage) }
}
showMonthPicker = false
}) { Text("确定") }
},
dismissButton = {
TextButton(onClick = { showMonthPicker = false }) { Text("取消") }
}
) {
DatePicker(state = datePickerState)
}
}
}
/**
* 迷你月历的单页网格复用主日历 [getMonthGridInfo] 计算行数与偏移
*
* @param year
* @param month (1-12)
* @param pattern 当前轮班配置(只读)
* @param onPatternChange 修改后的新 pattern 回调
* @param modifier 外部布局修饰符
*/
@Composable
private fun MonthGrid(
year: Int,
month: Int,
pattern: ShiftPattern,
onPatternChange: (ShiftPattern) -> Unit,
modifier: Modifier = Modifier
) {
val gridInfo = remember(year, month) { getMonthGridInfo(year, month) }
Column(modifier) {
(0 until gridInfo.rows).forEach { row ->
Row(Modifier.fillMaxWidth()) {
(0 until 7).forEach { col ->
val cellIndex = row * 7 + col
val dayNum = cellIndex - gridInfo.offset + 1
if (dayNum in 1..gridInfo.daysInMonth) {
val date = LocalDate(year, Month(month), dayNum)
ShiftDayCell(
date = date,
pattern = pattern,
onClick = { onPatternChange(toggleOverride(pattern, date)) },
onLongClick = { onPatternChange(toggleFlipAndRephase(pattern, date)) },
modifier = Modifier.weight(1f)
)
} else {
Box(Modifier.weight(1f).aspectRatio(1f))
}
}
}
}
}
}
/**
* 翻转某天的班/(单日 override)翻转后若与基础周期值一致,则移除 override
*
* 若该天存在 rephaseFlip(长按产生的翻转并重排),则移除整个 rephaseFlip,
* 避免留下孤立的"幽灵重排"(只清翻转但保留后续相位重排)
*/
private fun toggleOverride(pattern: ShiftPattern, date: LocalDate): ShiftPattern {
val existingFlip = pattern.rephaseFlips.find { it.date == date }
if (existingFlip != null) {
// 该天是 rephaseFlip 的翻转日:整体移除,避免幽灵重排
return pattern.copy(rephaseFlips = pattern.rephaseFlips - existingFlip)
}
val current = pattern.kindAt(date) ?: return pattern
val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK
val tempPattern = pattern.copy(overrides = pattern.overrides - date)
val base = tempPattern.kindAt(date)
val newOverrides = if (newVal == base) pattern.overrides - date
else pattern.overrides + (date to newVal)
return pattern.copy(overrides = newOverrides)
}
/**
* 翻转某天的班/,并从次日起重排周期(后续按 cycle 重新顺延)
*
* 产出原子记录 [RephaseFlip]:翻转该天 + 从次日([rephaseFrom])起重排
* 次日成为新的 cycle[0],后续自动按周期顺延
*
* 撤销:再次长按同一天, [RephaseFlip.date] 精确匹配并整体移除(不会误删其他记录)
*
* @param date 被翻转并作为重排起点的日期
*/
private fun toggleFlipAndRephase(pattern: ShiftPattern, date: LocalDate): ShiftPattern {
val existing = pattern.rephaseFlips.find { it.date == date }
if (existing != null) {
// 撤销:整体移除该 rephaseFlip(原子删除)
return pattern.copy(rephaseFlips = pattern.rephaseFlips - existing)
}
// 翻转该天 + 次日重排
val current = pattern.kindAt(date) ?: return pattern
val newVal = if (current == ShiftKind.WORK) ShiftKind.OFF else ShiftKind.WORK
val rephaseFrom = date.plus(DatePeriod(days = 1))
return pattern.copy(
overrides = pattern.overrides - date, // 该天改由 rephaseFlip 管辖,移除可能的旧 override
rephaseFlips = pattern.rephaseFlips + RephaseFlip(date, newVal, rephaseFrom)
)
}
/**
* 迷你月历的单日格子显示日期数字与班次角标
*
* @param date 该格对应的日期
* @param pattern 当前轮班配置(只读)
* @param onClick 点击回调(翻转班/)
* @param onLongClick 长按回调(/清相位断点)
* @param modifier 外部布局修饰符
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ShiftDayCell(
date: LocalDate,
pattern: ShiftPattern,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
val kind = pattern.kindAt(date)
// rephaseFlip 的 rephaseFrom = 当天,表示该天为重排起点
val isRephaseStart = pattern.rephaseFlips.any { it.rephaseFrom == date }
// 背景色与文字色配对,保证对比度(对照 DayCell 的配色风格)
val badgeColor = when {
isRephaseStart -> MaterialTheme.colorScheme.tertiary
kind == ShiftKind.WORK -> MaterialTheme.colorScheme.primary
kind == ShiftKind.OFF -> MaterialTheme.colorScheme.error
else -> Color.Transparent
}
val badgeTextColor = when {
isRephaseStart -> MaterialTheme.colorScheme.onTertiary
kind == ShiftKind.WORK -> MaterialTheme.colorScheme.onPrimary
kind == ShiftKind.OFF -> MaterialTheme.colorScheme.onError
else -> Color.Transparent
}
val badgeText = when {
isRephaseStart -> ""
kind == ShiftKind.WORK -> ""
kind == ShiftKind.OFF -> ""
else -> ""
}
Box(
modifier = modifier
.aspectRatio(1f)
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
contentAlignment = Alignment.Center
) {
Text(
text = date.day.toString(),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface
)
if (badgeText.isNotEmpty()) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 2.dp, end = 2.dp)
.background(badgeColor, CircleShape)
.padding(horizontal = 3.dp, vertical = 1.dp)
) {
Text(
text = badgeText,
color = badgeTextColor,
fontSize = 8.sp,
fontWeight = FontWeight.Bold,
lineHeight = 8.sp
)
}
}
}
}
private fun LocalDate.toEpochMillis(): Long =
this.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
private fun Long.toLocalDate(): LocalDate =
Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.UTC).date

View File

@ -0,0 +1,227 @@
package plus.rua.project.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.coroutines.launch
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toLocalDateTime
import plus.rua.project.CalendarViewModel
import plus.rua.project.ShiftKind
import plus.rua.project.ShiftPattern
import plus.rua.project.ShiftPatternStorage
import kotlin.time.Instant
/**
* 班次设置页照抄 DateCheckerScreen storage 创建 + 自动存盘模式
*
* 用户在页面内修改 [LaunchedEffect] 自动存 storage 返回主界面后
* CalendarViewModel.onResume 重读 日历立即刷新
*
* 页面结构三段:
* 1. 基础周期:锚点日期当前周期展示;
* 2. 预设方案:1班1休 / 2班2休 / 3班3休 / 4班4休 快捷切换;
* 3. 调班设置:迷你月历(=翻转班/,长按=翻转并从次日起重排)+ 恢复默认
*
* @param onBack 返回回调( Activity 触发 finishWithSlideBack)
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ShiftPatternScreen(onBack: () -> Unit) {
val context = LocalContext.current.applicationContext
val storage = remember { ShiftPatternStorage.fromContext(context) }
val saved = remember { storage.load() }
var pattern by remember { mutableStateOf(saved ?: CalendarViewModel.DEFAULT_PATTERN) }
LaunchedEffect(pattern) { storage.save(pattern) }
var showAnchorPicker by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text("班次设置") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ChevronLeft, contentDescription = "返回")
}
}
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("基础周期", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow)
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("锚点日期", style = MaterialTheme.typography.bodyMedium)
Text(
text = pattern.anchorDate.toString(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { showAnchorPicker = true }
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("周期", style = MaterialTheme.typography.bodyMedium)
Text(
pattern.cycle.joinToString(" ") { if (it == ShiftKind.WORK) "" else "" },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
}
}
}
Text("预设方案", style = MaterialTheme.typography.labelLarge)
val presets = listOf(
"1班1休" to listOf(ShiftKind.WORK, ShiftKind.OFF),
"2班2休" to listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
"3班3休" to listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF, ShiftKind.OFF),
"4班4休" to listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF, ShiftKind.OFF, ShiftKind.OFF)
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
presets.forEach { (label, cycle) ->
FilterChip(
selected = pattern.cycle == cycle,
onClick = {
pattern = pattern.copy(
cycle = cycle,
overrides = emptyMap(),
rephaseFlips = emptyList()
)
},
label = { Text(label) }
)
}
}
Text("调班设置", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
ShiftCalendarGrid(pattern = pattern, onPatternChange = { pattern = it })
Text(
text = "点某天 = 翻转班/休(仅当天),长按某天 = 翻转并从次日起重排",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "图例:班(蓝)/休(红)/重排起点(琥珀)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedButton(
onClick = {
val previous = pattern
pattern = CalendarViewModel.DEFAULT_PATTERN
scope.launch {
val result = snackbarHostState.showSnackbar(
message = "已恢复默认设置",
actionLabel = "撤销",
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
pattern = previous
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("恢复默认")
}
}
}
if (showAnchorPicker) {
val state = rememberDatePickerState(
initialSelectedDateMillis = pattern.anchorDate.toEpochMillis()
)
DatePickerDialog(
onDismissRequest = { showAnchorPicker = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let { pattern = pattern.copy(anchorDate = it.toLocalDate()) }
showAnchorPicker = false
}) { Text("确定") }
},
dismissButton = {
TextButton(onClick = { showAnchorPicker = false }) { Text("取消") }
}
) {
DatePicker(state = state)
}
}
}
private fun LocalDate.toEpochMillis(): Long =
this.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
private fun Long.toLocalDate(): LocalDate =
Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.UTC).date

View File

@ -1,6 +1,7 @@
package plus.rua.project.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -29,6 +30,7 @@ import androidx.compose.ui.unit.dp
*
* @param onBack 返回回调
* @param onNavigateToDateChecker 跳转到日期检查器回调
* @param onNavigateToDateRecorder 跳转到日期记录器回调
* @param modifier 布局修饰符
*/
@OptIn(ExperimentalMaterial3Api::class)
@ -36,6 +38,7 @@ import androidx.compose.ui.unit.dp
fun ToolsScreen(
onBack: () -> Unit,
onNavigateToDateChecker: () -> Unit,
onNavigateToDateRecorder: () -> Unit,
modifier: Modifier = Modifier
) {
Scaffold(
@ -58,13 +61,19 @@ fun ToolsScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ToolItem(
title = "日期检查器",
onClick = onNavigateToDateChecker,
modifier = Modifier.testTag("tool_date_checker")
)
ToolItem(
title = "日期记录器",
onClick = onNavigateToDateRecorder,
modifier = Modifier.testTag("tool_date_recorder")
)
}
}
}

View File

@ -1,9 +1,11 @@
package plus.rua.project
import android.content.SharedPreferences
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.number
import plus.rua.project.ShiftKind
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@ -127,4 +129,126 @@ class CalendarViewModelTest {
val selectedCell = days.first { it.isSelected }
assertEquals(15, selectedCell.date.day)
}
// ---- shiftPattern: 默认值与 refresh ----
@Test
fun shiftPattern_noStorage_returnsDefault() {
val vm = createViewModel() // 不传 storage
assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15)))
assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 5, 17)))
}
@Test
fun refreshShiftPattern_reloadsFromStorage() {
val prefs = CalendarVmTestPrefs()
val storage = ShiftPatternStorage(prefs)
// storage 里存一个 1班1休,锚点 2026-01-01
storage.save(
ShiftPattern(
anchorDate = LocalDate(2026, 1, 1),
cycle = listOf(ShiftKind.WORK, ShiftKind.OFF)
)
)
val vm = CalendarViewModel(clock = testClock, shiftStorage = storage)
// 初始即从 storage 读
assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 1, 1)))
assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 1, 2)))
}
@Test
fun refreshShiftPattern_reloadsAfterStorageChange() {
val prefs = CalendarVmTestPrefs()
val storage = ShiftPatternStorage(prefs)
// 初始:2 班 2 休(默认),构造 VM(不传 storage → 用 DEFAULT_PATTERN)
val vm = CalendarViewModel(clock = testClock, shiftStorage = storage)
// 2026-05-15 = WORK(默认 2班2休 锚点)
assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15)))
// 改 storage 为 1班1休,锚点 2026-01-01
storage.save(
ShiftPattern(
anchorDate = LocalDate(2026, 1, 1),
cycle = listOf(ShiftKind.WORK, ShiftKind.OFF)
)
)
// refresh 前:VM 还持有旧 pattern
assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15)))
// 调用 refresh
vm.refreshShiftPattern()
// refresh 后:VM 已从 storage 重读
// 2026-05-15 距 2026-01-01 = 134 天,134 % 2 = 0 → cycle[0] = WORK
assertEquals(ShiftKind.WORK, vm.shiftKindAt(LocalDate(2026, 5, 15)))
// 2026-01-02 距锚点 1 天,1 % 2 = 1 → cycle[1] = OFF
assertEquals(ShiftKind.OFF, vm.shiftKindAt(LocalDate(2026, 1, 2)))
}
}
private class CalendarVmTestPrefs : SharedPreferences {
private val data = mutableMapOf<String, Any?>()
override fun getAll(): Map<String, *> = data.toMap()
override fun getString(key: String, defValue: String?): String? =
data[key] as? String ?: defValue
override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? =
data[key] as? Set<String> ?: defValues
override fun getInt(key: String, defValue: Int): Int =
data[key] as? Int ?: defValue
override fun getLong(key: String, defValue: Long): Long =
data[key] as? Long ?: defValue
override fun getFloat(key: String, defValue: Float): Float =
data[key] as? Float ?: defValue
override fun getBoolean(key: String, defValue: Boolean): Boolean =
data[key] as? Boolean ?: defValue
override fun contains(key: String): Boolean = data.containsKey(key)
override fun edit(): SharedPreferences.Editor = CalendarVmTestPrefsEditor(data)
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
}
private class CalendarVmTestPrefsEditor(private val data: MutableMap<String, Any?>) : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearPending = false
override fun putString(key: String, value: String?): SharedPreferences.Editor = apply {
pending[key] = value
}
override fun putStringSet(key: String, values: Set<String>?): SharedPreferences.Editor = apply {
pending[key] = values
}
override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply { pending[key] = value }
override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply { pending[key] = value }
override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply { pending[key] = value }
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply { pending[key] = value }
override fun remove(key: String): SharedPreferences.Editor = apply { pending[key] = null }
override fun clear(): SharedPreferences.Editor = apply { clearPending = true }
override fun commit(): Boolean {
apply()
return true
}
override fun apply() {
if (clearPending) {
data.clear()
clearPending = false
}
data.putAll(pending)
pending.clear()
}
}

View File

@ -0,0 +1,119 @@
package plus.rua.project
import kotlinx.datetime.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Instant
/**
* 日期记录器排序逻辑单元测试
*
* 覆盖 [DateRecorderViewModel.sortDateRecords] 的所有字段与升降序组合
* 以及无关联日期记录的末尾排列
*/
class DateRecorderSortTest {
private val records = listOf(
record(id = 1, title = "A", shoot = LocalDate(2026, 1, 1), linked = LocalDate(2026, 1, 1), created = Instant.fromEpochSeconds(1000)),
record(id = 2, title = "B", shoot = LocalDate(2026, 3, 1), linked = null, created = Instant.fromEpochSeconds(2000)),
record(id = 3, title = "C", shoot = LocalDate(2026, 2, 1), linked = LocalDate(2026, 2, 1), created = Instant.fromEpochSeconds(3000))
)
@Test
fun sortByShootDate_descending_newestFirst() {
val sorted = DateRecorderViewModel.sortDateRecords(
records,
RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = false)
)
assertEquals(listOf(2L, 3L, 1L), sorted.map { it.id })
}
@Test
fun sortByShootDate_ascending_oldestFirst() {
val sorted = DateRecorderViewModel.sortDateRecords(
records,
RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = true)
)
assertEquals(listOf(1L, 3L, 2L), sorted.map { it.id })
}
@Test
fun sortByLinkedDate_descending_nullsFirst() {
val sorted = DateRecorderViewModel.sortDateRecords(
records,
RecordSortOrder(RecordSortField.LINKED_DATE, ascending = false)
)
// 降序时 reversed() 把 nullsLast 翻转为 nullsFirst
// null(id2) → 2026-02-01(id3) → 2026-01-01(id1)
assertEquals(listOf(2L, 3L, 1L), sorted.map { it.id })
}
@Test
fun sortByLinkedDate_ascending_nullsLast() {
val sorted = DateRecorderViewModel.sortDateRecords(
records,
RecordSortOrder(RecordSortField.LINKED_DATE, ascending = true)
)
// 升序2026-01-01(id1) → 2026-02-01(id3) → null(id2)
assertEquals(listOf(1L, 3L, 2L), sorted.map { it.id })
}
@Test
fun sortByCreatedAt_descending() {
val sorted = DateRecorderViewModel.sortDateRecords(
records,
RecordSortOrder(RecordSortField.CREATED_AT, ascending = false)
)
assertEquals(listOf(3L, 2L, 1L), sorted.map { it.id })
}
@Test
fun sortByCreatedAt_ascending() {
val sorted = DateRecorderViewModel.sortDateRecords(
records,
RecordSortOrder(RecordSortField.CREATED_AT, ascending = true)
)
assertEquals(listOf(1L, 2L, 3L), sorted.map { it.id })
}
@Test
fun sort_emptyList_returnsEmpty() {
val sorted = DateRecorderViewModel.sortDateRecords(
emptyList(),
RecordSortOrder.DEFAULT
)
assertEquals(emptyList(), sorted)
}
@Test
fun sort_tieBreakerById_ascending() {
// 同一拍摄日期、同一创建时间,仅 id 不同 → 按 id 升序
val tied = listOf(
record(id = 5, shoot = LocalDate(2026, 1, 1), created = Instant.fromEpochSeconds(0)),
record(id = 2, shoot = LocalDate(2026, 1, 1), created = Instant.fromEpochSeconds(0)),
record(id = 8, shoot = LocalDate(2026, 1, 1), created = Instant.fromEpochSeconds(0))
)
val sorted = DateRecorderViewModel.sortDateRecords(
tied,
RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = true)
)
// 主键 id 升序兜底
assertEquals(listOf(2L, 5L, 8L), sorted.map { it.id })
}
private fun record(
id: Long,
title: String = "t",
shoot: LocalDate = LocalDate(2026, 1, 1),
linked: LocalDate? = null,
created: Instant = Instant.fromEpochSeconds(0)
) = DateRecord(
id = id,
title = title,
note = "",
shootDate = shoot,
linkedDate = linked,
photoPath = "fake/path.jpg",
createdAt = created
)
}

View File

@ -0,0 +1,109 @@
package plus.rua.project
import androidx.compose.ui.geometry.Offset
import sun.misc.Unsafe
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* 手写笔触分段逻辑单元测试
*
* 覆盖 [PhotoEditorState.withAddedPoint] / [PhotoEditorState.withEndedStroke]
* 抬手后再次落笔必须另起新段不能与上一条笔触连成一条折线
*/
class HandStrokeTest {
private fun emptyState() = PhotoEditorState(
sourceBitmap = UninitializedBitmap,
sourceAbsolutePath = "/tmp/fake.jpg"
)
@Test
fun twoStrokes_afterLiftAreSeparate() {
// 画第一笔 A→B
var s = emptyState()
.withAddedPoint(Offset(0f, 0f))
.withAddedPoint(Offset(10f, 0f))
// 抬手
s = s.withEndedStroke()
// 在别处落笔 C→D
s = s.withAddedPoint(Offset(100f, 100f))
.withAddedPoint(Offset(110f, 100f))
// 期望:两条独立笔触,而不是 [A,B,C,D] 一条
assertEquals(2, s.strokes.size, "两笔应分隔为两段")
assertEquals(listOf(Offset(0f, 0f), Offset(10f, 0f)), s.strokes[0].points)
assertEquals(listOf(Offset(100f, 100f), Offset(110f, 100f)), s.strokes[1].points)
}
@Test
fun points_accumulateWithinSameStroke() {
val s = emptyState()
.withAddedPoint(Offset(1f, 1f))
.withAddedPoint(Offset(2f, 2f))
.withAddedPoint(Offset(3f, 3f))
assertEquals(1, s.strokes.size)
assertEquals(
listOf(Offset(1f, 1f), Offset(2f, 2f), Offset(3f, 3f)),
s.strokes[0].points
)
}
@Test
fun endStroke_marksCurrentStrokeFinished() {
val s = emptyState()
.withAddedPoint(Offset(0f, 0f))
.withEndedStroke()
assertEquals(1, s.strokes.size)
assertTrue(s.strokes[0].isFinished, "抬手后笔触应标记为已结束")
}
@Test
fun endStroke_withNoStrokes_isNoop() {
val s = emptyState().withEndedStroke()
assertTrue(s.strokes.isEmpty())
}
@Test
fun endStroke_calledTwice_keepsStrokeFinished() {
val s = emptyState()
.withAddedPoint(Offset(0f, 0f))
.withEndedStroke()
.withEndedStroke()
assertEquals(1, s.strokes.size)
assertTrue(s.strokes[0].isFinished)
}
@Test
fun addPoint_afterFinished_startsNewStroke() {
// 抬手之后再落点:上一条 isFinished=true应另起新段
val s = emptyState()
.withAddedPoint(Offset(0f, 0f))
.withEndedStroke()
.withAddedPoint(Offset(50f, 50f))
assertEquals(2, s.strokes.size)
assertFalse(s.strokes[0].points.contains(Offset(50f, 50f)))
}
private companion object {
/**
* 通过 Unsafe.allocateInstance 创建 Bitmap 跳过 Android 框架的静态初始化
* 笔触分段逻辑不读 Bitmap 任何字段/方法桩仅用于满足 PhotoEditorState 构造器
*/
@Suppress("DiscouragedPrivateApi", "DEPRECATION")
val UninitializedBitmap: android.graphics.Bitmap by lazy {
val unsafeField = Unsafe::class.java.getDeclaredField("theUnsafe").apply { isAccessible = true }
val unsafe = unsafeField.get(null) as Unsafe
@Suppress("UNCHECKED_CAST")
val bmp = unsafe.allocateInstance(android.graphics.Bitmap::class.java)
as android.graphics.Bitmap
bmp
}
}
}

View File

@ -0,0 +1,37 @@
package plus.rua.project
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* PhotoProcessor 降采样计算逻辑单元测试
*
* 仅覆盖纯计算部分[PhotoProcessor.calculateInSampleSizePublic]
* 涉及 Bitmap/IO 的部分依赖 Android 框架 instrumented 测试
*/
class PhotoProcessorTest {
@Test
fun sampleSize_smallImage_returns1() {
// 源宽 500要求 1080 → 不需降采样
assertEquals(1, PhotoProcessor.calculateInSampleSizePublic(500, 1080))
}
@Test
fun sampleSize_exactly2xTarget_returns1() {
// 源宽 2160要求 1080 → 2160/1 = 2160 <= 2160(1080*2),返回 1
assertEquals(1, PhotoProcessor.calculateInSampleSizePublic(2160, 1080))
}
@Test
fun sampleSize_largeImage_returnsPowerOf2() {
// 源宽 8000要求 1080 → 8000/2=4000 > 2160, 8000/4=2000 <= 2160 → 4
assertEquals(4, PhotoProcessor.calculateInSampleSizePublic(8000, 1080))
}
@Test
fun sampleSize_hugeImage_returnsLargerPowerOf2() {
// 源宽 20000要求 1080 → 20000/2=10000, /4=5000, /8=2500, /16=1250 <= 2160 → 16
assertEquals(16, PhotoProcessor.calculateInSampleSizePublic(20000, 1080))
}
}

View File

@ -0,0 +1,170 @@
package plus.rua.project
import android.content.SharedPreferences
import kotlinx.datetime.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ShiftPatternStorageTest {
private val prefs = ShiftPatternTestPrefs()
private val storage = ShiftPatternStorage(prefs)
@Test
fun load_noSavedData_returnsNull() {
storage.clear()
assertNull(storage.load())
}
@Test
fun saveAndLoad_roundTrips_basicPattern() {
storage.clear()
val pattern = ShiftPattern(
anchorDate = LocalDate(2026, 5, 15),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF)
)
storage.save(pattern)
val result = storage.load()
assertEquals(pattern, result)
}
@Test
fun saveAndLoad_roundTrips_withOverridesAndBreaks() {
storage.clear()
val pattern = ShiftPattern(
anchorDate = LocalDate(2026, 7, 8),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
overrides = mapOf(
LocalDate(2026, 7, 12) to ShiftKind.OFF,
LocalDate(2026, 7, 14) to ShiftKind.WORK
),
rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 17), ShiftKind.OFF, LocalDate(2026, 7, 18)))
)
storage.save(pattern)
val result = storage.load()
assertEquals(pattern, result)
}
@Test
fun saveAndLoad_emptyOverridesAndBreaks() {
storage.clear()
val pattern = ShiftPattern(
anchorDate = LocalDate(2026, 5, 15),
cycle = listOf(ShiftKind.WORK, ShiftKind.OFF),
overrides = emptyMap(),
rephaseFlips = emptyList()
)
storage.save(pattern)
assertEquals(pattern, storage.load())
}
@Test
fun load_corruptOverrides_returnsNull() {
storage.clear()
// 通过底层 prefs 注入损坏的 overrides 值
prefs.edit()
.putString("shift_anchor", "2026-05-15")
.putString("shift_cycle", "1,1,0,0")
.putString("shift_overrides", "not-a-date:1")
.apply()
assertNull(storage.load())
}
@Test
fun saveAndLoad_customName_preserved() {
storage.clear()
val pattern = ShiftPattern(
anchorDate = LocalDate(2026, 5, 15),
cycle = listOf(ShiftKind.WORK, ShiftKind.OFF),
name = "我的方案"
)
storage.save(pattern)
val loaded = storage.load()
assertEquals(pattern, loaded)
assertEquals("我的方案", loaded?.name)
}
@Test
fun saveAndLoad_emptyCycle_roundTrips() {
storage.clear()
val pattern = ShiftPattern(
anchorDate = LocalDate(2026, 5, 15),
cycle = emptyList()
)
storage.save(pattern)
val loaded = storage.load()
assertEquals(pattern, loaded)
}
}
// 复制自 DateCheckerStorageTest(其为 private,无法共享);重命名以避免同包下 private 类同名冲突
private class ShiftPatternTestPrefs : SharedPreferences {
private val data = mutableMapOf<String, Any?>()
override fun getAll(): Map<String, *> = data.toMap()
override fun getString(key: String, defValue: String?): String? =
data[key] as? String ?: defValue
override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? =
data[key] as? Set<String> ?: defValues
override fun getInt(key: String, defValue: Int): Int =
data[key] as? Int ?: defValue
override fun getLong(key: String, defValue: Long): Long =
data[key] as? Long ?: defValue
override fun getFloat(key: String, defValue: Float): Float =
data[key] as? Float ?: defValue
override fun getBoolean(key: String, defValue: Boolean): Boolean =
data[key] as? Boolean ?: defValue
override fun contains(key: String): Boolean = data.containsKey(key)
override fun edit(): SharedPreferences.Editor = ShiftPatternTestPrefsEditor(data)
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
}
private class ShiftPatternTestPrefsEditor(private val data: MutableMap<String, Any?>) : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearPending = false
override fun putString(key: String, value: String?): SharedPreferences.Editor = apply {
pending[key] = value
}
override fun putStringSet(key: String, values: Set<String>?): SharedPreferences.Editor = apply {
pending[key] = values
}
override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply { pending[key] = value }
override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply { pending[key] = value }
override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply { pending[key] = value }
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply { pending[key] = value }
override fun remove(key: String): SharedPreferences.Editor = apply { pending[key] = null }
override fun clear(): SharedPreferences.Editor = apply { clearPending = true }
override fun commit(): Boolean {
apply()
return true
}
override fun apply() {
if (clearPending) {
data.clear()
clearPending = false
}
data.putAll(pending)
pending.clear()
}
}

View File

@ -176,4 +176,180 @@ class ShiftPatternTest {
assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
}
// ---- kindAt: 单日 override ----
@Test
fun kindAt_overrideOnBaseDay_flipsToOff() {
// 基础周期下 5/15 = WORK(锚点),override 为 OFF
val pattern = twoOnTwoOff.copy(
overrides = mapOf(LocalDate(2026, 5, 15) to ShiftKind.OFF)
)
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 15)))
// 隔天不受影响
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 16)))
}
@Test
fun kindAt_overrideOnOffDay_flipsToWork() {
// 基础周期下 5/17 = OFF,override 为 WORK
val pattern = twoOnTwoOff.copy(
overrides = mapOf(LocalDate(2026, 5, 17) to ShiftKind.WORK)
)
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 17)))
}
// ---- kindAt: 翻转并重排 rephaseFlip ----
@Test
fun kindAt_rephaseFlip_restartsCycleFromRephaseDate() {
// 5/18 翻转为 OFF,5/19 起重排(rephaseFrom=5/19)
val pattern = twoOnTwoOff.copy(
rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 5, 18), ShiftKind.OFF, LocalDate(2026, 5, 19)))
)
// 5/18 = 翻转为 OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 18)))
// 5/19 = cycle[0] = WORK(重排起点)
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 19)))
// 5/20 = cycle[1] = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 20)))
// 5/21 = cycle[2] = OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 21)))
}
@Test
fun kindAt_multipleRephaseFlips_usesLatestApplicable() {
// 两个 rephaseFlip:5/18→5/19 重排,5/24→5/25 重排
val pattern = twoOnTwoOff.copy(
rephaseFlips = listOf(
RephaseFlip(LocalDate(2026, 5, 18), ShiftKind.OFF, LocalDate(2026, 5, 19)),
RephaseFlip(LocalDate(2026, 5, 24), ShiftKind.OFF, LocalDate(2026, 5, 25))
)
)
// 5/19-5/23 受第一个重排支配(5/19 起锚)
// 5/19 = cycle[0] = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 19)))
// 5/23 = cycle[(4)%4] = cycle[0] = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 23)))
// 5/24 = 第二个 rephaseFlip 的翻转日,翻转为 OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 24)))
// 5/25 起受第二个断点支配(5/25 起锚)
// 5/25 = cycle[0] = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 25)))
// 5/26 = cycle[1] = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 5, 26)))
// 5/27 = cycle[2] = OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 5, 27)))
}
// ---- kindAt: override + rephaseFlip 组合(用户原始例子)----
@Test
fun kindAt_userExample_combinedOverridesAndRephaseFlip() {
// 基础锚点 7/8,周期 [WORK,WORK,OFF,OFF]
val pattern = ShiftPattern(
anchorDate = LocalDate(2026, 7, 8),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
overrides = mapOf(
LocalDate(2026, 7, 12) to ShiftKind.OFF, // 班→休(单日)
LocalDate(2026, 7, 14) to ShiftKind.WORK, // 休→班(单日)
LocalDate(2026, 7, 15) to ShiftKind.WORK, // 休→班(单日)
LocalDate(2026, 7, 16) to ShiftKind.OFF, // 班→休(单日)
LocalDate(2026, 7, 17) to ShiftKind.OFF // 班→休(单日)
),
// 7/17 翻转为 OFF,7/18 起重排
rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 17), ShiftKind.OFF, LocalDate(2026, 7, 18)))
)
// 7/10,11 = 基础周期自动算 (idx 2,3) = OFF,OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10)))
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 11)))
// 7/12 = override OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 12)))
// 7/13 = 基础周期 (idx 5%4=1) = WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 13)))
// 7/14,15 = override WORK
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 14)))
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 15)))
// 7/16 = override OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 16)))
// 7/17 = rephaseFlip 翻转为 OFF
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 17)))
// 7/18 起 rephase 重排:cycle[0,1,2,3] = WORK,WORK,OFF,OFF
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 18)))
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 19)))
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 20)))
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 21)))
}
// ---- 长按"翻转并重排"场景(RephaseFlip 原子操作)----
/**
* 单次长按:锚点 7/10,2班2休长按 7/10 把班翻转为休,7/11 起重排
* 期望:7/10=,7/11-12=,7/13-14=(后续按 cycle 顺延)
*/
@Test
fun longPress_singleFlip_dateFlippedAndRephased() {
val pattern = ShiftPattern(
anchorDate = LocalDate(2026, 7, 10),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 10), ShiftKind.OFF, LocalDate(2026, 7, 11)))
)
// 7/10 翻转为休
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10)))
// 7/11 起重排:cycle[0,1]=班班
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 11)))
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 12)))
// cycle[2,3]=休休
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 13)))
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 14)))
// 继续循环:7/15=cycle[0]=班
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 15)))
}
/**
* 连续两次长按:7/10 一次7/14 一次两个 rephaseFlip 共存
* 期望:7/11-13 受第一个重排支配,7/15 起受第二个重排支配
*/
@Test
fun longPress_twoFlips_bothRephasesApplied() {
val pattern = ShiftPattern(
anchorDate = LocalDate(2026, 7, 10),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
rephaseFlips = listOf(
RephaseFlip(LocalDate(2026, 7, 10), ShiftKind.OFF, LocalDate(2026, 7, 11)),
RephaseFlip(LocalDate(2026, 7, 14), ShiftKind.OFF, LocalDate(2026, 7, 15))
)
)
// 7/10 翻转为休
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 10)))
// 7/11-13 受第一个重排(7/11 起):班班休
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 11)))
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 12)))
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 13)))
// 7/14 翻转为休
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 14)))
// 7/15 起受第二个重排:班班休休
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 15)))
assertEquals(ShiftKind.WORK, pattern.kindAt(LocalDate(2026, 7, 16)))
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 17)))
assertEquals(ShiftKind.OFF, pattern.kindAt(LocalDate(2026, 7, 18)))
}
/**
* 撤销长按:移除整个 RephaseFlip ,回到基础周期
*/
@Test
fun longPress_undo_returnsToBaseCycle() {
val before = ShiftPattern(
anchorDate = LocalDate(2026, 7, 10),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF),
rephaseFlips = listOf(RephaseFlip(LocalDate(2026, 7, 10), ShiftKind.OFF, LocalDate(2026, 7, 11)))
)
// 撤销:移除整个 rephaseFlip(原子删除,不会留孤立断点)
val after = before.copy(rephaseFlips = emptyList())
// 回到基础:7/10=班(锚点),7/12=休
assertEquals(ShiftKind.WORK, after.kindAt(LocalDate(2026, 7, 10)))
assertEquals(ShiftKind.WORK, after.kindAt(LocalDate(2026, 7, 11)))
assertEquals(ShiftKind.OFF, after.kindAt(LocalDate(2026, 7, 12)))
}
}

View File

@ -0,0 +1,315 @@
# 个人轮班设置页设计
## 背景
当前 `ShiftPattern``CalendarViewModel` 中硬编码(锚点 `2026-05-15`,周期 `[WORK, WORK, OFF, OFF]`),用户无法修改,重启后回到默认值(注释明确标注"MVP 默认,后续接入设置页与持久化")。
本设计新增一个独立设置页,让用户能:
- 编辑基础周期(锚点日期 + 班次序列);
- 对单日做调班(翻转某天班/休);
- 设置相位断点(从某天起重排周期相位);
- 持久化到 SharedPreferences设置返回后主界面立即生效。
## 目标
- 用户能从主界面 FAB 菜单进入"班次设置"页。
- 基础周期通过预设方案选择1班1休 / 2班2休 / 3班3休 / 4班4休
- 锚点日期通过 DatePicker 修改。
- 迷你月历上:**点某天 = 翻转班/休**(单日 override**长按某天 = 设/清相位断点**。
- 设置页改动通过 `LaunchedEffect` 自动存盘,返回主界面后 `onResume` 触发 VM 重读,立即刷新日历。
- 单元测试覆盖核心算法(`kindAt` 含 overrides/phaseBreaks、持久化往返、VM 重读。
## 需求语义(用户原始例子)
基础周期 `[WORK, WORK, OFF, OFF]` 锚点 7/8。期望 7/107/21 序列:
| 日期 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|
| 目标 | 休 | 休 | **休** | 班 | **班** | **班** | **休** | **休** | **班** | **班** | **休** | **休** |
实现方式:
- 7/10、7/11、7/13基础周期自动算出无需调班。
- 7/12、7/14、7/15、7/16、7/17单日 override 翻转。
- 7/18 起:相位断点重排(`PhaseBreak(date=7/18, cycleOffset=0)`18 当作 `cycle[0]` 重新循环,自动得到 1821 = 班班休休。
**结论**:单靠 override 或单靠 phaseBreak 都无法表达此例,必须**两者结合**。
## 方案选择
采用**独立 Activity + SharedPreferences + onResume 重读**。理由:
- 与项目现有架构一致(`DateCheckerActivity` + `DateCheckerStorage` 是现成的"设置页 + 持久化"完整范例,无 DI、无 Navigation、无 DataStore
- 改动隔离,不污染主界面 Composable。
- `onResume` 重读实现简单、体验顺滑(设置返回立即生效),无需引入 Flow 持久化或 ContentObserver。
备选方案(未采用):
- 底部弹窗 Dialog与多 Activity 架构不一致,弹窗内 UI 拥挤。
- 全屏覆盖层:增加主 Composable 复杂度,违背"改动隔离"原则。
- 实时同步Flow + OnSharedPreferenceChangeListener过度设计设置页返回一次重读已足够。
## 详细设计
### 数据层
文件:`core/src/main/kotlin/plus/rua/project/ShiftPattern.kt`
新增 `PhaseBreak` 数据类,并扩展 `ShiftPattern`
```kotlin
data class PhaseBreak(
val date: LocalDate, // 从这天起重排
val cycleOffset: Int // 这天对应 cycle 的第几位0 = cycle[0]
)
data class ShiftPattern(
val anchorDate: LocalDate,
val cycle: List<ShiftKind>,
val overrides: Map<LocalDate, ShiftKind> = emptyMap(), // 单日翻转
val phaseBreaks: List<PhaseBreak> = emptyList(), // 相位重排
val name: String = "默认"
) {
fun kindAt(date: LocalDate): ShiftKind? {
if (cycle.isEmpty()) return null
overrides[date]?.let { return it } // 1. override 优先
val (anchor, offset) = activeAnchor(date) // 2. 找活跃锚点
val diff = anchor.daysUntil(date)
val size = cycle.size
val idx = (((diff + offset) % size) + size) % size
return cycle[idx]
}
private fun activeAnchor(date: LocalDate): Pair<LocalDate, Int> {
val applicable = phaseBreaks.filter { it.date <= date }.maxByOrNull { it.date }
return if (applicable != null) applicable.date to applicable.cycleOffset
else anchorDate to 0
}
}
```
`activeAnchor` 语义:在 `date` 当天或之前(`<=`)的 phaseBreaks 中取日期最大的那个;若无则回退到基础 `anchorDate`offset=0`kindAt` 先查 override未命中再按活跃锚点推算。
> 注:`cycleOffset` 保留为可配置字段(当前 UI 仅写入 0为未来"断点从周期中段开始"留扩展点YAGNI 之下不暴露到 UI。
### 持久化
新文件:`core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt`
照抄 `DateCheckerStorage` 模式SharedPreferences + `fromContext` 工厂 + save/load/clear不引入新依赖。
```kotlin
class ShiftPatternStorage(private val prefs: SharedPreferences) {
companion object {
private const val KEY_ANCHOR = "shift_anchor" // "2026-07-08"ISO
private const val KEY_CYCLE = "shift_cycle" // "1,1,0,0"1=WORK 0=OFF
private const val KEY_OVERRIDES = "shift_overrides" // "2026-07-12:0,2026-07-14:1"
private const val KEY_BREAKS = "shift_breaks" // "2026-07-18:0"
private const val SEPARATOR_PAIR = ","
fun fromContext(context: Context): ShiftPatternStorage =
ShiftPatternStorage(
context.getSharedPreferences("shift_pattern", Context.MODE_PRIVATE)
)
}
fun save(pattern: ShiftPattern)
fun load(): ShiftPattern?
fun clear()
}
```
编码格式(无 JSON 依赖,与 `DateCheckerStorage` 风格一致):
- 锚点:`LocalDate.toString()``"2026-07-08"`
- 周期:`"1,1,0,0"`(逗号分隔;`1=WORK 0=OFF`)。
- overrides/breaks`"日期:值,日期:值"`(逗号分隔的 key:value 对overrides 的值 0/1breaks 的值是 cycleOffset
`load()` 在锚点或周期缺失/解析失败时返回 `null`,由 VM 回退到 `DEFAULT_PATTERN`
### ViewModel 接入
文件:`core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt`
1. 构造参数新增 `shiftStorage: ShiftPatternStorage? = null`(可空,便于测试注入与保持默认构造可用)。
2. `_shiftPattern` 初始值改为 `loadShiftPattern()`
3. 新增 `refreshShiftPattern()`:从 storage 重读后写入 `_shiftPattern.value`
4. 新增 `companion object DEFAULT_PATTERN`(迁移原硬编码值:锚点 `2026-05-15`,周期 `[WORK,WORK,OFF,OFF]`)。
```kotlin
class CalendarViewModel(
private val clock: Clock = Clock.System,
private val shiftStorage: ShiftPatternStorage? = null
) : ViewModel() {
private val _shiftPattern = MutableStateFlow(loadShiftPattern())
val shiftPattern: StateFlow<ShiftPattern?> = _shiftPattern.asStateFlow()
private fun loadShiftPattern(): ShiftPattern =
shiftStorage?.load() ?: DEFAULT_PATTERN // storage 为空或未存 → 用默认(非空)
fun refreshShiftPattern() {
_shiftPattern.value = loadShiftPattern()
}
companion object {
val DEFAULT_PATTERN = ShiftPattern(
anchorDate = LocalDate(2026, 5, 15),
cycle = listOf(ShiftKind.WORK, ShiftKind.WORK, ShiftKind.OFF, ShiftKind.OFF)
)
}
}
```
`shiftKindAt(date)` 逻辑不变(内部 `shiftPattern.value?.kindAt(date)` 已自动支持 overrides/phaseBreaks
### VM 创建方式Factory
文件:`core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt`
`viewModel<CalendarViewModel>()` 改为带 factory 的写法:
```kotlin
val context = LocalContext.current.applicationContext
val viewModel: CalendarViewModel = viewModel(
factory = viewModelFactory {
initializer {
CalendarViewModel(
clock = Clock.System,
shiftStorage = ShiftPatternStorage.fromContext(context)
)
}
}
)
```
`androidx.lifecycle.viewmodel.viewModelFactory` / `initializer` 已随 lifecycle-viewmodel-compose2.11.0)提供,无需加依赖。
### onResume 重读(立即生效)
文件:`core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt`
在 Composable 中监听生命周期,设置页返回时刷新:
```kotlin
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) viewModel.refreshShiftPattern()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
```
设置页改完 → 用户返回 → 主 Activity `onResume` → VM 重读 storage → `shiftPattern` flow 发新值 → 日历自动重组刷新。
### 设置页 UI
新文件:`core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt`
照抄 `DateCheckerScreen` 的 storage 创建 + 状态管理 + 自动存盘模式:
```kotlin
val context = LocalContext.current.applicationContext
val storage = remember { ShiftPatternStorage.fromContext(context) }
val saved = remember { storage.load() }
var pattern by remember { mutableStateOf(saved ?: CalendarViewModel.DEFAULT_PATTERN) }
LaunchedEffect(pattern) { storage.save(pattern) }
```
页面结构(三段式):
**Section 1 — 基础周期**
- 锚点日期 Card点击弹 `DatePickerDialog`(复用 `DateCheckerScreen``rememberDatePickerState` + `toLocalDate` 工具)。
- 预设周期 `FlowRow``FilterChip` 列表1班1休 / 2班2休 / 3班3休 / 4班4休当前选中高亮。点击 → `pattern.copy(cycle = preset, overrides = emptyMap(), phaseBreaks = emptyList())`(换周期清空调班,避免错位)。
**Section 2 — 迷你月历(核心交互)**
新文件:`core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt`
- 月份切换 `<` / `>` 头部。
- 每个日期格 `Box.aspectRatio(1f).combinedClickable(onClick = ::toggleOverride, onLongClick = ::togglePhaseBreak)`
- 右上角角标显示当天状态:
- 断点当天 → 琥珀色 `tertiary` + "断"标。
- 班 → `primary` + "班"标。
- 休 → `error` + "休"标。
- 月历下方提示文案:"点 = 翻转班/休,长按 = 设/清断点"。
- 图例:颜色含义说明。
交互逻辑:
- `toggleOverride(date)`:算出该天不含 override 时的基础值 `base`;当前值翻转得到 `newVal``newVal == base` 则从 overrides 移除该 date否则加入 `date to newVal`
- `togglePhaseBreak(date)`:已有则移除,否则加入 `PhaseBreak(date, 0)`
- `combinedClickable` 已在 Compose Foundation 提供,无需新依赖。
**Section 3 — 恢复默认**
- 底部 `OutlinedButton("恢复默认")``AlertDialog` 确认(复用 `DateCheckerScreen` 的对话框样式)→ 重置为 `CalendarViewModel.DEFAULT_PATTERN`
### 入口FAB 菜单项
文件:`core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt`
在"显示调休"菜单项之后新增:
```kotlin
MenuItem(text = "班次设置", selected = false, onClick = {
isMenuExpanded = false
onNavigateToShiftSettings()
})
```
`CalendarMonthView` 签名新增 `onNavigateToShiftSettings: () -> Unit = {}`
### Activity 与导航
新文件:`app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt`(照抄 `DateCheckerActivity`20 行薄壳):
```kotlin
class ShiftPatternActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YaYaTheme {
ShiftPatternScreen(onBack = { finishWithSlideBack() })
}
}
}
}
```
文件改动:
- `app/src/main/kotlin/plus/rua/project/MainActivity.kt``CalendarMonthView(...)` 调用处加 `onNavigateToShiftSettings = { startActivityWithSlide(Intent(this, ShiftPatternActivity::class.java)) }`
- `app/src/main/AndroidManifest.xml`:注册 `<activity android:name=".ShiftPatternActivity" android:exported="false" />`
### 颜色方案(与主日历 DayCell 一致)
| 元素 | 颜色 |
|------|------|
| 班 | `MaterialTheme.colorScheme.primary` |
| 休 | `MaterialTheme.colorScheme.error` |
| 断点标记 | `MaterialTheme.colorScheme.tertiary` |
## 测试
| 测试文件 | 覆盖内容 |
|---------|---------|
| `core/src/test/.../ShiftPatternTest.kt`(改) | `kindAt` 含 overrides / phaseBreaks / 两者结合;用户原始例子全序列断言 |
| `core/src/test/.../ShiftPatternStorageTest.kt`(新) | save→load 往返、空值返回 null、格式解析、复用 `InMemorySharedPreferences` |
| `core/src/test/.../CalendarViewModelTest.kt`(改) | `refreshShiftPattern()` 从 mock storage 重读storage 为 null 时回退 `DEFAULT_PATTERN` |
## 改动文件
- `core/src/main/kotlin/plus/rua/project/ShiftPattern.kt`(改:加 `PhaseBreak``overrides``phaseBreaks`、新 `kindAt`
- `core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt`(新)
- `core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt`(新)
- `core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt`(新)
- `core/src/main/kotlin/plus/rua/project/CalendarViewModel.kt`(改:注入 storage、`refreshShiftPattern``DEFAULT_PATTERN`
- `core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt`VM factory、onResume 重读、菜单项、新回调参数)
- `app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt`(新)
- `app/src/main/kotlin/plus/rua/project/MainActivity.kt`(改:接导航回调)
- `app/src/main/AndroidManifest.xml`(改:注册 Activity
- `core/src/test/kotlin/plus/rua/project/ShiftPatternTest.kt`(改)
- `core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt`(新)
- `core/src/test/kotlin/plus/rua/project/CalendarViewModelTest.kt`(改)
## 风险与注意事项
- **换预设周期时必须清空 overrides 和 phaseBreaks**,否则旧调班数据与新周期错位。`copy(cycle = preset, overrides = emptyMap(), phaseBreaks = emptyList())` 已处理。
- **VM Factory 改动**:原 `viewModel<CalendarViewModel>()` 默认构造改为 factory 注入,需确认现有测试(`CalendarViewModelTest` 通过 `FixedClock` 直接构造 VM不受影响——构造参数 `shiftStorage` 可空且有默认值,直接构造仍可用。
- **迷你月历性能**:单月 42 格,`kindAt` 是 O(phaseBreaks) 查找phaseBreaks 数量预期极小(个位数),无需优化。
- **combinedClickable 长按冲突**:需确认与系统长按菜单无冲突;`combinedClickable` 默认不触发文本选择,无额外风险。
- **存储格式向后兼容**:未来若改格式,`load()` 应在解析失败时返回 null 回退默认值,避免崩溃。

View File

@ -10,7 +10,7 @@ org.gradle.parallel=true
org.gradle.daemon=true
#App
app.version.base=1.1.0
app.version.base=1.3.0
#Android
android.nonTransitiveRClass=true

View File

@ -5,24 +5,32 @@ android-minSdk = "24"
android-targetSdk = "37"
androidx-activity = "1.13.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
androidx-lifecycle = "2.11.0"
androidx-media3 = "1.6.1"
androidx-testExt = "1.3.0"
androidx-uiautomator = "2.3.0"
androidx-uiautomator = "2.4.0"
benchmarkMacro = "1.4.1"
camerax = "1.5.3"
catalogUpdate = "1.1.0"
composeBom = "2026.05.01"
composeBom = "2026.06.01"
kotlin = "2.3.21"
kotlinx-datetime = "0.8.0"
ksp = "2.3.10"
profileinstaller = "1.4.1"
room = "2.8.4"
sketch = "4.4.0"
spotless = "8.5.1"
tyme4kt = "1.4.5"
spotless = "8.8.0"
tyme4kt = "1.5.0"
versions = "0.54.0"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmarkMacro" }
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version = "1.4.1" }
camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" }
camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }
camera-view = { module = "androidx.camera:camera-view", version.ref = "camerax" }
androidx-lifecycle-runtimeCompose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
@ -42,6 +50,10 @@ compose-uiTooling = { module = "androidx.compose.ui:ui-tooling" }
compose-uiToolingPreview = { module = "androidx.compose.ui:ui-tooling-preview" }
kotlinx-coroutines-test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0"
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
sketch-animated-webp = { module = "io.github.panpf.sketch4:sketch-animated-webp", version.ref = "sketch" }
sketch-compose = { module = "io.github.panpf.sketch4:sketch-compose", version.ref = "sketch" }
tyme4kt = { module = "cn.6tail:tyme4kt", version.ref = "tyme4kt" }
@ -53,5 +65,7 @@ androidTest = { id = "com.android.test", version.ref = "agp" }
catalogUpdate = { id = "nl.littlerobots.version-catalog-update", version.ref = "catalogUpdate" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room = { id = "androidx.room", version.ref = "room" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
versions = { id = "com.github.ben-manes.versions", version.ref = "versions" }