761 lines
22 KiB
Markdown
761 lines
22 KiB
Markdown
|
|
# 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<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?**
|