216 lines
8.6 KiB
Markdown
216 lines
8.6 KiB
Markdown
# 日历提醒统一交互设计(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` 统计)。
|