Compare commits
33 Commits
4be8cada86
...
b1064f95b7
| Author | SHA1 | Date | |
|---|---|---|---|
| b1064f95b7 | |||
| 81aeb8e894 | |||
| 50da18460f | |||
| b2942a3ca9 | |||
| 2e61fea21b | |||
| a1c71c2b93 | |||
| 8963b6162b | |||
| c665adfeff | |||
| afea8bf109 | |||
| 2a3089bf47 | |||
| 7b1290dd66 | |||
| 1cf950817e | |||
| 62ed8bd09a | |||
| c68944bd55 | |||
| 4b098442c4 | |||
| f8df108bf3 | |||
| d8014b5154 | |||
| 66a054f2d8 | |||
| 14a3239b39 | |||
| 7f080f2d90 | |||
| 32d66f0a42 | |||
| a5795b16dc | |||
| d528b98cde | |||
| e08243d0fa | |||
| ef55ffcf64 | |||
| 1f1db17384 | |||
| e4834a52c9 | |||
| eca6c61058 | |||
| 778d5dc8be | |||
| 5afac5dd04 | |||
| 08a6016479 | |||
| db31b9eaed | |||
| fae7654452 |
74
.agents/skills/yayacal-release/SKILL.md
Normal file
74
.agents/skills/yayacal-release/SKILL.md
Normal 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
1
.gitignore
vendored
@ -25,3 +25,4 @@ logs/
|
||||
docs/superpowers/*
|
||||
!docs/superpowers/specs/
|
||||
.worktrees/
|
||||
.zcode/
|
||||
|
||||
137
.zcode/plans/plan-sess_ace6e49c-6557-47c4-9e42-2b0bbfd83e00.md
Normal file
137
.zcode/plans/plan-sess_ace6e49c-6557-47c4-9e42-2b0bbfd83e00.md
Normal 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 用法正确,不破坏现有布局。
|
||||
57
CHANGELOG.md
57
CHANGELOG.md
@ -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
|
||||
|
||||
80
CLAUDE.md
80
CLAUDE.md
@ -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.
|
||||
230
COMMENTS.md
230
COMMENTS.md
@ -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 无替代 API,kotlinx-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` 是否有原因注释?
|
||||
- [ ] 动画/进度参数是否注明了取值范围?
|
||||
- [ ] 是否存在废话注释、过时注释或注释掉的代码?
|
||||
60
RELEASE.md
60
RELEASE.md
@ -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
|
||||
@ -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
|
||||
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- 日期记录器:相机拍摄;照片存 filesDir,minSdk 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>
|
||||
|
||||
36
app/src/main/kotlin/plus/rua/project/CameraActivity.kt
Normal file
36
app/src/main/kotlin/plus/rua/project/CameraActivity.kt
Normal 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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/src/main/kotlin/plus/rua/project/DateRecorderActivity.kt
Normal file
32
app/src/main/kotlin/plus/rua/project/DateRecorderActivity.kt
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,9 @@ class MainActivity : BaseActivity() {
|
||||
},
|
||||
onNavigateToTools = {
|
||||
startActivityWithSlide(Intent(this, ToolsActivity::class.java))
|
||||
},
|
||||
onNavigateToShiftSettings = {
|
||||
startActivityWithSlide(Intent(this, ShiftPatternActivity::class.java))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
40
app/src/main/kotlin/plus/rua/project/PhotoEditorActivity.kt
Normal file
40
app/src/main/kotlin/plus/rua/project/PhotoEditorActivity.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
app/src/main/kotlin/plus/rua/project/RecordDetailActivity.kt
Normal file
47
app/src/main/kotlin/plus/rua/project/RecordDetailActivity.kt
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/src/main/kotlin/plus/rua/project/RecordEditActivity.kt
Normal file
34
app/src/main/kotlin/plus/rua/project/RecordEditActivity.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt
Normal file
18
app/src/main/kotlin/plus/rua/project/ShiftPatternActivity.kt
Normal 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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,9 @@ class ToolsActivity : BaseActivity() {
|
||||
onBack = { finishWithSlideBack() },
|
||||
onNavigateToDateChecker = {
|
||||
startActivityWithSlide(Intent(this, DateCheckerActivity::class.java))
|
||||
},
|
||||
onNavigateToDateRecorder = {
|
||||
startActivityWithSlide(Intent(this, DateRecorderActivity::class.java))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
7
app/src/main/res/xml/file_paths.xml
Normal file
7
app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
@ -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)
|
||||
}
|
||||
|
||||
66
core/schemas/plus.rua.project.DateRecordDatabase/1.json
Normal file
66
core/schemas/plus.rua.project.DateRecordDatabase/1.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否在右上角显示法定调休角标。默认禁用,此时右上角让位给个人排班。
|
||||
|
||||
30
core/src/main/kotlin/plus/rua/project/DateRecord.kt
Normal file
30
core/src/main/kotlin/plus/rua/project/DateRecord.kt
Normal 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
|
||||
)
|
||||
@ -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) }
|
||||
}
|
||||
57
core/src/main/kotlin/plus/rua/project/DateRecordDao.kt
Normal file
57
core/src/main/kotlin/plus/rua/project/DateRecordDao.kt
Normal 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
|
||||
}
|
||||
42
core/src/main/kotlin/plus/rua/project/DateRecordDatabase.kt
Normal file
42
core/src/main/kotlin/plus/rua/project/DateRecordDatabase.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
113
core/src/main/kotlin/plus/rua/project/DateRecorderRepository.kt
Normal file
113
core/src/main/kotlin/plus/rua/project/DateRecorderRepository.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
186
core/src/main/kotlin/plus/rua/project/DateRecorderViewModel.kt
Normal file
186
core/src/main/kotlin/plus/rua/project/DateRecorderViewModel.kt
Normal 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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
112
core/src/main/kotlin/plus/rua/project/PhotoEditorState.kt
Normal file
112
core/src/main/kotlin/plus/rua/project/PhotoEditorState.kt
Normal 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
|
||||
}
|
||||
166
core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt
Normal file
166
core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
202
core/src/main/kotlin/plus/rua/project/PhotoProcessor.kt
Normal file
202
core/src/main/kotlin/plus/rua/project/PhotoProcessor.kt
Normal 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 旋转后的新 Bitmap;0° 时返回原对象
|
||||
*/
|
||||
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()
|
||||
)
|
||||
}
|
||||
157
core/src/main/kotlin/plus/rua/project/RecordEditViewModel.kt
Normal file
157
core/src/main/kotlin/plus/rua/project/RecordEditViewModel.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
91
core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt
Normal file
91
core/src/main/kotlin/plus/rua/project/ShiftPatternStorage.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
369
core/src/main/kotlin/plus/rua/project/ui/CameraScreen.kt
Normal file
369
core/src/main/kotlin/plus/rua/project/ui/CameraScreen.kt
Normal 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")
|
||||
}
|
||||
18
core/src/main/kotlin/plus/rua/project/ui/DateRecorderNav.kt
Normal file
18
core/src/main/kotlin/plus/rua/project/ui/DateRecorderNav.kt
Normal 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"
|
||||
}
|
||||
388
core/src/main/kotlin/plus/rua/project/ui/DateRecorderScreen.kt
Normal file
388
core/src/main/kotlin/plus/rua/project/ui/DateRecorderScreen.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
463
core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt
Normal file
463
core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt
Normal 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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
221
core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt
Normal file
221
core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
317
core/src/main/kotlin/plus/rua/project/ui/RecordEditScreen.kt
Normal file
317
core/src/main/kotlin/plus/rua/project/ui/RecordEditScreen.kt
Normal 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')}"
|
||||
}
|
||||
384
core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt
Normal file
384
core/src/main/kotlin/plus/rua/project/ui/ShiftCalendarGrid.kt
Normal 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
|
||||
227
core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt
Normal file
227
core/src/main/kotlin/plus/rua/project/ui/ShiftPatternScreen.kt
Normal 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
|
||||
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
119
core/src/test/kotlin/plus/rua/project/DateRecorderSortTest.kt
Normal file
119
core/src/test/kotlin/plus/rua/project/DateRecorderSortTest.kt
Normal 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
|
||||
)
|
||||
}
|
||||
109
core/src/test/kotlin/plus/rua/project/HandStrokeTest.kt
Normal file
109
core/src/test/kotlin/plus/rua/project/HandStrokeTest.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
37
core/src/test/kotlin/plus/rua/project/PhotoProcessorTest.kt
Normal file
37
core/src/test/kotlin/plus/rua/project/PhotoProcessorTest.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
170
core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt
Normal file
170
core/src/test/kotlin/plus/rua/project/ShiftPatternStorageTest.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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/10–7/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]` 重新循环,自动得到 18–21 = 班班休休。
|
||||
|
||||
**结论**:单靠 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/1,breaks 的值是 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-compose(2.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 回退默认值,避免崩溃。
|
||||
@ -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
|
||||
|
||||
@ -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" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user