diff --git a/docs/plans/2026-03-20-reminder-overlay-implementation-plan.md b/docs/plans/2026-03-20-reminder-overlay-implementation-plan.md new file mode 100644 index 0000000..10a3d21 --- /dev/null +++ b/docs/plans/2026-03-20-reminder-overlay-implementation-plan.md @@ -0,0 +1,760 @@ +# Reminder Overlay Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 简化日历提醒机制,用 ReminderOverlay 统一处理所有用户交互,移除前台/后台判断逻辑。 + +**Architecture:** 每条通知独立 payload,点击通知后打开 ReminderOverlay 处理用户操作(完成/稍后提醒)。同分钟多条通知按时间排序依次处理。操作完成后 app 退到后台。iOS 冷启动通过 UserDefaults 传递 payload,Android 使用 full-screen intent。 + +**Tech Stack:** Flutter, flutter_local_notifications, Provider/Bloc (状态管理) + +--- + +## Task 1: 创建 ReminderQueueManager + +**Files:** +- Create: `apps/lib/features/calendar/reminders/reminder_queue_manager.dart` +- Test: `apps/test/features/calendar/reminders/reminder_queue_manager_test.dart` + +**Step 1: 编写测试** + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart'; +import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart'; + +void main() { + group('ReminderQueueManager', () { + test('按点击顺序处理,第一条处理完后处理剩余的按时间排序', () { + final manager = ReminderQueueManager(); + + final event1 = ReminderPayload(eventId: '1', title: 'Event 1', startAt: DateTime(2026, 3, 20, 10, 1), mode: ReminderPayloadMode.single); + final event2 = ReminderPayload(eventId: '2', title: 'Event 2', startAt: DateTime(2026, 3, 20, 10, 2), mode: ReminderPayloadMode.single); + final event3 = ReminderPayload(eventId: '3', title: 'Event 3', startAt: DateTime(2026, 3, 20, 10, 3), mode: ReminderPayloadMode.single); + + // 用户点击 event2 + manager.enqueueFromClick(event2); + // 剩余 event1 和 event3 按时间排序: event1 -> event3 + manager.enqueuePending([event1, event3]); + + expect(manager.currentPayload?.eventId, '2'); + manager.dequeueCurrent(); + expect(manager.currentPayload?.eventId, '1'); + manager.dequeueCurrent(); + expect(manager.currentPayload?.eventId, '3'); + manager.dequeueCurrent(); + expect(manager.isEmpty, true); + }); + + test('单条通知处理完直接清空', () { + final manager = ReminderQueueManager(); + final event = ReminderPayload(eventId: '1', title: 'Event 1', startAt: DateTime.now(), mode: ReminderPayloadMode.single); + + manager.enqueueFromClick(event); + expect(manager.isEmpty, false); + manager.dequeueCurrent(); + expect(manager.isEmpty, true); + }); + }); +} +``` + +**Step 2: 运行测试验证失败** + +Run: `cd apps && flutter test test/features/calendar/reminders/reminder_queue_manager_test.dart` +Expected: FAIL - ReminderQueueManager not defined + +**Step 3: 编写最小实现** + +```dart +import 'reminder_payload.dart'; + +class ReminderQueueManager { + ReminderPayload? _currentPayload; + final List _pending = []; + + void enqueueFromClick(ReminderPayload payload) { + _currentPayload = payload; + } + + void enqueuePending(List payloads) { + payloads.sort((a, b) => a.startAt.compareTo(b.startAt)); + _pending.addAll(payloads); + } + + ReminderPayload? get currentPayload => _currentPayload; + + bool get isEmpty => _currentPayload == null && _pending.isEmpty; + + void dequeueCurrent() { + _currentPayload = null; + if (_pending.isNotEmpty) { + _currentPayload = _pending.removeAt(0); + } + } + + void clear() { + _currentPayload = null; + _pending.clear(); + } +} +``` + +**Step 4: 运行测试验证通过** + +Run: `cd apps && flutter test test/features/calendar/reminders/reminder_queue_manager_test.dart` +Expected: PASS + +**Step 5: 提交** + +```bash +git add apps/lib/features/calendar/reminders/reminder_queue_manager.dart apps/test/features/calendar/reminders/reminder_queue_manager_test.dart +git commit -m "feat(calendar): add ReminderQueueManager for handling multiple notifications" +``` + +--- + +## Task 2: 创建 IOSNotificationPayloadBridge + +**Files:** +- Create: `apps/lib/core/notifications/ios_notification_payload_bridge.dart` +- Test: `apps/test/core/notifications/ios_notification_payload_bridge_test.dart` + +**Step 1: 编写测试** + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:social_app/core/notifications/ios_notification_payload_bridge.dart'; +import 'dart:convert'; + +void main() { + group('IOSNotificationPayloadBridge', () { + test('启动时读取待处理的 notification payload', () async { + SharedPreferences.setMockInitialValues({ + 'pending_notification_payload': jsonEncode({ + 'eventId': 'evt_123', + 'title': 'Test Event', + 'startAt': '2026-03-20T10:00:00Z', + 'mode': 'single', + }), + }); + + final prefs = await SharedPreferences.getInstance(); + final bridge = IOSNotificationPayloadBridge(prefs); + final payload = await bridge.getPendingPayload(); + + expect(payload?.eventId, 'evt_123'); + expect(payload?.title, 'Test Event'); + }); + + test('处理完成后清理 UserDefaults', () async { + SharedPreferences.setMockInitialValues({ + 'pending_notification_payload': jsonEncode({ + 'eventId': 'evt_123', + 'title': 'Test Event', + 'startAt': '2026-03-20T10:00:00Z', + 'mode': 'single', + }), + }); + + final prefs = await SharedPreferences.getInstance(); + final bridge = IOSNotificationPayloadBridge(prefs); + await bridge.clearPendingPayload(); + + final remaining = prefs.getString('pending_notification_payload'); + expect(remaining, isNull); + }); + }); +} +``` + +**Step 2: 运行测试验证失败** + +Run: `cd apps && flutter test test/core/notifications/ios_notification_payload_bridge_test.dart` +Expected: FAIL - IOSNotificationPayloadBridge not defined + +**Step 3: 编写最小实现** + +```dart +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../features/calendar/reminders/models/reminder_payload.dart'; + +class IOSNotificationPayloadBridge { + static const String _key = 'pending_notification_payload'; + final SharedPreferences _prefs; + + IOSNotificationPayloadBridge(this._prefs); + + Future getPendingPayload() async { + final raw = _prefs.getString(_key); + if (raw == null || raw.isEmpty) { + return null; + } + try { + final json = Map.from(jsonDecode(raw) as Map); + return ReminderPayload.fromJson(json); + } catch (_) { + return null; + } + } + + Future clearPendingPayload() async { + await _prefs.remove(_key); + } +} +``` + +**Step 4: 运行测试验证通过** + +Run: `cd apps && flutter test test/core/notifications/ios_notification_payload_bridge_test.dart` +Expected: PASS + +**Step 5: 提交** + +```bash +git add apps/lib/core/notifications/ios_notification_payload_bridge.dart apps/test/core/notifications/ios_notification_payload_bridge_test.dart +git commit -m "feat(notifications): add IOSNotificationPayloadBridge for cold start handling" +``` + +--- + +## Task 3: 创建 ReminderOverlay UI 组件 + +**Files:** +- Create: `apps/lib/features/calendar/reminders/ui/reminder_overlay.dart` +- Test: `apps/test/features/calendar/reminders/reminder_overlay_test.dart` + +**Step 1: 编写测试** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:social_app/features/calendar/reminders/ui/reminder_overlay.dart'; +import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart'; +import 'package:social_app/core/notifications/local_notification_service.dart'; +import 'package:social_app/core/notifications/ios_notification_payload_bridge.dart'; +import 'package:social_app/features/calendar/data/services/calendar_service.dart'; +import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart'; +import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart'; + +void main() { + group('ReminderOverlay', () { + late ReminderQueueManager queueManager; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + queueManager = ReminderQueueManager(); + }); + + testWidgets('显示日程标题和当前时间', (tester) async { + final payload = ReminderPayload( + eventId: '1', + title: 'Test Meeting', + startAt: DateTime(2026, 3, 20, 10, 0), + mode: ReminderPayloadMode.single, + ); + queueManager.enqueueFromClick(payload); + + await tester.pumpWidget( + MaterialApp( + home: ReminderOverlay( + queueManager: queueManager, + onComplete: () {}, + onSnooze: (minutes) {}, + onArchive: () {}, + ), + ), + ); + + expect(find.text('Test Meeting'), findsOneWidget); + // 当前时间显示在界面上 + expect(find.textContaining(':'), findsWidgets); + }); + + testWidgets('点击完成按钮触发归档', (tester) async { + bool archiveCalled = false; + final payload = ReminderPayload( + eventId: '1', + title: 'Test Meeting', + startAt: DateTime(2026, 3, 20, 10, 0), + mode: ReminderPayloadMode.single, + ); + queueManager.enqueueFromClick(payload); + + await tester.pumpWidget( + MaterialApp( + home: ReminderOverlay( + queueManager: queueManager, + onComplete: () {}, + onSnooze: (minutes) {}, + onArchive: () => archiveCalled = true, + ), + ), + ); + + await tester.tap(find.text('完成')); + await tester.pump(); + + expect(archiveCalled, true); + }); + + testWidgets('点击稍后提醒显示下拉选项', (tester) async { + final payload = ReminderPayload( + eventId: '1', + title: 'Test Meeting', + startAt: DateTime(2026, 3, 20, 10, 0), + mode: ReminderPayloadMode.single, + ); + queueManager.enqueueFromClick(payload); + + await tester.pumpWidget( + MaterialApp( + home: ReminderOverlay( + queueManager: queueManager, + onComplete: () {}, + onSnooze: (minutes) {}, + onArchive: () {}, + ), + ), + ); + + await tester.tap(find.text('稍后提醒')); + await tester.pumpAndSettle(); + + expect(find.text('5 分钟'), findsOneWidget); + expect(find.text('15 分钟'), findsOneWidget); + }); + }); +} +``` + +**Step 2: 运行测试验证失败** + +Run: `cd apps && flutter test test/features/calendar/reminders/reminder_overlay_test.dart` +Expected: FAIL - ReminderOverlay not defined + +**Step 3: 编写最小实现** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/theme/design_tokens.dart'; +import '../../../../shared/widgets/app_button.dart'; +import '../../reminders/reminder_queue_manager.dart'; +import '../../reminders/models/reminder_payload.dart'; + +class ReminderOverlay extends StatefulWidget { + const ReminderOverlay({ + super.key, + required this.queueManager, + required this.onComplete, + required this.onSnooze, + required this.onArchive, + }); + + final ReminderQueueManager queueManager; + final VoidCallback onComplete; + final void Function(int minutes) onSnooze; + final VoidCallback onArchive; + + @override + State createState() => _ReminderOverlayState(); +} + +class _ReminderOverlayState extends State { + bool _showSnoozeOptions = false; + final LayerLink _layerLink = LayerLink(); + OverlayEntry? _overlayEntry; + + ReminderPayload? get _currentPayload => widget.queueManager.currentPayload; + + @override + void dispose() { + _hideSnoozeOptions(); + super.dispose(); + } + + void _hideSnoozeOptions() { + _overlayEntry?.remove(); + _overlayEntry = null; + setState(() { + _showSnoozeOptions = false; + }); + } + + void _showSnoozeDropdown() { + _hideSnoozeOptions(); + + _overlayEntry = OverlayEntry( + builder: (context) => Positioned( + width: 120, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _SnoozeOption( + label: '5 分钟', + onTap: () { + _hideSnoozeOptions(); + widget.onSnooze(5); + }, + ), + Divider(height: 1, color: AppColors.borderSecondary), + _SnoozeOption( + label: '15 分钟', + onTap: () { + _hideSnoozeOptions(); + widget.onSnooze(15); + }, + ), + ], + ), + ), + ), + ), + ); + + Overlay.of(context).insert(_overlayEntry!); + setState(() { + _showSnoozeOptions = true; + }); + } + + void _handleComplete() { + widget.onArchive(); + widget.queueManager.dequeueCurrent(); + if (!widget.queueManager.isEmpty) { + // 下一条会通过外部状态管理打开新的 overlay + } + widget.onComplete(); + } + + void _handleSnooze(int minutes) { + widget.onSnooze(minutes); + widget.queueManager.dequeueCurrent(); + if (!widget.queueManager.isEmpty) { + // 下一条会通过外部状态管理打开新的 overlay + } + widget.onComplete(); + } + + @override + Widget build(BuildContext context) { + final payload = _currentPayload; + if (payload == null) { + return const SizedBox.shrink(); + } + + return Container( + color: AppColors.white, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + payload.title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: AppColors.slate900, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.sm), + Text( + DateFormat('HH:mm').format(DateTime.now()), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.slate500, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.xl), + Row( + children: [ + Expanded( + child: CompositedTransformTarget( + link: _layerLink, + child: AppButton( + text: '稍后提醒', + isOutlined: true, + onPressed: _showSnoozeDropdown, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AppButton( + text: '完成', + onPressed: _handleComplete, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _SnoozeOption extends StatelessWidget { + const _SnoozeOption({ + required this.label, + required this.onTap, + }); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.slate900, + ), + ), + ), + ); + } +} +``` + +**Step 4: 运行测试验证通过** + +Run: `cd apps && flutter test test/features/calendar/reminders/reminder_overlay_test.dart` +Expected: PASS + +**Step 5: 提交** + +```bash +git add apps/lib/features/calendar/reminders/ui/reminder_overlay.dart apps/test/features/calendar/reminders/reminder_overlay_test.dart +git commit -m "feat(calendar): add ReminderOverlay UI component" +``` + +--- + +## Task 4: 修改 LocalNotificationService - 移除权限降级逻辑 + +**Files:** +- Modify: `apps/lib/core/notifications/local_notification_service.dart` + +**Step 1: 阅读现有代码确认移除范围** + +Read: `apps/lib/core/notifications/local_notification_service.dart` + +**Step 2: 移除以下逻辑** + +1. 移除 `_canDeliverSystemNotification` 相关判断 +2. 移除 `_scheduleInAppFallbackRemindersFrom` 方法 +3. 移除 `_scheduleInAppFallbackPayload` 方法 +4. 移除 `_inAppFallbackTimersByEventId` 及相关方法 +5. 移除 `_trackFallback` 方法 +6. 移除 `rebuildUpcomingReminders` 中的降级分支 + +**Step 3: 验证 flutter analyze 通过** + +Run: `cd apps && flutter analyze lib/core/notifications/local_notification_service.dart` +Expected: 无错误 + +**Step 4: 提交** + +```bash +git add apps/lib/core/notifications/local_notification_service.dart +git commit -m "refactor(notifications): remove permission fallback logic" +``` + +--- + +## Task 5: 修改通知发送逻辑 - 使用原生分组 + +**Files:** +- Modify: `apps/lib/core/notifications/local_notification_service.dart` + +**Step 1: 添加 threadIdentifier/groupKey 到通知详情** + +在 `_buildNotificationDetails` 方法中: +- iOS: 添加 `threadIdentifier: _getThreadIdentifier(fireAt)` +- Android: 添加 `groupKey: _getGroupKey(fireAt)` + +**Step 2: 实现分组 key 方法** + +```dart +String _getThreadIdentifier(DateTime fireAt) { + final bucket = fireAt.millisecondsSinceEpoch ~/ const Duration(minutes: 1).inMilliseconds; + return 'calendar_reminder_$bucket'; +} + +String _getGroupKey(DateTime fireAt) { + final bucket = fireAt.millisecondsSinceEpoch ~/ const Duration(minutes: 1).inMilliseconds; + return 'com.socialapp.calendar.$bucket'; +} +``` + +**Step 3: 验证 flutter analyze 通过** + +Run: `cd apps && flutter analyze lib/core/notifications/local_notification_service.dart` +Expected: 无错误 + +**Step 4: 提交** + +```bash +git add apps/lib/core/notifications/local_notification_service.dart +git commit -m "feat(notifications): add native notification grouping by time bucket" +``` + +--- + +## Task 6: 修改 main.dart - 集成 iOS payload bridge + +**Files:** +- Modify: `apps/lib/main.dart` + +**Step 1: 在 main() 中添加启动时检查** + +在 `runApp` 之前或 app 初始化时: +1. 创建 IOSNotificationPayloadBridge 实例 +2. 调用 `getPendingPayload()` +3. 如果有 payload,通过 ReminderQueueManager 处理 + +**Step 2: 验证 flutter analyze 通过** + +Run: `cd apps && flutter analyze lib/main.dart` +Expected: 无错误 + +**Step 3: 提交** + +```bash +git add apps/lib/main.dart +git commit -m "feat(ios): integrate IOSNotificationPayloadBridge for cold start handling" +``` + +--- + +## Task 7: 删除废弃组件 + +**Files:** +- Delete: `apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart` +- Delete: `apps/lib/features/calendar/reminders/reminder_outbox_store.dart` +- Delete: `apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart` +- Delete: `apps/lib/features/calendar/reminders/reminder_overlap_policy.dart` +- Delete: `apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart` +- Delete: `apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart` +- Delete: `apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart` + +**Step 1: 删除文件** + +```bash +git rm apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart +git rm apps/lib/features/calendar/reminders/reminder_outbox_store.dart +git rm apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart +git rm apps/lib/features/calendar/reminders/reminder_overlap_policy.dart +git rm apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart +git rm apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart +git rm apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart +``` + +**Step 2: 删除测试文件** + +```bash +git rm apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart +git rm apps/test/features/calendar/reminders/reminder_outbox_store_test.dart +git rm apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart +git rm apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart +git rm apps/test/features/calendar/reminders/reminder_foreground_presenter_test.dart +git rm apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart +git rm apps/test/features/calendar/reminders/reminder_action_sheet_test.dart +``` + +**Step 3: 运行 flutter analyze 验证无引用错误** + +Run: `cd apps && flutter analyze` +Expected: 无错误(可能有 deprecated warnings 可以忽略) + +**Step 4: 提交** + +```bash +git commit -m "refactor(calendar): remove deprecated reminder components" +``` + +--- + +## Task 8: iOS 原生层配置 (AppDelegate.swift) + +**Files:** +- Modify: `apps/ios/Runner/AppDelegate.swift` + +**Step 1: 添加 UserDefaults 写入逻辑** + +在 `userNotificationCenter(_, didReceive, withCompletionHandler)` 中: +1. 获取 notification 的 `userInfo` +2. 将 payload 写入 `UserDefaults.standard` +3. Key: `pending_notification_payload` + +**Step 2: 验证 Xcode build** + +Run: `cd apps && flutter build ios --simulator --no-codesign 2>&1 | head -50` +Expected: Build 成功 + +**Step 3: 提交** + +```bash +git add apps/ios/Runner/AppDelegate.swift +git commit -m "feat(ios): write notification payload to UserDefaults on cold start" +``` + +--- + +## 验证清单 + +完成所有任务后,运行以下验证: + +```bash +# 1. Flutter analyze +cd apps && flutter analyze + +# 2. 测试 +cd apps && flutter test test/features/calendar/reminders/ + +# 3. iOS build +cd apps && flutter build ios --simulator --no-codesign + +# 4. Android build +cd apps && flutter build apk --debug +``` + +--- + +## Plan Complete + +**Plan saved to:** `docs/plans/2026-03-20-reminder-overlay-implementation-plan.md` + +**Two execution options:** + +**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration + +**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints + +**Which approach?**