From 5e2ade614fbe78eff503b1949c570b0dde37738a Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 18:22:14 +0800 Subject: [PATCH] docs: add reminder overlay design document --- .../2026-03-20-reminder-overlay-design.md | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 docs/plans/2026-03-20-reminder-overlay-design.md diff --git a/docs/plans/2026-03-20-reminder-overlay-design.md b/docs/plans/2026-03-20-reminder-overlay-design.md new file mode 100644 index 0000000..f5e4a18 --- /dev/null +++ b/docs/plans/2026-03-20-reminder-overlay-design.md @@ -0,0 +1,221 @@ +# Reminder Overlay 设计文档 + +## 概述 + +重构日历提醒机制,简化前台/后台判断逻辑,将所有提醒交互统一到独立的 ReminderOverlay 组件处理。 + +## 背景 + +当前实现复杂,涉及: +- App 启动状态判断(前台/后台) +- 离线归档请求队列 + 指数退避重试 +- 通知权限降级(Android Timer 模拟) +- 聚合通知批量操作 + +新方案利用 iOS/Android 原生通知分组能力,实现: +- 每条通知独立 payload,点击哪条处理哪条 +- 统一的 ReminderOverlay 处理所有用户交互 +- 操作完成后 app 退到后台 + +## 设计决策 + +| 决策项 | 选择 | +|--------|------| +| 关闭 overlay 后的行为 | 回到首页,保持缓存状态 | +| 同分钟多条通知处理 | 按点击顺序处理当前,剩余按时间排序 | +| iOS 冷启动 payload 传递 | UserDefaults(App Groups 方案) | +| Android 通知展示 | Full-screen intent(锁屏也弹窗) | +| 稍后提醒时间选项 | 5 分钟、15 分钟(下拉选项) | +| "完成"按钮行为 | 归档 + 关闭 + 退后台 | +| "稍后提醒"按钮行为 | 弹出选项 + 延后通知 + 关闭 + 退后台 | +| UI 组件 | 新建 ReminderOverlay(不复用现有) | + +## 核心流程 + +``` +通知到达 → 用户点击通知 → + ├─ App 已运行 → 恢复前台 → 直接收到 payload → 打开 ReminderOverlay + └─ App 未运行 → + ├─ iOS: 原生层写入 UserDefaults → Flutter 启动时读取 + └─ Android: full-screen intent 启动 → Flutter 收到 payload + +ReminderOverlay 显示: + - 日程标题 + - 当前时间 + - [稍后提醒 ▼] | [完成] + +用户操作: + ├─ 完成 → 归档请求 → 关闭 overlay → 退后台 + └─ 稍后提醒 → 选择时间 → 取消当前通知 + 注册新通知 → 关闭 overlay → 退后台 + +处理完当前 → 检查同分钟是否有多条 → + ├─ 有 → 打开下一条的 ReminderOverlay + └─ 无 → 保持退后台状态 +``` + +## 移除的组件 + +| 组件 | 文件路径 | 移除原因 | +|------|----------|----------| +| ReminderColdStartQueue | `lib/features/calendar/reminders/reminder_cold_start_queue.dart` | 不需要后台重放机制 | +| ReminderOutboxStore | `lib/features/calendar/reminders/reminder_outbox_store.dart` | 不需要离线归档队列 | +| ReminderForegroundPresenter | `lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart` | 不需要前台判断 | +| ReminderPresentationCoordinator | `lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart` | 不需要防重复展示 | +| ReminderActionDedupeStore | `lib/features/calendar/reminders/reminder_action_dedupe_store.dart` | 通知原生幂等 | +| ReminderOverlapPolicy | `lib/features/calendar/reminders/reminder_overlap_policy.dart` | 改为原生分组 | +| Android Timer 模拟逻辑 | `LocalNotificationService` 内 | 不需要权限降级 | + +## 新增组件 + +### ReminderOverlay + +独立的状态管理页面,处理提醒交互。 + +**职责**: +- 显示日程标题和当前时间 +- 提供"稍后提醒"下拉选项(5分钟/15分钟) +- 提供"完成"按钮(归档) +- 处理完成后关闭 overlay + +**文件位置**:`lib/features/calendar/reminders/ui/reminder_overlay.dart` + +### ReminderQueueManager + +管理同分钟多条通知的处理队列。 + +**职责**: +- 存储同分钟的通知列表 +- 按点击顺序跟踪当前处理项 +- 处理完当前后调度下一项 + +**文件位置**:`lib/features/calendar/reminders/reminder_queue_manager.dart` + +### IOSNotificationPayloadBridge + +iOS 冷启动时从 UserDefaults 读取 notification payload。 + +**职责**: +- App 启动时检查是否有待处理的通知 launch +- 读取 payload 并打开对应的 ReminderOverlay +- 处理完成后清理 UserDefaults + +**文件位置**:`lib/core/notifications/ios_notification_payload_bridge.dart` + +## 平台差异处理 + +### iOS + +1. **通知点击启动 App**: + - 配置 `setPluginRegistrantCallback`(已有) + - iOS 原生层将 payload 写入 UserDefaults + - Flutter 启动时 `IOSNotificationPayloadBridge` 读取数据 + +2. **通知分组**: + - 使用 `threadIdentifier` 分组 + - 同一分钟的通知使用相同的 `threadIdentifier` + +### Android + +1. **Full-screen intent**: + - `AndroidNotificationDetails` 设置 `fullScreenIntent: true` + - 锁屏时直接弹出全屏 overlay + +2. **通知分组**: + - 使用 `groupKey` 分组 + - 同一分钟的通知使用相同的 `groupKey` + +## API 变化 + +### 归档请求 + +仍然使用现有的 `CalendarService.archiveEvent()`,但不再需要失败重试逻辑。 + +``` +POST /api/v1/calendar/events/{eventId}/archive +``` + +### 通知 Payload + +```json +{ + "eventId": "evt_xxx", + "title": "日程标题", + "startAt": "2026-03-20T10:00:00Z", + "endAt": "2026-03-20T11:00:00Z", + "timezone": "Asia/Shanghai", + "mode": "single", + "fireTimeBucket": 1774060800000 +} +``` + +## 数据流 + +### 通知发送流程(不变) + +``` +CalendarService.upsertEventReminder() + → LocalNotificationService.upsertEventReminder() + → flutter_local_notifications.zonedSchedule() +``` + +### 通知点击处理流程 + +``` +用户点击通知 + ├─ App 运行中 → onDidReceiveNotificationResponse(payload) + └─ App 未运行 + ├─ iOS → 原生写入 UserDefaults → Flutter 启动 → 读取 → 打开 overlay + └─ Android → full-screen intent → Flutter 收到 payload → 打开 overlay + +ReminderOverlay 打开 + ├─ 用户点击"完成" → archiveEvent() → 关闭 → 检查队列 → 有下一条则打开下一条 + └─ 用户点击"稍后提醒" → cancelNotification() + scheduleReminderAt() → 关闭 → 检查队列 → 有下一条则打开下一条 +``` + +## 错误处理 + +| 场景 | 处理方式 | +|------|----------| +| 归档请求失败 | 显示 toast 提示用户,操作已完成(下次打开 app 时同步) | +| 延后通知注册失败 | 显示 toast 提示用户,当前提醒已取消 | +| 同分钟多条处理时其中一条失败 | 跳过该条,处理下一条 | + +## 文件变更清单 + +### 删除 + +- `lib/features/calendar/reminders/reminder_cold_start_queue.dart` +- `lib/features/calendar/reminders/reminder_outbox_store.dart` +- `lib/features/calendar/reminders/reminder_action_dedupe_store.dart` +- `lib/features/calendar/reminders/reminder_overlap_policy.dart` +- `lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart` +- `lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart` +- `lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart` +- 相关测试文件 + +### 新增 + +- `lib/features/calendar/reminders/ui/reminder_overlay.dart` +- `lib/features/calendar/reminders/reminder_queue_manager.dart` +- `lib/core/notifications/ios_notification_payload_bridge.dart` + +### 修改 + +- `lib/core/notifications/local_notification_service.dart`(移除权限降级逻辑) +- `lib/main.dart`(集成 IOSNotificationPayloadBridge) +- 相关测试文件 + +## 测试策略 + +### 单元测试 +- ReminderQueueManager: 队列排序、下一条调度 +- IOSNotificationPayloadBridge: payload 读写 + +### 集成测试 +- 通知点击 → overlay 打开 → 操作 → 关闭 +- 同分钟多条通知顺序处理 + +### 手动测试 +- iOS 冷启动点击通知 +- Android 锁屏点击 full-screen intent 通知 +- 稍后提醒 5 分钟/15 分钟验证