Files
social-app/docs/plans/2026-03-20-reminder-overlay-implementation-plan.md
T

761 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 传递 payloadAndroid 使用 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<ReminderPayload> _pending = [];
void enqueueFromClick(ReminderPayload payload) {
_currentPayload = payload;
}
void enqueuePending(List<ReminderPayload> 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<ReminderPayload?> getPendingPayload() async {
final raw = _prefs.getString(_key);
if (raw == null || raw.isEmpty) {
return null;
}
try {
final json = Map<String, dynamic>.from(jsonDecode(raw) as Map);
return ReminderPayload.fromJson(json);
} catch (_) {
return null;
}
}
Future<void> 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<ReminderOverlay> createState() => _ReminderOverlayState();
}
class _ReminderOverlayState extends State<ReminderOverlay> {
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?**