diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index b8713dc..db2d5ab 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
@@ -34,6 +35,8 @@
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
+ android:showWhenLocked="true"
+ android:turnScreenOn="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart
index 4d4199f..01d26ee 100644
--- a/apps/lib/core/di/injection.dart
+++ b/apps/lib/core/di/injection.dart
@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import '../api/api_client.dart';
import '../api/i_api_client.dart';
import '../storage/token_storage.dart';
@@ -13,6 +14,8 @@ import '../../features/auth/presentation/bloc/auth_bloc.dart';
import '../../features/auth/presentation/bloc/auth_event.dart';
import '../../features/calendar/data/calendar_api.dart';
import '../../features/calendar/data/services/calendar_service.dart';
+import '../../features/calendar/reminders/reminder_action_executor.dart';
+import '../../features/calendar/reminders/reminder_outbox_store.dart';
import '../../features/calendar/ui/calendar_state_manager.dart';
import '../../features/friends/data/friends_api.dart';
import '../../features/messages/data/inbox_api.dart';
@@ -49,6 +52,9 @@ Future configureDependencies() async {
final authApi = AuthApi(apiClient);
sl.registerSingleton(authApi);
+ final sharedPreferences = await SharedPreferences.getInstance();
+ sl.registerSingleton(sharedPreferences);
+
final usersApi = UsersApi(apiClient);
sl.registerSingleton(usersApi);
@@ -58,8 +64,18 @@ Future configureDependencies() async {
final calendarService = CalendarService(apiClient: apiClient);
sl.registerSingleton(calendarService);
+ final reminderOutboxStore = ReminderOutboxStore(sharedPreferences);
+ sl.registerSingleton(reminderOutboxStore);
+
sl.registerSingleton(LocalNotificationService());
+ final reminderActionExecutor = ReminderActionExecutor(
+ calendarService: calendarService,
+ notificationService: sl(),
+ outboxStore: reminderOutboxStore,
+ );
+ sl.registerSingleton(reminderActionExecutor);
+
final friendsApi = FriendsApi(apiClient);
sl.registerSingleton(friendsApi);
diff --git a/apps/lib/core/notifications/local_notification_service.dart b/apps/lib/core/notifications/local_notification_service.dart
index 7f6df15..424b51d 100644
--- a/apps/lib/core/notifications/local_notification_service.dart
+++ b/apps/lib/core/notifications/local_notification_service.dart
@@ -1,26 +1,40 @@
-import 'package:flutter_local_notifications/flutter_local_notifications.dart';
+import 'dart:convert';
+
import 'package:flutter/foundation.dart';
+import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
import '../../features/calendar/data/models/schedule_item_model.dart';
+import '../../features/calendar/reminders/models/reminder_action.dart';
+import '../../features/calendar/reminders/models/reminder_payload.dart';
+import '../../features/calendar/reminders/reminder_overlap_policy.dart';
-class NotificationScheduleException implements Exception {
- final String message;
-
- NotificationScheduleException(this.message);
-
- @override
- String toString() => message;
-}
+typedef ReminderNotificationActionHandler =
+ Future Function({
+ required ReminderAction action,
+ required ReminderPayload payload,
+ });
class LocalNotificationService {
- final FlutterLocalNotificationsPlugin _plugin;
- bool _initialized = false;
- bool _exactAlarmPermissionRequested = false;
+ static const String _iosCategoryId = 'calendar_reminder_actions_v1';
+ static const String _actionCancel = 'cancel';
+ static const String _actionSnooze = 'snooze_10m';
- LocalNotificationService({FlutterLocalNotificationsPlugin? plugin})
- : _plugin = plugin ?? FlutterLocalNotificationsPlugin();
+ final FlutterLocalNotificationsPlugin _plugin;
+ final ReminderOverlapPolicy _overlapPolicy;
+ bool _initialized = false;
+ ReminderNotificationActionHandler? _actionHandler;
+
+ LocalNotificationService({
+ FlutterLocalNotificationsPlugin? plugin,
+ ReminderOverlapPolicy? overlapPolicy,
+ }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(),
+ _overlapPolicy = overlapPolicy ?? const ReminderOverlapPolicy();
+
+ void bindActionHandler(ReminderNotificationActionHandler handler) {
+ _actionHandler = handler;
+ }
Future initialize() async {
if (_initialized) {
@@ -29,20 +43,40 @@ class LocalNotificationService {
tz_data.initializeTimeZones();
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
- const ios = DarwinInitializationSettings(
+ final ios = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
+ notificationCategories: [
+ DarwinNotificationCategory(
+ _iosCategoryId,
+ actions: [
+ DarwinNotificationAction.plain(_actionCancel, '取消'),
+ DarwinNotificationAction.plain(
+ _actionSnooze,
+ '稍后提醒',
+ options: {
+ DarwinNotificationActionOption.foreground,
+ },
+ ),
+ ],
+ ),
+ ],
);
- const settings = InitializationSettings(android: android, iOS: ios);
+ final settings = InitializationSettings(android: android, iOS: ios);
- await _plugin.initialize(settings);
+ await _plugin.initialize(
+ settings,
+ onDidReceiveNotificationResponse: _onNotificationResponse,
+ );
final androidImpl = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
await androidImpl?.requestNotificationsPermission();
+ await androidImpl?.requestExactAlarmsPermission();
+ await androidImpl?.requestFullScreenIntentPermission();
final iosImpl = _plugin
.resolvePlatformSpecificImplementation<
@@ -55,115 +89,46 @@ class LocalNotificationService {
Future upsertEventReminder(ScheduleItemModel event) async {
await initialize();
-
- final reminderMinutes = event.metadata?.reminderMinutes;
- if (reminderMinutes == null) {
+ if (event.status != ScheduleStatus.active ||
+ event.metadata?.reminderMinutes == null) {
await cancelEventReminder(event.id);
return;
}
- final fireAt = event.startAt.subtract(Duration(minutes: reminderMinutes));
- if (!fireAt.isAfter(DateTime.now())) {
+ final now = DateTime.now();
+ final fireAt = _overlapPolicy.resolveFirstFireAt(event, now: now);
+ if (fireAt == null) {
await cancelEventReminder(event.id);
return;
}
- final notificationId = _notificationIdForEvent(event.id);
- final scheduledAt = tz.TZDateTime.from(fireAt, tz.local);
- final androidImpl = _plugin
- .resolvePlatformSpecificImplementation<
- AndroidFlutterLocalNotificationsPlugin
- >();
+ await cancelEventReminder(event.id);
+ await _scheduleRemindersFrom(event: event, firstFireAt: fireAt);
+ }
- var androidScheduleMode = AndroidScheduleMode.exactAllowWhileIdle;
- if (defaultTargetPlatform == TargetPlatform.android &&
- androidImpl != null) {
- var notificationsEnabled =
- await androidImpl.areNotificationsEnabled() ?? false;
- if (!notificationsEnabled) {
- await androidImpl.requestNotificationsPermission();
- notificationsEnabled =
- await androidImpl.areNotificationsEnabled() ?? false;
- }
- if (!notificationsEnabled) {
- throw NotificationScheduleException('系统通知权限未开启,无法创建提醒');
- }
-
- try {
- var canScheduleExact =
- await androidImpl.canScheduleExactNotifications() ?? false;
- if (!canScheduleExact && !_exactAlarmPermissionRequested) {
- _exactAlarmPermissionRequested = true;
- await androidImpl.requestExactAlarmsPermission();
- canScheduleExact =
- await androidImpl.canScheduleExactNotifications() ?? false;
- }
- if (!canScheduleExact) {
- androidScheduleMode = AndroidScheduleMode.inexactAllowWhileIdle;
- }
- } catch (_) {
- androidScheduleMode = AndroidScheduleMode.inexactAllowWhileIdle;
- }
- }
-
- final details = NotificationDetails(
- android: AndroidNotificationDetails(
- 'calendar_reminder_channel',
- '日历提醒',
- channelDescription: '日历事件提醒通知',
- importance: Importance.max,
- priority: Priority.high,
- enableVibration: true,
- ),
- iOS: const DarwinNotificationDetails(
- presentAlert: true,
- presentSound: true,
- presentBadge: true,
- ),
- );
-
- try {
- await _plugin.zonedSchedule(
- notificationId,
- event.title,
- _buildReminderBody(event, reminderMinutes),
- scheduledAt,
- details,
- androidScheduleMode: androidScheduleMode,
- uiLocalNotificationDateInterpretation:
- UILocalNotificationDateInterpretation.absoluteTime,
- );
-
- final pending = await _plugin.pendingNotificationRequests();
- final scheduled = pending.any((item) => item.id == notificationId);
- if (!scheduled) {
- throw NotificationScheduleException('提醒未被系统接受,请检查系统通知和电池优化设置');
- }
- } catch (error) {
- if (error is NotificationScheduleException) {
- rethrow;
- }
- await _plugin.zonedSchedule(
- notificationId,
- event.title,
- _buildReminderBody(event, reminderMinutes),
- scheduledAt,
- details,
- androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
- uiLocalNotificationDateInterpretation:
- UILocalNotificationDateInterpretation.absoluteTime,
- );
-
- final pending = await _plugin.pendingNotificationRequests();
- final scheduled = pending.any((item) => item.id == notificationId);
- if (!scheduled) {
- throw NotificationScheduleException('提醒创建失败,请检查系统设置后重试');
- }
- }
+ Future scheduleReminderAt(
+ ScheduleItemModel event,
+ DateTime fireAt,
+ ) async {
+ await initialize();
+ await cancelEventReminder(event.id);
+ await _scheduleRemindersFrom(event: event, firstFireAt: fireAt);
}
Future cancelEventReminder(String eventId) async {
await initialize();
+ final pending = await _plugin.pendingNotificationRequests();
+ for (final request in pending) {
+ final payload = _decodePayload(request.payload);
+ if (payload == null) {
+ continue;
+ }
+ if (payload.eventId == eventId ||
+ payload.aggregateIds.contains(eventId)) {
+ await _plugin.cancel(request.id);
+ }
+ }
+
await _plugin.cancel(_notificationIdForEvent(eventId));
}
@@ -171,8 +136,15 @@ class LocalNotificationService {
Iterable events,
) async {
await initialize();
- for (final event in events) {
- await upsertEventReminder(event);
+
+ final now = DateTime.now();
+ final groups = _overlapPolicy.groupByMinute(events, now: now);
+ for (final group in groups) {
+ if (group.isAggregate) {
+ await _scheduleAggregateReminder(group.events, group.fireAt);
+ continue;
+ }
+ await upsertEventReminder(group.events.first);
}
}
@@ -180,10 +152,243 @@ class LocalNotificationService {
return eventId.hashCode & 0x7fffffff;
}
- String _buildReminderBody(ScheduleItemModel event, int reminderMinutes) {
- if (reminderMinutes == 0) {
- return '日程现在开始:${event.title}';
+ int _notificationIdForEventCycle(
+ String eventId,
+ DateTime fireAt,
+ ReminderPayloadMode mode,
+ ) {
+ final cycleMinute =
+ fireAt.millisecondsSinceEpoch ~/
+ const Duration(minutes: 1).inMilliseconds;
+ return '$eventId|$cycleMinute|${mode.value}'.hashCode & 0x7fffffff;
+ }
+
+ Future _resolveAndroidScheduleMode() async {
+ if (defaultTargetPlatform != TargetPlatform.android) {
+ return AndroidScheduleMode.exactAllowWhileIdle;
+ }
+
+ final androidImpl = _plugin
+ .resolvePlatformSpecificImplementation<
+ AndroidFlutterLocalNotificationsPlugin
+ >();
+ if (androidImpl == null) {
+ return AndroidScheduleMode.exactAllowWhileIdle;
+ }
+
+ final canScheduleExact =
+ await androidImpl.canScheduleExactNotifications() ?? false;
+ return canScheduleExact
+ ? AndroidScheduleMode.exactAllowWhileIdle
+ : AndroidScheduleMode.inexactAllowWhileIdle;
+ }
+
+ NotificationDetails _buildNotificationDetails() {
+ return NotificationDetails(
+ android: AndroidNotificationDetails(
+ 'calendar_alarm_channel_v2',
+ '日程闹钟提醒',
+ channelDescription: '日程到点闹钟式提醒通知',
+ importance: Importance.max,
+ priority: Priority.max,
+ category: AndroidNotificationCategory.alarm,
+ audioAttributesUsage: AudioAttributesUsage.alarm,
+ fullScreenIntent: true,
+ playSound: true,
+ enableVibration: true,
+ vibrationPattern: Int64List.fromList([0, 1000, 500, 1200]),
+ timeoutAfter: 30000,
+ autoCancel: true,
+ actions: [
+ AndroidNotificationAction(_actionCancel, '取消'),
+ AndroidNotificationAction(
+ _actionSnooze,
+ '稍后提醒',
+ showsUserInterface: true,
+ ),
+ ],
+ ),
+ iOS: const DarwinNotificationDetails(
+ presentAlert: true,
+ presentSound: true,
+ presentBadge: true,
+ categoryIdentifier: _iosCategoryId,
+ ),
+ );
+ }
+
+ Future _scheduleSingleReminder({
+ required ScheduleItemModel event,
+ required DateTime fireAt,
+ }) async {
+ final notificationId = _notificationIdForEventCycle(
+ event.id,
+ fireAt,
+ ReminderPayloadMode.single,
+ );
+ final payload = ReminderPayload(
+ eventId: event.id,
+ title: event.title,
+ startAt: event.startAt,
+ endAt: event.endAt,
+ timezone: event.timezone,
+ location: event.metadata?.location,
+ notes: event.metadata?.notes,
+ color: event.metadata?.color,
+ mode: ReminderPayloadMode.single,
+ version: 1,
+ );
+
+ final details = _buildNotificationDetails();
+ final scheduledAt = tz.TZDateTime.from(fireAt, tz.local);
+ final mode = await _resolveAndroidScheduleMode();
+
+ try {
+ await _plugin.zonedSchedule(
+ notificationId,
+ event.title,
+ _buildReminderBody(event, event.metadata?.reminderMinutes ?? 0),
+ scheduledAt,
+ details,
+ payload: jsonEncode(payload.toJson()),
+ androidScheduleMode: mode,
+ uiLocalNotificationDateInterpretation:
+ UILocalNotificationDateInterpretation.absoluteTime,
+ );
+ } catch (_) {
+ await _plugin.zonedSchedule(
+ notificationId,
+ event.title,
+ _buildReminderBody(event, event.metadata?.reminderMinutes ?? 0),
+ scheduledAt,
+ details,
+ payload: jsonEncode(payload.toJson()),
+ androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
+ uiLocalNotificationDateInterpretation:
+ UILocalNotificationDateInterpretation.absoluteTime,
+ );
+ }
+ }
+
+ Future _scheduleRemindersFrom({
+ required ScheduleItemModel event,
+ required DateTime firstFireAt,
+ }) async {
+ final endAt = event.endAt;
+ var cursor = firstFireAt;
+ if (endAt == null) {
+ await _scheduleSingleReminder(event: event, fireAt: cursor);
+ return;
+ }
+
+ while (cursor.isBefore(endAt)) {
+ await _scheduleSingleReminder(event: event, fireAt: cursor);
+ cursor = cursor.add(const Duration(minutes: 10));
+ }
+ }
+
+ Future _scheduleAggregateReminder(
+ List events,
+ DateTime fireAt,
+ ) async {
+ if (events.isEmpty) {
+ return;
+ }
+
+ final first = events.first;
+ final aggregateIds = events.map((event) => event.id).toList();
+ for (final id in aggregateIds) {
+ await cancelEventReminder(id);
+ }
+
+ final payload = ReminderPayload(
+ eventId: first.id,
+ title: '你有${events.length}个日程提醒',
+ startAt: first.startAt,
+ endAt: first.endAt,
+ timezone: first.timezone,
+ mode: ReminderPayloadMode.aggregate,
+ aggregateIds: aggregateIds,
+ version: 1,
+ );
+
+ final details = _buildNotificationDetails();
+ final scheduledAt = tz.TZDateTime.from(fireAt, tz.local);
+ final mode = await _resolveAndroidScheduleMode();
+ final preview = events.take(3).map((item) => item.title).join('、');
+
+ await _plugin.zonedSchedule(
+ _notificationIdForEventCycle(
+ first.id,
+ fireAt,
+ ReminderPayloadMode.aggregate,
+ ),
+ '你有${events.length}个日程提醒',
+ preview,
+ scheduledAt,
+ details,
+ payload: jsonEncode(payload.toJson()),
+ androidScheduleMode: mode,
+ uiLocalNotificationDateInterpretation:
+ UILocalNotificationDateInterpretation.absoluteTime,
+ );
+ }
+
+ ReminderPayload? _decodePayload(String? raw) {
+ if (raw == null || raw.isEmpty) {
+ return null;
+ }
+ try {
+ final json = Map.from(jsonDecode(raw) as Map);
+ return ReminderPayload.fromJson(json);
+ } catch (_) {
+ return null;
+ }
+ }
+
+ String _buildReminderBody(ScheduleItemModel event, int reminderMinutes) {
+ final when = reminderMinutes == 0
+ ? '日程现在开始'
+ : '日程即将开始(提前$reminderMinutes分钟)';
+ final location = event.metadata?.location;
+ final notes = event.metadata?.notes;
+ final buffer = StringBuffer(when);
+ if (location != null && location.isNotEmpty) {
+ buffer.write('\n地点:$location');
+ }
+ if (notes != null && notes.isNotEmpty) {
+ buffer.write(
+ '\n备注:${notes.length > 30 ? '${notes.substring(0, 30)}...' : notes}',
+ );
+ }
+ return buffer.toString();
+ }
+
+ Future _onNotificationResponse(NotificationResponse response) async {
+ final payloadRaw = response.payload;
+ if (payloadRaw == null || payloadRaw.isEmpty) {
+ return;
+ }
+ final handler = _actionHandler;
+ if (handler == null) {
+ return;
+ }
+
+ try {
+ final payload = ReminderPayload.fromJson(
+ Map.from(jsonDecode(payloadRaw) as Map),
+ );
+ final actionId = response.actionId;
+ if (actionId == _actionCancel) {
+ await handler(action: ReminderAction.cancel, payload: payload);
+ return;
+ }
+ if (actionId == _actionSnooze) {
+ await handler(action: ReminderAction.snooze10m, payload: payload);
+ }
+ } catch (_) {
+ debugPrint('failed to handle reminder notification response');
+ return;
}
- return '日程即将开始(提前$reminderMinutes分钟):${event.title}';
}
}
diff --git a/apps/lib/core/startup/auth_session_bootstrapper.dart b/apps/lib/core/startup/auth_session_bootstrapper.dart
index c499ca5..88e21a8 100644
--- a/apps/lib/core/startup/auth_session_bootstrapper.dart
+++ b/apps/lib/core/startup/auth_session_bootstrapper.dart
@@ -1,16 +1,20 @@
import '../../features/auth/presentation/bloc/auth_state.dart';
import '../../features/calendar/data/services/calendar_service.dart';
+import '../../features/calendar/reminders/reminder_action_executor.dart';
import '../notifications/local_notification_service.dart';
class AuthSessionBootstrapper {
AuthSessionBootstrapper({
required CalendarService calendarService,
required LocalNotificationService notificationService,
+ required ReminderActionExecutor reminderActionExecutor,
}) : _calendarService = calendarService,
- _notificationService = notificationService;
+ _notificationService = notificationService,
+ _reminderActionExecutor = reminderActionExecutor;
final CalendarService _calendarService;
final LocalNotificationService _notificationService;
+ final ReminderActionExecutor _reminderActionExecutor;
String? _syncedUserId;
@@ -24,13 +28,15 @@ class AuthSessionBootstrapper {
return;
}
- _syncedUserId = state.user.id;
-
try {
+ await _reminderActionExecutor.replayPendingActions();
+
final now = DateTime.now();
+ final start = now.subtract(const Duration(days: 90));
final end = now.add(const Duration(days: 90));
- final events = await _calendarService.getEventsForRange(now, end);
+ final events = await _calendarService.getEventsForRange(start, end);
await _notificationService.rebuildUpcomingReminders(events);
+ _syncedUserId = state.user.id;
} catch (_) {
// ignore reminder bootstrap failures
}
diff --git a/apps/lib/features/calendar/data/services/calendar_service.dart b/apps/lib/features/calendar/data/services/calendar_service.dart
index 69be549..c1cf9e8 100644
--- a/apps/lib/features/calendar/data/services/calendar_service.dart
+++ b/apps/lib/features/calendar/data/services/calendar_service.dart
@@ -44,6 +44,14 @@ class CalendarService {
return _api.update(event);
}
+ Future archiveEvent(String id) async {
+ final event = await getEventById(id);
+ if (event == null) {
+ return null;
+ }
+ return updateEvent(event.copyWith(status: ScheduleStatus.archived));
+ }
+
Future deleteEvent(String id) async {
await _api.delete(id);
}
diff --git a/apps/lib/features/calendar/reminders/models/reminder_action.dart b/apps/lib/features/calendar/reminders/models/reminder_action.dart
new file mode 100644
index 0000000..2fadcce
--- /dev/null
+++ b/apps/lib/features/calendar/reminders/models/reminder_action.dart
@@ -0,0 +1,17 @@
+enum ReminderAction {
+ cancel('cancel'),
+ snooze10m('snooze_10m'),
+ timeout30s('timeout_30s'),
+ autoArchive('auto_archive');
+
+ const ReminderAction(this.value);
+
+ final String value;
+
+ static ReminderAction fromValue(String raw) {
+ return ReminderAction.values.firstWhere(
+ (item) => item.value == raw,
+ orElse: () => ReminderAction.timeout30s,
+ );
+ }
+}
diff --git a/apps/lib/features/calendar/reminders/models/reminder_payload.dart b/apps/lib/features/calendar/reminders/models/reminder_payload.dart
new file mode 100644
index 0000000..c427ea9
--- /dev/null
+++ b/apps/lib/features/calendar/reminders/models/reminder_payload.dart
@@ -0,0 +1,174 @@
+class ReminderPayload {
+ final String eventId;
+ final String title;
+ final DateTime startAt;
+ final DateTime? endAt;
+ final String timezone;
+ final String? location;
+ final String? notes;
+ final String? color;
+ final ReminderPayloadMode mode;
+ final List aggregateIds;
+ final int version;
+
+ const ReminderPayload({
+ required this.eventId,
+ required this.title,
+ required this.startAt,
+ required this.timezone,
+ this.endAt,
+ this.location,
+ this.notes,
+ this.color,
+ this.mode = ReminderPayloadMode.single,
+ this.aggregateIds = const [],
+ this.version = 1,
+ });
+
+ ReminderPayload copyWith({
+ String? eventId,
+ String? title,
+ DateTime? startAt,
+ DateTime? endAt,
+ String? timezone,
+ String? location,
+ String? notes,
+ String? color,
+ ReminderPayloadMode? mode,
+ List? aggregateIds,
+ int? version,
+ }) {
+ return ReminderPayload(
+ eventId: eventId ?? this.eventId,
+ title: title ?? this.title,
+ startAt: startAt ?? this.startAt,
+ endAt: endAt ?? this.endAt,
+ timezone: timezone ?? this.timezone,
+ location: location ?? this.location,
+ notes: notes ?? this.notes,
+ color: color ?? this.color,
+ mode: mode ?? this.mode,
+ aggregateIds: aggregateIds ?? this.aggregateIds,
+ version: version ?? this.version,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'eventId': eventId,
+ 'title': title,
+ 'startAt': startAt.toIso8601String(),
+ 'endAt': endAt?.toIso8601String(),
+ 'timezone': timezone,
+ 'location': location,
+ 'notes': notes,
+ 'color': color,
+ 'mode': mode.value,
+ 'aggregateIds': aggregateIds,
+ 'version': version,
+ };
+ }
+
+ factory ReminderPayload.fromJson(Map json) {
+ final eventId = (json['eventId'] as String?) ?? '';
+ if (eventId.isEmpty) {
+ throw const FormatException('eventId is required');
+ }
+
+ final startAtRaw = json['startAt'] as String?;
+ if (startAtRaw == null || startAtRaw.isEmpty) {
+ throw const FormatException('startAt is required');
+ }
+ final parsedStartAt = DateTime.parse(startAtRaw);
+
+ final mode = ReminderPayloadMode.fromValue(
+ (json['mode'] as String?) ?? 'single',
+ );
+ final aggregateIds = (json['aggregateIds'] as List? ?? const [])
+ .map((item) => item.toString())
+ .toList();
+ if (mode == ReminderPayloadMode.aggregate && aggregateIds.length < 2) {
+ throw const FormatException('aggregateIds must contain at least 2 items');
+ }
+
+ return ReminderPayload(
+ eventId: eventId,
+ title: (json['title'] as String?) ?? '',
+ startAt: parsedStartAt,
+ endAt: json['endAt'] != null
+ ? DateTime.parse(json['endAt'] as String)
+ : null,
+ timezone: (json['timezone'] as String?) ?? 'UTC',
+ location: json['location'] as String?,
+ notes: json['notes'] as String?,
+ color: json['color'] as String?,
+ mode: mode,
+ aggregateIds: aggregateIds,
+ version: (json['version'] as int?) ?? 1,
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) {
+ return true;
+ }
+ return other is ReminderPayload &&
+ other.eventId == eventId &&
+ other.title == title &&
+ other.startAt == startAt &&
+ other.endAt == endAt &&
+ other.timezone == timezone &&
+ other.location == location &&
+ other.notes == notes &&
+ other.color == color &&
+ other.mode == mode &&
+ _listEquals(other.aggregateIds, aggregateIds) &&
+ other.version == version;
+ }
+
+ @override
+ int get hashCode {
+ return Object.hash(
+ eventId,
+ title,
+ startAt,
+ endAt,
+ timezone,
+ location,
+ notes,
+ color,
+ mode,
+ Object.hashAll(aggregateIds),
+ version,
+ );
+ }
+}
+
+enum ReminderPayloadMode {
+ single('single'),
+ aggregate('aggregate');
+
+ const ReminderPayloadMode(this.value);
+
+ final String value;
+
+ static ReminderPayloadMode fromValue(String raw) {
+ return ReminderPayloadMode.values.firstWhere(
+ (item) => item.value == raw,
+ orElse: () => ReminderPayloadMode.single,
+ );
+ }
+}
+
+bool _listEquals(List left, List right) {
+ if (left.length != right.length) {
+ return false;
+ }
+ for (var i = 0; i < left.length; i++) {
+ if (left[i] != right[i]) {
+ return false;
+ }
+ }
+ return true;
+}
diff --git a/apps/lib/features/calendar/reminders/reminder_action_executor.dart b/apps/lib/features/calendar/reminders/reminder_action_executor.dart
new file mode 100644
index 0000000..ee90689
--- /dev/null
+++ b/apps/lib/features/calendar/reminders/reminder_action_executor.dart
@@ -0,0 +1,106 @@
+import 'dart:math';
+
+import '../data/services/calendar_service.dart';
+import '../../../core/notifications/local_notification_service.dart';
+import 'models/reminder_action.dart';
+import 'models/reminder_payload.dart';
+import 'reminder_outbox_store.dart';
+
+class ReminderActionExecutor {
+ final CalendarService _calendarService;
+ final LocalNotificationService _notificationService;
+ final ReminderOutboxStore _outboxStore;
+ final Random _random;
+
+ ReminderActionExecutor({
+ required CalendarService calendarService,
+ required LocalNotificationService notificationService,
+ required ReminderOutboxStore outboxStore,
+ Random? random,
+ }) : _calendarService = calendarService,
+ _notificationService = notificationService,
+ _outboxStore = outboxStore,
+ _random = random ?? Random();
+
+ Future handleAction({
+ required ReminderAction action,
+ required ReminderPayload payload,
+ }) async {
+ final ids = payload.mode == ReminderPayloadMode.aggregate
+ ? payload.aggregateIds
+ : [payload.eventId];
+
+ if (action == ReminderAction.cancel) {
+ for (final id in ids) {
+ await _notificationService.cancelEventReminder(id);
+ await _archiveEvent(id, ReminderAction.cancel);
+ }
+ return;
+ }
+
+ if (action == ReminderAction.snooze10m ||
+ action == ReminderAction.timeout30s) {
+ for (final id in ids) {
+ await _snoozeEvent(id);
+ }
+ }
+ }
+
+ Future replayPendingActions() async {
+ final pending = await _outboxStore.listPending();
+ for (final item in pending) {
+ if (item.targetStatus != 'archived') {
+ await _outboxStore.markDone(item.opId);
+ continue;
+ }
+ try {
+ await _calendarService.archiveEvent(item.eventId);
+ await _outboxStore.markDone(item.opId);
+ } catch (error) {
+ await _outboxStore.markRetry(item.opId, error.toString());
+ }
+ }
+ }
+
+ Future _snoozeEvent(String eventId) async {
+ final event = await _calendarService.getEventById(eventId);
+ if (event == null) {
+ return;
+ }
+ final now = DateTime.now();
+ final endAt = event.endAt;
+ if (endAt != null && !now.isBefore(endAt)) {
+ await _notificationService.cancelEventReminder(eventId);
+ await _archiveEvent(eventId, ReminderAction.autoArchive);
+ return;
+ }
+
+ final nextAt = now.add(const Duration(minutes: 10));
+ if (endAt != null && !nextAt.isBefore(endAt)) {
+ await _notificationService.cancelEventReminder(eventId);
+ await _archiveEvent(eventId, ReminderAction.autoArchive);
+ return;
+ }
+
+ await _notificationService.scheduleReminderAt(event, nextAt);
+ }
+
+ Future _archiveEvent(String eventId, ReminderAction action) async {
+ final opId =
+ '${DateTime.now().millisecondsSinceEpoch}-${_random.nextInt(1 << 32)}';
+ final outboxItem = ReminderOutboxItem(
+ opId: opId,
+ eventId: eventId,
+ action: action,
+ targetStatus: 'archived',
+ occurredAt: DateTime.now(),
+ );
+ await _outboxStore.enqueue(outboxItem);
+ try {
+ await _calendarService.archiveEvent(eventId);
+ await _outboxStore.markDone(opId);
+ } catch (error) {
+ await _outboxStore.markRetry(opId, error.toString());
+ }
+ }
+}
diff --git a/apps/lib/features/calendar/reminders/reminder_outbox_store.dart b/apps/lib/features/calendar/reminders/reminder_outbox_store.dart
new file mode 100644
index 0000000..7655e5d
--- /dev/null
+++ b/apps/lib/features/calendar/reminders/reminder_outbox_store.dart
@@ -0,0 +1,202 @@
+import 'dart:convert';
+
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'models/reminder_action.dart';
+
+class ReminderOutboxItem {
+ final String opId;
+ final String eventId;
+ final ReminderAction action;
+ final String? targetStatus;
+ final DateTime occurredAt;
+ final int retryCount;
+ final DateTime? nextRetryAt;
+ final ReminderOutboxState state;
+ final String? lastError;
+
+ const ReminderOutboxItem({
+ required this.opId,
+ required this.eventId,
+ required this.action,
+ required this.occurredAt,
+ this.targetStatus,
+ this.retryCount = 0,
+ this.nextRetryAt,
+ this.state = ReminderOutboxState.pending,
+ this.lastError,
+ });
+
+ String get idempotencyBucket {
+ final bucket =
+ occurredAt.millisecondsSinceEpoch ~/
+ const Duration(minutes: 1).inMilliseconds;
+ return '$eventId|${action.value}|$bucket';
+ }
+
+ ReminderOutboxItem copyWith({
+ int? retryCount,
+ DateTime? nextRetryAt,
+ ReminderOutboxState? state,
+ String? lastError,
+ }) {
+ return ReminderOutboxItem(
+ opId: opId,
+ eventId: eventId,
+ action: action,
+ targetStatus: targetStatus,
+ occurredAt: occurredAt,
+ retryCount: retryCount ?? this.retryCount,
+ nextRetryAt: nextRetryAt ?? this.nextRetryAt,
+ state: state ?? this.state,
+ lastError: lastError ?? this.lastError,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'opId': opId,
+ 'eventId': eventId,
+ 'action': action.value,
+ 'targetStatus': targetStatus,
+ 'occurredAt': occurredAt.toIso8601String(),
+ 'retryCount': retryCount,
+ 'nextRetryAt': nextRetryAt?.toIso8601String(),
+ 'state': state.value,
+ 'lastError': lastError,
+ };
+ }
+
+ factory ReminderOutboxItem.fromJson(Map json) {
+ return ReminderOutboxItem(
+ opId: (json['opId'] as String?) ?? '',
+ eventId: (json['eventId'] as String?) ?? '',
+ action: ReminderAction.fromValue(
+ (json['action'] as String?) ?? 'timeout_30s',
+ ),
+ targetStatus: json['targetStatus'] as String?,
+ occurredAt: DateTime.parse(json['occurredAt'] as String),
+ retryCount: (json['retryCount'] as int?) ?? 0,
+ nextRetryAt: json['nextRetryAt'] != null
+ ? DateTime.parse(json['nextRetryAt'] as String)
+ : null,
+ state: ReminderOutboxState.fromValue(
+ (json['state'] as String?) ?? 'pending',
+ ),
+ lastError: json['lastError'] as String?,
+ );
+ }
+}
+
+enum ReminderOutboxState {
+ pending('pending'),
+ done('done'),
+ dead('dead');
+
+ const ReminderOutboxState(this.value);
+ final String value;
+
+ static ReminderOutboxState fromValue(String raw) {
+ return ReminderOutboxState.values.firstWhere(
+ (item) => item.value == raw,
+ orElse: () => ReminderOutboxState.pending,
+ );
+ }
+}
+
+class ReminderOutboxStore {
+ static const String _key = 'calendar_reminder_outbox_v1';
+ final SharedPreferences _prefs;
+
+ ReminderOutboxStore(this._prefs);
+
+ Future enqueue(ReminderOutboxItem item) async {
+ final current = await _readAll();
+ final duplicated = current.any(
+ (existing) =>
+ existing.state == ReminderOutboxState.pending &&
+ existing.idempotencyBucket == item.idempotencyBucket,
+ );
+ if (duplicated) {
+ return;
+ }
+ current.add(item);
+ await _writeAll(current);
+ }
+
+ Future> listPending() async {
+ final all = await _readAll();
+ final now = DateTime.now();
+ return all
+ .where((item) => item.state == ReminderOutboxState.pending)
+ .where(
+ (item) => item.nextRetryAt == null || !item.nextRetryAt!.isAfter(now),
+ )
+ .toList();
+ }
+
+ Future markDone(String opId) async {
+ final all = await _readAll();
+ final updated = all
+ .map(
+ (item) => item.opId == opId
+ ? item.copyWith(
+ state: ReminderOutboxState.done,
+ nextRetryAt: null,
+ )
+ : item,
+ )
+ .toList();
+ await _writeAll(updated);
+ }
+
+ Future markRetry(String opId, String error) async {
+ final all = await _readAll();
+ final updated = all.map((item) {
+ if (item.opId != opId) {
+ return item;
+ }
+ final nextRetryCount = item.retryCount + 1;
+ if (nextRetryCount >= 8) {
+ return item.copyWith(
+ retryCount: nextRetryCount,
+ state: ReminderOutboxState.dead,
+ lastError: error,
+ nextRetryAt: null,
+ );
+ }
+ final delayMinutes = nextRetryCount == 1 ? 0 : 1 << (nextRetryCount - 1);
+ return item.copyWith(
+ retryCount: nextRetryCount,
+ lastError: error,
+ nextRetryAt: DateTime.now().add(Duration(minutes: delayMinutes)),
+ );
+ }).toList();
+ await _writeAll(updated);
+ }
+
+ Future> _readAll() async {
+ try {
+ final raw = _prefs.getString(_key);
+ if (raw == null || raw.isEmpty) {
+ return [];
+ }
+ final list = jsonDecode(raw) as List;
+ return list
+ .whereType