# 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?**