feat: 实现日历提醒 in-app fallback 机制及通知服务重构

This commit is contained in:
zl-q
2026-03-20 01:30:34 +08:00
parent 7fd536e976
commit d574128815
55 changed files with 4565 additions and 647 deletions
@@ -0,0 +1,215 @@
# 日历提醒统一交互设计(iOS/Android
## 1. 背景与问题
当前日历提醒模块存在以下问题:
1. iOS 通知动作("稍后提醒"/"取消")在横幅场景下可见性不稳定,用户误判为无按钮。
2. Android 与 iOS 的通知动作回调链路不一致,导致按钮点击在部分状态下无效。
3. 前台提醒体验依赖系统默认样式,Android 观感较弱,不符合产品视觉语言。
4. 提醒动作与弹窗交互代码存在历史分叉,维护成本高,且存在潜在无用旧代码残留。
## 2. 目标与非目标
### 2.1 目标
- 支持 App 关闭状态下的到点提醒(系统通知主触达)。
- 统一提醒动作语义:
- `稍后提醒` = 延后 10 分钟。
- `取消` = 归档日历事件。
- 前台状态提供一套跨平台复用的应用内提醒面板,提升视觉质量。
- 无论动作来自系统通知还是应用内面板,都进入同一业务执行链路。
- 在新链路稳定后,清除无用旧代码与重复入口。
### 2.2 非目标
- 不改变提醒策略(仍按当前 reminderMinutes + 重复提醒策略)。
- 不改动日历事件核心数据结构与后端协议。
- 不在本次引入新的提醒类型(例如自定义延后时长、多级动作)。
## 3. 总体方案
采用“系统通知主触达 + 前台应用内面板增强”的混合方案:
1. **系统通知层(平台差异化)**
- Android/iOS 继续使用 `flutter_local_notifications`
- 平台分别补齐通知动作接收能力,确保前台/后台/终止态都可触发动作。
2. **动作执行层(跨平台统一)**
-`ReminderActionExecutor` 作为唯一动作入口。
- 内部动作 ID 固定为:`ReminderAction.snooze10m``ReminderAction.archive`
- UI 文案中的“取消”仅为展示文案,内部统一映射到 `archive`
3. **前台呈现层(跨平台复用)**
- 新增应用内 `ReminderActionSheet`(共享组件,遵循设计 token)。
- 仅在应用前台触发,用于替代系统默认弹窗体验。
4. **展示策略(避免双提醒)**
- 前台(App active):默认只展示 `ReminderActionSheet`,不展示系统通知横幅。
- 后台/终止态:只展示系统通知。
## 4. 关键设计决策
### 4.1 是否需要 iOS/Android 各写一套弹窗组件
不需要。应用内提醒组件采用一套 Flutter 共享实现。
需要分平台处理的是系统通知配置与回调桥接,不是应用内 UI 组件本身。
### 4.2 到点提醒是否继续使用“弹窗”
主流做法是系统通知,不是纯应用内弹窗。原因:
- App 关闭态仅系统通知可达。
- 锁屏、通知中心具备天然可达性与系统一致性。
- 可直接承载动作按钮(稍后提醒、取消并归档)。
前台场景再补应用内面板,兼顾体验与一致行为。
### 4.3 iOS 动作按钮显示问题
iOS 横幅通常不保证直接展示全部动作按钮,需展开通知查看动作。该行为属于系统 UI 规则。
此外 iOS 通知 category 存在缓存特性,category 变更后可能需要重装或升级 category id 才能稳定生效。
## 5. 模块与职责划分
### 5.1 保留并增强
- `apps/lib/core/notifications/local_notification_service.dart`
- 仅负责通知调度与平台动作回调桥接。
- 不负责前台 UI 展示。
- 补齐后台动作回调接入。
- `apps/lib/features/calendar/reminders/reminder_action_executor.dart`
- 作为唯一动作执行器。
- 保持归档 outbox 重试机制。
### 5.2 新增
- `apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart`
- 感知 app 前后台状态。
- 前台触发应用内提醒面板。
- 作为前台展示唯一入口,禁止其他模块直接弹出提醒面板。
- `ReminderActionSheet`(共享组件)
- 展示事件摘要 + 两个动作按钮。
- 保证与系统通知动作语义一致。
### 5.3 平台接入补齐
- Android:
-`apps/android/app/src/main/AndroidManifest.xml` 增加 `ActionBroadcastReceiver`
- 配置并接入 `onDidReceiveBackgroundNotificationResponse`
- Android 13+ 先做 `POST_NOTIFICATIONS` 授权检查;未授权时降级应用内提示并记录埋点。
- 后台回调函数必须为 top-level 且加 `@pragma('vm:entry-point')`
- iOS:
-`apps/ios/Runner/AppDelegate.swift` 配置 plugin registrant callback(用于后台 action isolate)。
- 后台回调函数必须为 top-level 且加 `@pragma('vm:entry-point')`
- `UNNotificationCategory` 在应用启动早期完成注册(早于提醒调度)。
- 为 category id 增加版本化策略(`calendar_reminder_v{n}`),避免缓存导致动作更新不生效。
## 6. 动作流转(统一)
### 6.1 稍后提醒
触发源(系统通知按钮或应用内面板按钮)
-> 统一映射为 `ReminderAction.snooze10m`
-> `ReminderActionExecutor._snoozeEvent`
-> 重新计算下一次时间并 `scheduleReminderAt`
### 6.2 取消(归档)
触发源(系统通知按钮或应用内面板按钮)
-> 统一映射为 `ReminderAction.archive`
-> 取消本地提醒
-> 写 outbox 并调用归档接口
-> 成功标记 done,失败进入 retry/backoff
### 6.3 动作回传契约(前台/后台/终止态统一)
- 每次动作生成幂等键:`actionExecutionId = notificationId + actionId + fireTimeBucket`
- 执行前先查重(本地持久化幂等表);命中时直接 ACK,不重复执行业务副作用。
- 终止态动作进入 cold-start queue 回放,按接收时间顺序处理。
- 单条动作失败不阻塞后续动作;失败进入 retry/backoff 并可观测。
## 7. 旧代码收集与清理计划
### 7.1 旧代码清单建立
改造前先建立“提醒模块迁移清单”,按三类标记:
- `保留`:仍由新架构使用。
- `替换`:保留接口,重写实现。
- `删除`:无引用、重复职责、历史临时逻辑。
迁移清单字段必须包含:`文件路径``符号名``处理决策(保留/替换/删除)``责任人`
### 7.2 清理时机
- 新链路在 Android+iOS 均验证通过后,立即执行删除。
- 不做“先保留一版再说”的长期并存。
### 7.3 清理范围
- 无效弹窗触发入口。
- 不再使用的提醒动作映射分支。
- 重复回调注册与过时常量(旧 action id / 旧 category id)。
- 不再有保护价值的旧测试与旧 fixture。
### 7.4 清理验收
- 以“旧标识 0 引用”为验收标准,至少覆盖:旧 action id、旧 category id、旧入口函数名。
- 输出固定 grep 关键字清单并逐条验收。
- 提醒链路测试通过。
- 删除项对应测试通过,且无悬挂 fixture/snapshot 引用。
- 手工回归覆盖前台/后台/终止态三种状态。
## 8. 测试与验证
### 8.1 自动化
- 新增/更新 reminders 相关单测:
- 动作映射正确性(notification/app sheet -> executor)。
- `archive` 的 outbox 行为与重试退避逻辑。
- `snooze10m` 在边界时间的调度行为。
- 同一 `actionExecutionId` 重复投递仅执行一次(幂等)。
- 终止态 cold-start queue 回放不丢失且顺序一致。
- 前台面板与系统通知并发触发时仅产生一次业务副作用。
### 8.2 手工验证矩阵
- Android:
- 前台:面板按钮可用。
- 后台:通知动作可用。
- 杀进程:通知动作可用。
- iOS:
- 前台:面板按钮可用。
- 后台/锁屏:通知展开后动作可用。
- 安装升级后 category 动作可用。
## 9. 风险与缓解
- **风险**iOS category 缓存导致动作更新不生效。
- **缓解**category id 版本化 + 明确重装验证步骤。
- **风险**:后台动作 isolate 未正确注册导致点击丢失。
- **缓解**AppDelegate/Manifest 严格按插件要求配置,并做终止态回归。
- **风险**:前台面板与系统通知并发触发造成重复操作。
- **缓解**PresentationCoordinator 增加去重窗口与事件级幂等保护。
- **风险**:通知权限未授权导致后台提醒不可达。
- **缓解**:启动期权限检查 + 降级提示 + 埋点追踪。
## 10. 完成定义(DoD
- App 关闭状态下,系统通知可触达并可执行两个动作。
- `取消` 在业务上严格等价于归档。
- 前台统一提醒面板上线,Android 样式符合项目视觉语言。
- 动作执行链路唯一,平台仅保留桥接差异。
- 历史无用代码完成清理,且通过验证。
- 手工矩阵 6/6 场景通过(Android 前台/后台/杀进程 + iOS 前台/后台锁屏/升级后)。
- 动作日志可追踪,重复执行率=0(基于 `actionExecutionId` 统计)。
@@ -0,0 +1,113 @@
# 待办事项四象限拖拽交互设计
## 概述
四象限待办页面支持待办项在象限内排序以及跨象限拖拽移动,同时保持与后端的数据同步。
## 交互设计
### 拖拽状态
| 状态 | 视觉反馈 |
|------|----------|
| 按住(未拖拽) | 卡片 scale 1.0,轻微阴影 |
| 拖拽开始 | 卡片 scale 1.03 + 阴影加深,原位置保留半透明占位框 |
| 拖拽中 | 卡片跟随手指(transform),目标象限边框高亮发光 |
| 释放-象限内排序 | 卡片平滑移动到新位置(200ms ease-out |
| 释放-跨象限移动 | 卡片以 spring 动画弹入目标位置 |
| 操作完成 | 显示成功 Toast |
### 动画参数
- **micro-interaction**: 150-300ms
- **easing**: ease-out 进入,ease-in 退出
- **spring**: 用于跨象限移动,natural feel
- **scale feedback**: 0.95-1.05 on press
- **exit faster than enter**: 退出时长是进入的 60-70%
### 防误触
- 拖拽启动延迟:100-150ms 确认是长按而非点击
- 仅在按住并移动超过阈值后启动拖拽
## 数据流
### 状态管理
```
_QuadrantScreenState
├── List<TodoResponse> _todos
├── DragState _dragState (null / dragging)
└── int? _dragTargetQuadrant (1, 2, 3)
```
### API 交互
1. **象限内排序**:调用 `PUT /todos/{id}` 更新 `priority``sort_order`
2. **跨象限移动**:调用 `PUT /todos/{id}` 更新 `priority`
### 乐观更新
- 用户释放后立即更新本地 UI
- 后端请求失败时回滚 + 显示错误 Toast
## 组件结构
```
TodoQuadrantsScreen
├── _QuadrantDragContainer (LongPressDraggable + DragTarget)
│ ├── _QuadrantCard (象限容器)
│ └── _TodoDragItem (可拖拽待办项)
└── _DragFeedbackWidget (拖拽中的视觉反馈)
```
## 状态定义
| 状态 | 描述 |
|------|------|
| `idle` | 正常显示 |
| `dragging` | 正在拖拽某项 |
| `dragOverQuadrant` | 拖拽到某象限上方 |
| `reordering` | 正在执行排序动画 |
## 优先级定义
| 象限 | Priority Value |
|------|---------------|
| 重要紧急 | 1 |
| 紧急不重要 | 3 |
| 重要不紧急 | 2 |
## 视觉规范
### 卡片样式
- **正常**: `color: AppColors.todoCardBg`, `borderRadius: 14px`
- **拖拽中**: `opacity: 0.5` 在原位置显示占位
- **跟随手指**: `scale: 1.03`, `shadow: elevated`
### 象限边框高亮
- **正常**: `border: 1px solid {quadrantBorderColor}`
- **dragOver**: `border: 2px solid AppColors.blue400`, `boxShadow: 0 0 12px AppColors.blue200`
### 插入指示器
- 高度 2px,圆角 1px
- 颜色:`AppColors.blue500`
- 位置:两个待办项之间
## 错误处理
| 场景 | 处理方式 |
|------|----------|
| 后端请求失败 | 回滚本地状态,显示错误 Toast |
| 网络断开 | 显示网络错误提示 |
| 并发冲突 | 以最新数据为准,提示用户刷新 |
## 实现要点
1. 使用 Flutter `LongPressDraggable` + `DragTarget` 实现拖拽
2. 使用 `AnimatedContainer` / `AnimatedPositioned` 实现平滑动画
3. 乐观更新:先更新 UI,后请求后端
4. 拖拽反馈使用 `Transform` 而非改变位置,避免 CLS