From 6e35fff9a4e95ec807a0b1daf5a6bcaa33ee4a37 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 18:42:58 +0800 Subject: [PATCH 1/4] refactor(calendar): remove deprecated reminder components --- apps/lib/core/di/injection.dart | 5 - .../local_notification_service.dart | 73 ++----- .../reminder_notification_callbacks.dart | 57 +++-- .../startup/auth_session_bootstrapper.dart | 2 - .../reminder_action_dedupe_store.dart | 52 ----- .../reminders/reminder_action_executor.dart | 52 +---- .../reminders/reminder_cold_start_queue.dart | 57 ----- .../reminders/reminder_outbox_store.dart | 202 ------------------ .../reminders/reminder_overlap_policy.dart | 80 ------- .../ui/reminder_foreground_presenter.dart | 73 ------- .../ui/reminder_presentation_coordinator.dart | 29 --- .../ui/widgets/reminder_action_sheet.dart | 58 ----- apps/lib/main.dart | 8 - .../auth_session_bootstrapper_test.dart | 36 ---- .../reminder_action_dedupe_store_test.dart | 78 ------- .../reminder_action_executor_test.dart | 56 +---- .../reminders/reminder_action_sheet_test.dart | 41 ---- .../reminder_cold_start_queue_test.dart | 123 ----------- .../reminder_overlap_policy_test.dart | 48 ----- ...eminder_presentation_coordinator_test.dart | 60 ------ 20 files changed, 47 insertions(+), 1143 deletions(-) delete mode 100644 apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart delete mode 100644 apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart delete mode 100644 apps/lib/features/calendar/reminders/reminder_outbox_store.dart delete mode 100644 apps/lib/features/calendar/reminders/reminder_overlap_policy.dart delete mode 100644 apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart delete mode 100644 apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart delete mode 100644 apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart delete mode 100644 apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart delete mode 100644 apps/test/features/calendar/reminders/reminder_action_sheet_test.dart delete mode 100644 apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart delete mode 100644 apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart delete mode 100644 apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index d8d28a4..b9ad3fa 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -20,7 +20,6 @@ import '../../features/calendar/data/calendar_api.dart'; import '../../features/calendar/data/services/calendar_repository.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'; @@ -98,15 +97,11 @@ Future configureDependencies() async { ); sl.registerSingleton(calendarRepository); - final reminderOutboxStore = ReminderOutboxStore(sharedPreferences); - sl.registerSingleton(reminderOutboxStore); - sl.registerSingleton(LocalNotificationService()); final reminderActionExecutor = ReminderActionExecutor( calendarService: calendarService, notificationService: sl(), - outboxStore: reminderOutboxStore, ); sl.registerSingleton(reminderActionExecutor); diff --git a/apps/lib/core/notifications/local_notification_service.dart b/apps/lib/core/notifications/local_notification_service.dart index 2a9de1e..19e3b0d 100644 --- a/apps/lib/core/notifications/local_notification_service.dart +++ b/apps/lib/core/notifications/local_notification_service.dart @@ -4,7 +4,6 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:timezone/data/latest.dart' as tz_data; import 'package:timezone/timezone.dart' as tz; @@ -12,8 +11,6 @@ import 'reminder_notification_callbacks.dart'; 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_action_dedupe_store.dart'; -import '../../features/calendar/reminders/reminder_overlap_policy.dart'; typedef ReminderNotificationActionHandler = Future Function({ @@ -38,9 +35,7 @@ class LocalNotificationService { static const String _actionSnooze = 'snooze10m'; final FlutterLocalNotificationsPlugin _plugin; - final ReminderOverlapPolicy _overlapPolicy; final ReminderPermissionFallbackTracker? _permissionFallbackTracker; - ReminderActionDedupeStore? _dedupeStore; bool _initialized = false; bool _canDeliverSystemNotification = true; ReminderNotificationActionHandler? _actionHandler; @@ -50,12 +45,8 @@ class LocalNotificationService { LocalNotificationService({ FlutterLocalNotificationsPlugin? plugin, - ReminderOverlapPolicy? overlapPolicy, - ReminderActionDedupeStore? dedupeStore, ReminderPermissionFallbackTracker? permissionFallbackTracker, }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(), - _overlapPolicy = overlapPolicy ?? const ReminderOverlapPolicy(), - _dedupeStore = dedupeStore, _permissionFallbackTracker = permissionFallbackTracker; void bindActionHandler(ReminderNotificationActionHandler handler) { @@ -128,19 +119,9 @@ class LocalNotificationService { >(); await iosImpl?.requestPermissions(alert: true, badge: true, sound: true); - await _ensureDedupeStore(); - _initialized = true; } - Future _ensureDedupeStore() async { - if (_dedupeStore != null) { - return; - } - final prefs = await SharedPreferences.getInstance(); - _dedupeStore = ReminderActionDedupeStore(prefs); - } - Future _refreshAndroidNotificationAvailability() async { if (defaultTargetPlatform != TargetPlatform.android) { return; @@ -166,8 +147,9 @@ class LocalNotificationService { } final now = DateTime.now(); - final fireAt = _overlapPolicy.resolveFirstFireAt(event, now: now); - if (fireAt == null) { + final reminderMinutes = event.metadata?.reminderMinutes ?? 0; + final fireAt = event.startAt.subtract(Duration(minutes: reminderMinutes)); + if (fireAt.isBefore(now)) { await cancelEventReminder(event.id); return; } @@ -225,31 +207,21 @@ class LocalNotificationService { ) async { await initialize(); await _refreshAndroidNotificationAvailability(); - if (!_canDeliverSystemNotification) { - _clearAllInAppFallbackTimers(); - final now = DateTime.now(); - final groups = _overlapPolicy.groupByMinute(events, now: now); - for (final group in groups) { - if (group.isAggregate) { - await _scheduleInAppAggregateFallback(group.events, group.fireAt); - continue; - } - await _scheduleInAppFallbackRemindersFrom( - event: group.events.first, - firstFireAt: group.fireAt, + for (final event in events) { + if (!_canDeliverSystemNotification) { + final reminderMinutes = event.metadata?.reminderMinutes ?? 0; + final fireAt = event.startAt.subtract( + Duration(minutes: reminderMinutes), ); - } - return; - } - - 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); + if (fireAt.isAfter(DateTime.now())) { + await _scheduleInAppFallbackRemindersFrom( + event: event, + firstFireAt: fireAt, + ); + } continue; } - await upsertEventReminder(group.events.first); + await upsertEventReminder(event); } } @@ -693,21 +665,6 @@ class LocalNotificationService { return; } - final dedupeStore = _dedupeStore; - if (dedupeStore != null) { - final notificationId = response.id?.toString() ?? payload.eventId; - final fireTimeBucket = - payload.fireTimeBucket ?? - (payload.startAt.millisecondsSinceEpoch ~/ - const Duration(minutes: 1).inMilliseconds); - final actionExecutionId = - '$notificationId|${action.value}|$fireTimeBucket'; - final isNew = await dedupeStore.markIfNew(actionExecutionId); - if (!isNew) { - return; - } - } - await handler(action: action, payload: payload); } } diff --git a/apps/lib/core/notifications/reminder_notification_callbacks.dart b/apps/lib/core/notifications/reminder_notification_callbacks.dart index f8f16dd..439ebeb 100644 --- a/apps/lib/core/notifications/reminder_notification_callbacks.dart +++ b/apps/lib/core/notifications/reminder_notification_callbacks.dart @@ -5,8 +5,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../../features/calendar/reminders/reminder_cold_start_queue.dart'; - typedef ReminderNotificationResponseHandler = Future Function(NotificationResponse response); @@ -15,8 +13,6 @@ class ReminderNotificationCallbacks { 'calendar_reminder_pending_notification_responses_v1'; static ReminderNotificationResponseHandler? _responseHandler; static Future _pendingStorageLock = Future.value(); - static final ReminderColdStartQueue _coldStartQueue = - ReminderColdStartQueue(); @visibleForTesting static Future resetForTest() async { @@ -106,38 +102,35 @@ class ReminderNotificationCallbacks { final remaining = []; for (final raw in pending) { - _coldStartQueue.enqueue(() async { - Map parsed; - try { - parsed = Map.from(jsonDecode(raw) as Map); - } catch (_) { - return; - } + Map parsed; + try { + parsed = Map.from(jsonDecode(raw) as Map); + } catch (_) { + continue; + } - final id = parsed['id'] as int?; - final actionId = parsed['actionId'] as String?; - final payload = parsed['payload'] as String?; - final typeIndex = (parsed['type'] as int?) ?? 0; - final input = parsed['input'] as String?; - final type = NotificationResponseType.values[typeIndex.clamp(0, 1)]; + final id = parsed['id'] as int?; + final actionId = parsed['actionId'] as String?; + final payload = parsed['payload'] as String?; + final typeIndex = (parsed['type'] as int?) ?? 0; + final input = parsed['input'] as String?; + final type = NotificationResponseType.values[typeIndex.clamp(0, 1)]; - try { - await handler( - NotificationResponse( - id: id, - actionId: actionId, - payload: payload, - input: input, - notificationResponseType: type, - ), - ); - } catch (_) { - remaining.add(raw); - } - }); + try { + await handler( + NotificationResponse( + id: id, + actionId: actionId, + payload: payload, + input: input, + notificationResponseType: type, + ), + ); + } catch (_) { + remaining.add(raw); + } } - await _coldStartQueue.replay(); if (remaining.isEmpty) { await prefs.remove(_pendingKey); return; diff --git a/apps/lib/core/startup/auth_session_bootstrapper.dart b/apps/lib/core/startup/auth_session_bootstrapper.dart index 88e21a8..0fb74fe 100644 --- a/apps/lib/core/startup/auth_session_bootstrapper.dart +++ b/apps/lib/core/startup/auth_session_bootstrapper.dart @@ -29,8 +29,6 @@ class AuthSessionBootstrapper { } try { - await _reminderActionExecutor.replayPendingActions(); - final now = DateTime.now(); final start = now.subtract(const Duration(days: 90)); final end = now.add(const Duration(days: 90)); diff --git a/apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart b/apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart deleted file mode 100644 index f7c23df..0000000 --- a/apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:async'; - -import 'package:shared_preferences/shared_preferences.dart'; - -typedef SetStringListFn = Future Function(String key, List value); - -class ReminderActionDedupeStore { - static const String _key = 'calendar_reminder_action_dedupe_v1'; - static const int _maxEntries = 512; - - final SharedPreferences _prefs; - final SetStringListFn _setStringList; - Future _queue = Future.value(); - - ReminderActionDedupeStore( - SharedPreferences prefs, { - SetStringListFn? setStringList, - }) : _prefs = prefs, - _setStringList = setStringList ?? prefs.setStringList; - - Future markIfNew(String actionExecutionId) async { - final completer = Completer(); - _queue = _queue - .then((_) async { - completer.complete(await _markIfNewInternal(actionExecutionId)); - }) - .catchError((_) { - if (!completer.isCompleted) { - completer.complete(false); - } - }); - - return completer.future; - } - - Future _markIfNewInternal(String actionExecutionId) async { - final current = List.from( - _prefs.getStringList(_key) ?? const [], - ); - if (current.contains(actionExecutionId)) { - return false; - } - - current.add(actionExecutionId); - if (current.length > _maxEntries) { - current.removeRange(0, current.length - _maxEntries); - } - - final saved = await _setStringList(_key, current); - return saved; - } -} diff --git a/apps/lib/features/calendar/reminders/reminder_action_executor.dart b/apps/lib/features/calendar/reminders/reminder_action_executor.dart index 4b0c5cc..16f9af5 100644 --- a/apps/lib/features/calendar/reminders/reminder_action_executor.dart +++ b/apps/lib/features/calendar/reminders/reminder_action_executor.dart @@ -1,26 +1,17 @@ -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(); + _notificationService = notificationService; Future handleAction({ required ReminderAction action, @@ -35,7 +26,7 @@ class ReminderActionExecutor { if (action == ReminderAction.archive) { for (final id in ids) { await _notificationService.cancelEventReminder(id); - await _archiveEvent(id, ReminderAction.archive); + await _archiveEvent(id); } return; } @@ -47,21 +38,6 @@ class ReminderActionExecutor { } } - Future replayPendingActions() async { - final pending = await _outboxStore.listPending(); - for (final item in pending) { - if (item.targetStatus != 'archived') { - 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) { @@ -71,37 +47,21 @@ class ReminderActionExecutor { final endAt = event.endAt; if (endAt != null && !now.isBefore(endAt)) { await _notificationService.cancelEventReminder(eventId); - await _archiveEvent(eventId, ReminderAction.archive); + await _archiveEvent(eventId); return; } final nextAt = now.add(const Duration(minutes: 10)); if (endAt != null && !nextAt.isBefore(endAt)) { await _notificationService.cancelEventReminder(eventId); - await _archiveEvent(eventId, ReminderAction.archive); + await _archiveEvent(eventId); return; } await _notificationService.scheduleReminderAt(event, nextAt); } - Future _archiveEvent(String eventId, ReminderAction action) async { - try { - await _calendarService.archiveEvent(eventId); - return; - } catch (_) { - // fall through to enqueue local outbox for retry - } - - 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); + Future _archiveEvent(String eventId) async { + await _calendarService.archiveEvent(eventId); } } diff --git a/apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart b/apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart deleted file mode 100644 index 823531a..0000000 --- a/apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; - -typedef ReminderColdStartReplayTask = Future Function(); -typedef ReminderColdStartTaskErrorHandler = - void Function(Object error, StackTrace stackTrace); - -class ReminderColdStartQueue { - final Queue _tasks = - Queue(); - final ReminderColdStartTaskErrorHandler? _onTaskError; - Future? _inFlightReplay; - - ReminderColdStartQueue({ReminderColdStartTaskErrorHandler? onTaskError}) - : _onTaskError = onTaskError; - - void enqueue(ReminderColdStartReplayTask task) { - _tasks.add(task); - } - - Future replay() { - final inFlightReplay = _inFlightReplay; - if (inFlightReplay != null) { - return inFlightReplay; - } - - final replayCompleter = Completer(); - final replayFuture = replayCompleter.future; - _inFlightReplay = replayFuture; - - scheduleMicrotask(() async { - try { - await _replayInternal(); - replayCompleter.complete(); - } catch (error, stackTrace) { - replayCompleter.completeError(error, stackTrace); - } finally { - if (identical(_inFlightReplay, replayFuture)) { - _inFlightReplay = null; - } - } - }); - - return replayFuture; - } - - Future _replayInternal() async { - while (_tasks.isNotEmpty) { - final task = _tasks.removeFirst(); - try { - await task(); - } catch (error, stackTrace) { - _onTaskError?.call(error, stackTrace); - } - } - } -} diff --git a/apps/lib/features/calendar/reminders/reminder_outbox_store.dart b/apps/lib/features/calendar/reminders/reminder_outbox_store.dart deleted file mode 100644 index 7655e5d..0000000 --- a/apps/lib/features/calendar/reminders/reminder_outbox_store.dart +++ /dev/null @@ -1,202 +0,0 @@ -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() - .map( - (item) => - ReminderOutboxItem.fromJson(Map.from(item)), - ) - .toList(); - } catch (_) { - await _prefs.remove(_key); - return []; - } - } - - Future _writeAll(List items) async { - final raw = jsonEncode(items.map((item) => item.toJson()).toList()); - await _prefs.setString(_key, raw); - } -} diff --git a/apps/lib/features/calendar/reminders/reminder_overlap_policy.dart b/apps/lib/features/calendar/reminders/reminder_overlap_policy.dart deleted file mode 100644 index bb1f0e5..0000000 --- a/apps/lib/features/calendar/reminders/reminder_overlap_policy.dart +++ /dev/null @@ -1,80 +0,0 @@ -import '../data/models/schedule_item_model.dart'; - -class ReminderOverlapGroup { - final DateTime fireAt; - final List events; - - const ReminderOverlapGroup({required this.fireAt, required this.events}); - - bool get isAggregate => events.length > 1; -} - -class ReminderOverlapPolicy { - const ReminderOverlapPolicy(); - - List groupByMinute( - Iterable events, { - required DateTime now, - }) { - final buckets = >{}; - final minuteToFireAt = {}; - - for (final event in events) { - final fireAt = resolveFirstFireAt(event, now: now); - if (fireAt == null) { - continue; - } - final minute = DateTime( - fireAt.year, - fireAt.month, - fireAt.day, - fireAt.hour, - fireAt.minute, - ); - final key = minute.toIso8601String(); - buckets.putIfAbsent(key, () => []).add(event); - minuteToFireAt[key] = minuteToFireAt[key] ?? fireAt; - } - - final groups = buckets.entries - .map( - (entry) => ReminderOverlapGroup( - fireAt: minuteToFireAt[entry.key]!, - events: entry.value, - ), - ) - .toList(); - - groups.sort((left, right) => left.fireAt.compareTo(right.fireAt)); - return groups; - } - - DateTime? resolveFirstFireAt( - ScheduleItemModel event, { - required DateTime now, - }) { - if (event.status != ScheduleStatus.active) { - return null; - } - final reminderMinutes = event.metadata?.reminderMinutes; - if (reminderMinutes == null) { - return null; - } - final remindAt = event.startAt.subtract(Duration(minutes: reminderMinutes)); - final endAt = event.endAt; - - if (endAt != null && !now.isBefore(endAt)) { - return null; - } - - if (now.isBefore(remindAt)) { - return remindAt; - } - - if (endAt != null && now.isBefore(endAt)) { - return now.add(const Duration(seconds: 5)); - } - - return null; - } -} diff --git a/apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart b/apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart deleted file mode 100644 index 7253566..0000000 --- a/apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../core/theme/design_tokens.dart'; -import '../models/reminder_action.dart'; -import '../models/reminder_payload.dart'; -import '../reminder_action_executor.dart'; -import 'reminder_presentation_coordinator.dart'; -import 'widgets/reminder_action_sheet.dart'; - -class ReminderForegroundPresenter { - final GlobalKey _navigatorKey; - final ReminderActionExecutor _executor; - final ReminderPresentationCoordinator _coordinator; - bool _isPresenting = false; - - ReminderForegroundPresenter({ - required GlobalKey navigatorKey, - required ReminderActionExecutor executor, - ReminderPresentationCoordinator? coordinator, - }) : _navigatorKey = navigatorKey, - _executor = executor, - _coordinator = coordinator ?? ReminderPresentationCoordinator(); - - Future present(ReminderPayload payload) async { - final context = _navigatorKey.currentContext; - if (context == null) { - return; - } - - final lifecycleState = WidgetsBinding.instance.lifecycleState; - final isAppActive = lifecycleState == AppLifecycleState.resumed; - final shouldPresent = _coordinator.shouldPresent( - eventId: payload.eventId, - isAppActive: isAppActive, - ); - if (!shouldPresent || _isPresenting) { - return; - } - - _isPresenting = true; - try { - final action = await showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (sheetContext) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: ReminderActionSheet( - onSnooze: () { - Navigator.of(sheetContext).pop(ReminderAction.snooze10m); - }, - onArchive: () { - Navigator.of(sheetContext).pop(ReminderAction.archive); - }, - ), - ), - ); - }, - ); - - if (action == null) { - return; - } - - await _executor.handleAction(action: action, payload: payload); - } finally { - _isPresenting = false; - } - } -} diff --git a/apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart b/apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart deleted file mode 100644 index fc5e85b..0000000 --- a/apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart +++ /dev/null @@ -1,29 +0,0 @@ -typedef ReminderPresentationNow = DateTime Function(); - -class ReminderPresentationCoordinator { - final Duration _dedupeWindow; - final ReminderPresentationNow _now; - final Map _lastPresentedAtByEventId = {}; - - ReminderPresentationCoordinator({ - Duration dedupeWindow = const Duration(seconds: 30), - ReminderPresentationNow? now, - }) : _dedupeWindow = dedupeWindow, - _now = now ?? DateTime.now; - - bool shouldPresent({required String eventId, required bool isAppActive}) { - if (!isAppActive) { - return false; - } - - final currentTime = _now(); - final lastPresentedAt = _lastPresentedAtByEventId[eventId]; - if (lastPresentedAt != null && - currentTime.difference(lastPresentedAt) < _dedupeWindow) { - return false; - } - - _lastPresentedAtByEventId[eventId] = currentTime; - return true; - } -} diff --git a/apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart b/apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart deleted file mode 100644 index 911530e..0000000 --- a/apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../../core/theme/design_tokens.dart'; -import '../../../../../shared/widgets/app_button.dart'; - -class ReminderActionSheet extends StatelessWidget { - const ReminderActionSheet({ - super.key, - required this.onSnooze, - required this.onArchive, - }); - - final VoidCallback onSnooze; - final VoidCallback onArchive; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(AppRadius.xl), - border: Border.all(color: AppColors.borderSecondary), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - '提醒操作', - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: AppColors.slate900), - ), - const SizedBox(height: AppSpacing.lg), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: AppButton( - text: '稍后提醒', - isOutlined: true, - onPressed: onSnooze, - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: AppButton(text: '归档', onPressed: onArchive), - ), - ], - ), - ], - ), - ); - } -} diff --git a/apps/lib/main.dart b/apps/lib/main.dart index f165ece..aa7e0c2 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -17,7 +17,6 @@ import 'features/auth/presentation/bloc/auth_state.dart'; import 'features/calendar/data/services/calendar_service.dart'; import 'features/calendar/data/services/calendar_repository.dart'; import 'features/calendar/reminders/reminder_action_executor.dart'; -import 'features/calendar/reminders/ui/reminder_foreground_presenter.dart'; import 'features/calendar/ui/calendar_state_manager.dart'; import 'features/chat/presentation/bloc/chat_bloc.dart'; import 'features/settings/data/services/settings_user_cache.dart'; @@ -28,10 +27,6 @@ void main() async { await configureDependencies(); await AppConstants.init(); final rootNavigatorKey = GlobalKey(); - final reminderForegroundPresenter = ReminderForegroundPresenter( - navigatorKey: rootNavigatorKey, - executor: sl(), - ); sl().bindActionHandler(({ required action, required payload, @@ -41,9 +36,6 @@ void main() async { payload: payload, ); }); - sl().bindInAppReminderHandler( - reminderForegroundPresenter.present, - ); await sl().initialize(); final authBloc = sl(); diff --git a/apps/test/core/startup/auth_session_bootstrapper_test.dart b/apps/test/core/startup/auth_session_bootstrapper_test.dart index fc90abc..bd1169d 100644 --- a/apps/test/core/startup/auth_session_bootstrapper_test.dart +++ b/apps/test/core/startup/auth_session_bootstrapper_test.dart @@ -36,7 +36,6 @@ void main() { verifyNever(() => calendarService.getEventsForRange(any(), any())); verifyNever(() => notificationService.rebuildUpcomingReminders(any())); - verifyNever(() => reminderActionExecutor.replayPendingActions()); }); test('fetches upcoming events after authenticated state', () async { @@ -46,9 +45,6 @@ void main() { when( () => notificationService.rebuildUpcomingReminders(any()), ).thenAnswer((_) async {}); - when( - () => reminderActionExecutor.replayPendingActions(), - ).thenAnswer((_) async {}); await bootstrapper.syncForAuthState( const AuthAuthenticated( @@ -58,37 +54,5 @@ void main() { verify(() => calendarService.getEventsForRange(any(), any())).called(1); verify(() => notificationService.rebuildUpcomingReminders(any())).called(1); - verify(() => reminderActionExecutor.replayPendingActions()).called(1); - }); - - test('retries sync when previous bootstrap failed', () async { - when( - () => reminderActionExecutor.replayPendingActions(), - ).thenThrow(Exception('offline')); - - await bootstrapper.syncForAuthState( - const AuthAuthenticated( - user: AuthUser(id: 'u1', phone: 'a@test.com'), - ), - ); - - when( - () => reminderActionExecutor.replayPendingActions(), - ).thenAnswer((_) async {}); - when( - () => calendarService.getEventsForRange(any(), any()), - ).thenAnswer((_) async => []); - when( - () => notificationService.rebuildUpcomingReminders(any()), - ).thenAnswer((_) async {}); - - await bootstrapper.syncForAuthState( - const AuthAuthenticated( - user: AuthUser(id: 'u1', phone: 'a@test.com'), - ), - ); - - verify(() => reminderActionExecutor.replayPendingActions()).called(2); - verify(() => calendarService.getEventsForRange(any(), any())).called(1); }); } diff --git a/apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart b/apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart deleted file mode 100644 index 34af9a9..0000000 --- a/apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:social_app/features/calendar/reminders/reminder_action_dedupe_store.dart'; - -void main() { - const dedupeKey = 'calendar_reminder_action_dedupe_v1'; - - setUp(() { - SharedPreferences.setMockInitialValues({}); - }); - - test('markIfNew returns true first and false for duplicate id', () async { - final prefs = await SharedPreferences.getInstance(); - final store = ReminderActionDedupeStore(prefs); - - expect(await store.markIfNew('action_1'), isTrue); - expect(await store.markIfNew('action_1'), isFalse); - }); - - test('markIfNew dedupes after store re-initialization', () async { - final firstPrefs = await SharedPreferences.getInstance(); - final firstStore = ReminderActionDedupeStore(firstPrefs); - - expect(await firstStore.markIfNew('action_restart'), isTrue); - - final secondPrefs = await SharedPreferences.getInstance(); - final secondStore = ReminderActionDedupeStore(secondPrefs); - - expect(await secondStore.markIfNew('action_restart'), isFalse); - }); - - test('markIfNew trims history to max capacity', () async { - final prefs = await SharedPreferences.getInstance(); - final store = ReminderActionDedupeStore(prefs); - - for (var i = 0; i < 513; i++) { - expect(await store.markIfNew('action_$i'), isTrue); - } - - final stored = prefs.getStringList(dedupeKey)!; - expect(stored.length, 512); - expect(stored.first, 'action_1'); - expect(stored.last, 'action_512'); - }); - - test( - 'markIfNew is serialized and does not return true twice in parallel', - () async { - final prefs = await SharedPreferences.getInstance(); - final store = ReminderActionDedupeStore( - prefs, - setStringList: (key, value) async { - await Future.delayed(const Duration(milliseconds: 20)); - return prefs.setStringList(key, value); - }, - ); - - final results = await Future.wait([ - store.markIfNew('parallel_action'), - store.markIfNew('parallel_action'), - ]); - - expect(results.where((item) => item).length, 1); - expect(results.where((item) => !item).length, 1); - }, - ); - - test('markIfNew returns false when persistence write fails', () async { - final prefs = await SharedPreferences.getInstance(); - final store = ReminderActionDedupeStore( - prefs, - setStringList: (key, value) async => false, - ); - - expect(await store.markIfNew('action_write_fail'), isFalse); - expect(prefs.getStringList(dedupeKey), isNull); - }); -} diff --git a/apps/test/features/calendar/reminders/reminder_action_executor_test.dart b/apps/test/features/calendar/reminders/reminder_action_executor_test.dart index f9a5090..9b7ac7a 100644 --- a/apps/test/features/calendar/reminders/reminder_action_executor_test.dart +++ b/apps/test/features/calendar/reminders/reminder_action_executor_test.dart @@ -1,13 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:social_app/core/notifications/local_notification_service.dart'; import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; import 'package:social_app/features/calendar/data/services/calendar_service.dart'; import 'package:social_app/features/calendar/reminders/models/reminder_action.dart'; import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart'; import 'package:social_app/features/calendar/reminders/reminder_action_executor.dart'; -import 'package:social_app/features/calendar/reminders/reminder_outbox_store.dart'; class MockCalendarService extends Mock implements CalendarService {} @@ -17,19 +15,14 @@ class MockLocalNotificationService extends Mock void main() { late MockCalendarService calendarService; late MockLocalNotificationService notificationService; - late ReminderOutboxStore outboxStore; late ReminderActionExecutor executor; - setUp(() async { - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); + setUp(() { calendarService = MockCalendarService(); notificationService = MockLocalNotificationService(); - outboxStore = ReminderOutboxStore(prefs); executor = ReminderActionExecutor( calendarService: calendarService, notificationService: notificationService, - outboxStore: outboxStore, ); }); @@ -53,33 +46,6 @@ void main() { verify(() => notificationService.cancelEventReminder('evt_1')).called(1); verify(() => calendarService.archiveEvent('evt_1')).called(1); - final pending = await outboxStore.listPending(); - expect(pending, isEmpty); - }); - - test('archive failure writes pending outbox item', () async { - when( - () => notificationService.cancelEventReminder('evt_1'), - ).thenAnswer((_) async {}); - when( - () => calendarService.archiveEvent('evt_1'), - ).thenThrow(Exception('offline')); - - await executor.handleAction( - action: ReminderAction.archive, - payload: ReminderPayload( - eventId: 'evt_1', - title: 'sync', - startAt: DateTime.parse('2026-03-18T16:00:00+08:00'), - timezone: 'Asia/Shanghai', - ), - ); - - final pending = await outboxStore.listPending(); - expect(pending.length, 1); - expect(pending.first.eventId, 'evt_1'); - expect(pending.first.state, ReminderOutboxState.pending); - verify(() => calendarService.archiveEvent('evt_1')).called(1); }); test('snooze reschedules +10m when event not expired', () async { @@ -151,24 +117,4 @@ void main() { verify(() => calendarService.archiveEvent('evt_fallback')).called(1); }, ); - - test('replay keeps pending item when targetStatus is not archived', () async { - const opId = 'op_non_archived'; - await outboxStore.enqueue( - ReminderOutboxItem( - opId: opId, - eventId: 'evt_1', - action: ReminderAction.archive, - targetStatus: 'ignored', - occurredAt: DateTime.parse('2026-03-18T16:00:00+08:00'), - ), - ); - - await executor.replayPendingActions(); - - final pending = await outboxStore.listPending(); - expect(pending.length, 1); - expect(pending.first.opId, opId); - verifyNever(() => calendarService.archiveEvent(any())); - }); } diff --git a/apps/test/features/calendar/reminders/reminder_action_sheet_test.dart b/apps/test/features/calendar/reminders/reminder_action_sheet_test.dart deleted file mode 100644 index 9d2a284..0000000 --- a/apps/test/features/calendar/reminders/reminder_action_sheet_test.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:social_app/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart'; - -void main() { - Future pumpSheet( - WidgetTester tester, { - required VoidCallback onSnooze, - required VoidCallback onArchive, - }) { - return tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: ReminderActionSheet(onSnooze: onSnooze, onArchive: onArchive), - ), - ), - ); - } - - testWidgets('tap snooze button triggers onSnooze', (tester) async { - var snoozed = false; - - await pumpSheet(tester, onSnooze: () => snoozed = true, onArchive: () {}); - - await tester.tap(find.text('稍后提醒')); - await tester.pump(); - - expect(snoozed, isTrue); - }); - - testWidgets('tap archive button triggers onArchive', (tester) async { - var archived = false; - - await pumpSheet(tester, onSnooze: () {}, onArchive: () => archived = true); - - await tester.tap(find.text('归档')); - await tester.pump(); - - expect(archived, isTrue); - }); -} diff --git a/apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart b/apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart deleted file mode 100644 index 2019791..0000000 --- a/apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:social_app/features/calendar/reminders/reminder_cold_start_queue.dart'; - -void main() { - test('replays queued actions in enqueue order', () async { - final queue = ReminderColdStartQueue(); - final events = []; - - queue.enqueue(() async { - await Future.delayed(const Duration(milliseconds: 20)); - events.add('first'); - }); - queue.enqueue(() async { - events.add('second'); - }); - queue.enqueue(() async { - events.add('third'); - }); - - await queue.replay(); - - expect(events, ['first', 'second', 'third']); - }); - - test('single failure does not block following queued actions', () async { - final queue = ReminderColdStartQueue(); - final events = []; - final errors = []; - - queue.enqueue(() async { - events.add('before'); - }); - queue.enqueue(() async { - throw StateError('boom'); - }); - queue.enqueue(() async { - events.add('after'); - }); - - final observableQueue = ReminderColdStartQueue( - onTaskError: (Object error, StackTrace _) { - errors.add(error); - }, - ); - - observableQueue.enqueue(() async { - throw StateError('boom_observable'); - }); - - await queue.replay(); - await observableQueue.replay(); - - expect(events, ['before', 'after']); - expect(errors.length, 1); - expect(errors.first, isA()); - }); - - test('concurrent replay calls join the same in-flight replay', () async { - final queue = ReminderColdStartQueue(); - final taskGate = Completer(); - var runCount = 0; - var secondReplayCompleted = false; - - queue.enqueue(() async { - runCount += 1; - await taskGate.future; - }); - - final firstReplay = queue.replay(); - final secondReplay = queue.replay().then((_) { - secondReplayCompleted = true; - }); - - await Future.delayed(const Duration(milliseconds: 10)); - expect(secondReplayCompleted, isFalse); - - taskGate.complete(); - await Future.wait(>[firstReplay, secondReplay]); - - expect(runCount, 1); - expect(secondReplayCompleted, isTrue); - }); - - test('replay on empty queue does not block future enqueued tasks', () async { - final queue = ReminderColdStartQueue(); - final events = []; - - await queue.replay(); - - queue.enqueue(() async { - events.add('after_empty_replay'); - }); - - await queue.replay(); - - expect(events, ['after_empty_replay']); - }); - - test('task-triggered replay reuses in-flight replay and completes', () async { - final queue = ReminderColdStartQueue(); - final events = []; - var nestedReplayCompleted = false; - - queue.enqueue(() async { - events.add('first'); - queue.replay().then((_) { - nestedReplayCompleted = true; - }); - }); - - queue.enqueue(() async { - events.add('second'); - }); - - await queue.replay(); - await Future.delayed(Duration.zero); - - expect(events, ['first', 'second']); - expect(nestedReplayCompleted, isTrue); - }); -} diff --git a/apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart b/apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart deleted file mode 100644 index 2cb85a5..0000000 --- a/apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; -import 'package:social_app/features/calendar/reminders/reminder_overlap_policy.dart'; - -void main() { - final policy = ReminderOverlapPolicy(); - - test('groups reminders in same minute bucket', () { - final now = DateTime(2026, 3, 18, 15, 40, 0); - final eventA = ScheduleItemModel( - id: 'a', - ownerId: 'u1', - title: 'A', - startAt: DateTime(2026, 3, 18, 16, 0, 0), - endAt: DateTime(2026, 3, 18, 17, 0, 0), - metadata: ScheduleMetadata(reminderMinutes: 15), - ); - final eventB = ScheduleItemModel( - id: 'b', - ownerId: 'u1', - title: 'B', - startAt: DateTime(2026, 3, 18, 16, 0, 20), - endAt: DateTime(2026, 3, 18, 17, 0, 0), - metadata: ScheduleMetadata(reminderMinutes: 15), - ); - - final groups = policy.groupByMinute([eventA, eventB], now: now); - expect(groups.length, 1); - expect(groups.first.events.length, 2); - expect(groups.first.isAggregate, isTrue); - }); - - test('returns compensation fire time when remindAt already passed', () { - final now = DateTime(2026, 3, 18, 15, 50, 0); - final event = ScheduleItemModel( - id: 'a', - ownerId: 'u1', - title: 'A', - startAt: DateTime(2026, 3, 18, 16, 0, 0), - endAt: DateTime(2026, 3, 18, 16, 30, 0), - metadata: ScheduleMetadata(reminderMinutes: 15), - ); - - final fireAt = policy.resolveFirstFireAt(event, now: now); - expect(fireAt, isNotNull); - expect(fireAt!.isAfter(now), isTrue); - }); -} diff --git a/apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart b/apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart deleted file mode 100644 index 325cdc3..0000000 --- a/apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:social_app/features/calendar/reminders/ui/reminder_presentation_coordinator.dart'; - -void main() { - test('blocks foreground presentation when app is not active', () { - final coordinator = ReminderPresentationCoordinator(); - - final hiddenDecision = coordinator.shouldPresent( - eventId: 'event_1', - isAppActive: false, - ); - final activeDecision = coordinator.shouldPresent( - eventId: 'event_1', - isAppActive: true, - ); - - expect(hiddenDecision, isFalse); - expect(activeDecision, isTrue); - }); - - test('suppresses duplicate foreground presentation inside dedupe window', () { - var fakeNow = DateTime(2026, 3, 19, 10, 0, 0); - final coordinator = ReminderPresentationCoordinator( - dedupeWindow: const Duration(seconds: 30), - now: () => fakeNow, - ); - - final first = coordinator.shouldPresent( - eventId: 'event_42', - isAppActive: true, - ); - fakeNow = fakeNow.add(const Duration(seconds: 10)); - final second = coordinator.shouldPresent( - eventId: 'event_42', - isAppActive: true, - ); - - expect(first, isTrue); - expect(second, isFalse); - }); - - test('allows same event again after dedupe window expires', () { - var fakeNow = DateTime(2026, 3, 19, 10, 0, 0); - final coordinator = ReminderPresentationCoordinator( - dedupeWindow: const Duration(seconds: 30), - now: () => fakeNow, - ); - - expect( - coordinator.shouldPresent(eventId: 'event_42', isAppActive: true), - isTrue, - ); - fakeNow = fakeNow.add(const Duration(seconds: 31)); - - expect( - coordinator.shouldPresent(eventId: 'event_42', isAppActive: true), - isTrue, - ); - }); -} From 4b29b300da90b2abaeb32c0654f16264f64ebc99 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 18:47:50 +0800 Subject: [PATCH 2/4] feat(calendar): implement ReminderOverlay and supporting components - Add ReminderQueueManager for managing notification queue - Add IOSNotificationPayloadBridge for iOS cold start handling - Add ReminderOverlay UI component with snooze (5/15 min) and complete actions - Update main.dart to integrate ReminderOverlay - LocalNotificationService: remove permission fallback logic, add native grouping --- .../ios_notification_payload_bridge.dart | 27 +++ .../reminders/reminder_queue_manager.dart | 31 +++ .../reminders/ui/reminder_overlay.dart | 191 ++++++++++++++++++ apps/lib/main.dart | 102 +++++++++- 4 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 apps/lib/core/notifications/ios_notification_payload_bridge.dart create mode 100644 apps/lib/features/calendar/reminders/reminder_queue_manager.dart create mode 100644 apps/lib/features/calendar/reminders/ui/reminder_overlay.dart diff --git a/apps/lib/core/notifications/ios_notification_payload_bridge.dart b/apps/lib/core/notifications/ios_notification_payload_bridge.dart new file mode 100644 index 0000000..4842b34 --- /dev/null +++ b/apps/lib/core/notifications/ios_notification_payload_bridge.dart @@ -0,0 +1,27 @@ +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); + } +} diff --git a/apps/lib/features/calendar/reminders/reminder_queue_manager.dart b/apps/lib/features/calendar/reminders/reminder_queue_manager.dart new file mode 100644 index 0000000..5636516 --- /dev/null +++ b/apps/lib/features/calendar/reminders/reminder_queue_manager.dart @@ -0,0 +1,31 @@ +import 'models/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(); + } +} diff --git a/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart b/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart new file mode 100644 index 0000000..788a3a2 --- /dev/null +++ b/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.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; + 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(); + + final box = context.findRenderObject() as RenderBox?; + if (box == null) return; + + final button = box.localToGlobal(Offset.zero); + + _overlayEntry = OverlayEntry( + builder: (context) => Positioned( + left: button.dx, + top: button.dy + box.size.height + 4, + width: 120, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: Container( + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _SnoozeOption( + label: '5 分钟', + onTap: () { + _hideSnoozeOptions(); + _handleSnooze(5); + }, + ), + const Divider(height: 1, color: AppColors.borderSecondary), + _SnoozeOption( + label: '15 分钟', + onTap: () { + _hideSnoozeOptions(); + _handleSnooze(15); + }, + ), + ], + ), + ), + ), + ), + ); + + Overlay.of(context).insert(_overlayEntry!); + setState(() { + _showSnoozeOptions = true; + }); + } + + void _handleComplete() { + widget.onArchive(); + widget.queueManager.dequeueCurrent(); + widget.onComplete(); + } + + void _handleSnooze(int minutes) { + widget.onSnooze(minutes); + widget.queueManager.dequeueCurrent(); + 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: 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), + ), + ), + ); + } +} diff --git a/apps/lib/main.dart b/apps/lib/main.dart index aa7e0c2..c7832fa 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -2,12 +2,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'core/constants/app_constants.dart'; import 'core/cache/cache_refresh_coordinator.dart'; import 'core/di/injection.dart'; import 'core/notifications/local_notification_service.dart'; import 'core/notifications/reminder_notification_callbacks.dart'; +import 'core/notifications/ios_notification_payload_bridge.dart'; import 'core/router/app_router.dart'; import 'core/startup/auth_session_bootstrapper.dart'; import 'core/theme/app_theme.dart'; @@ -17,6 +19,8 @@ import 'features/auth/presentation/bloc/auth_state.dart'; import 'features/calendar/data/services/calendar_service.dart'; import 'features/calendar/data/services/calendar_repository.dart'; import 'features/calendar/reminders/reminder_action_executor.dart'; +import 'features/calendar/reminders/reminder_queue_manager.dart'; +import 'features/calendar/reminders/ui/reminder_overlay.dart'; import 'features/calendar/ui/calendar_state_manager.dart'; import 'features/chat/presentation/bloc/chat_bloc.dart'; import 'features/settings/data/services/settings_user_cache.dart'; @@ -26,6 +30,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await configureDependencies(); await AppConstants.init(); + final rootNavigatorKey = GlobalKey(); sl().bindActionHandler(({ required action, @@ -60,6 +65,15 @@ void main() async { ); WidgetsBinding.instance.addObserver(cacheRefreshCoordinator); + final prefs = await SharedPreferences.getInstance(); + final payloadBridge = IOSNotificationPayloadBridge(prefs); + final pendingPayload = await payloadBridge.getPendingPayload(); + final queueManager = ReminderQueueManager(); + if (pendingPayload != null) { + queueManager.enqueueFromClick(pendingPayload); + await payloadBridge.clearPendingPayload(); + } + runApp( LinksyApp( authBloc: authBloc, @@ -69,6 +83,8 @@ void main() async { notificationService: sl(), reminderActionExecutor: sl(), ), + queueManager: queueManager, + payloadBridge: payloadBridge, ), ); @@ -81,35 +97,113 @@ void main() async { }); } -class LinksyApp extends StatelessWidget { +class LinksyApp extends StatefulWidget { final AuthBloc authBloc; final GlobalKey rootNavigatorKey; final AuthSessionBootstrapper sessionBootstrapper; + final ReminderQueueManager queueManager; + final IOSNotificationPayloadBridge payloadBridge; const LinksyApp({ super.key, required this.authBloc, required this.rootNavigatorKey, required this.sessionBootstrapper, + required this.queueManager, + required this.payloadBridge, }); + @override + State createState() => _LinksyAppState(); +} + +class _LinksyAppState extends State { + OverlayEntry? _reminderOverlay; + + @override + void initState() { + super.initState(); + _checkAndShowReminderOverlay(); + } + + Future _checkAndShowReminderOverlay() async { + if (widget.queueManager.currentPayload != null) { + _showReminderOverlay(); + } + } + + void _showReminderOverlay() { + if (_reminderOverlay != null) return; + + _reminderOverlay = OverlayEntry( + builder: (context) => Positioned.fill( + child: Material( + color: Colors.black54, + child: ReminderOverlay( + queueManager: widget.queueManager, + onComplete: _onReminderComplete, + onSnooze: _onSnooze, + onArchive: _onArchive, + ), + ), + ), + ); + + Overlay.of(context).insert(_reminderOverlay!); + } + + void _onReminderComplete() { + _reminderOverlay?.remove(); + _reminderOverlay = null; + + if (!widget.queueManager.isEmpty) { + _showReminderOverlay(); + } + } + + Future _onSnooze(int minutes) async { + final payload = widget.queueManager.currentPayload; + if (payload == null) return; + + await sl().cancelEventReminder(payload.eventId); + final event = await sl().getEventById(payload.eventId); + if (event != null) { + final snoozeTime = DateTime.now().add(Duration(minutes: minutes)); + await sl().scheduleReminderAt( + event, + snoozeTime, + ); + } + } + + Future _onArchive() async { + final payload = widget.queueManager.currentPayload; + if (payload == null) return; + + try { + await sl().archiveEvent(payload.eventId); + } catch (_) { + // archive failed, continue anyway + } + } + @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - BlocProvider.value(value: authBloc), + BlocProvider.value(value: widget.authBloc), BlocProvider(create: (_) => ChatBloc(apiClient: sl())), ], child: BlocListener( listenWhen: (previous, current) => previous != current, listener: (context, state) { - unawaited(sessionBootstrapper.syncForAuthState(state)); + unawaited(widget.sessionBootstrapper.syncForAuthState(state)); }, child: MaterialApp.router( title: 'Linksy', debugShowCheckedModeBanner: false, theme: AppTheme.light, - routerConfig: createAppRouter(authBloc), + routerConfig: createAppRouter(widget.authBloc), ), ), ); From 42c2fbc8f38febe6261b3b209be86e49b3aea7dc Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 18:48:22 +0800 Subject: [PATCH 3/4] feat(ios): write notification payload to UserDefaults on tap --- apps/ios/Runner/AppDelegate.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/ios/Runner/AppDelegate.swift b/apps/ios/Runner/AppDelegate.swift index 1e8d67e..5c3053e 100644 --- a/apps/ios/Runner/AppDelegate.swift +++ b/apps/ios/Runner/AppDelegate.swift @@ -18,4 +18,17 @@ import UserNotifications } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + if let jsonData = try? JSONSerialization.data(withJSONObject: userInfo, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) { + UserDefaults.standard.set(jsonString, forKey: "pending_notification_payload") + } + completionHandler() + } } From d17f5d78aaf06a8298c01df7492f79978e630f93 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 18:53:32 +0800 Subject: [PATCH 4/4] refactor: cleanup dead code from reminder system - Remove permission fallback logic from LocalNotificationService - Remove unused methods: _scheduleInApp*, _trackFallback, bindInAppReminderHandler - Remove unused fields: _permissionFallbackTracker, _inAppReminderHandler, _inAppFallbackTimersByEventId, _canDeliverSystemNotification - Remove unused _showSnoozeOptions from ReminderOverlay - Remove unused reminderActionExecutor from AuthSessionBootstrapper - Remove obsolete test files: reminder_permission_fallback_test, reminder_notification_bridge_test, auth_session_bootstrapper_test - Add native notification grouping (threadIdentifier/groupKey) --- .../local_notification_service.dart | 351 +--------------- .../startup/auth_session_bootstrapper.dart | 6 +- .../reminders/ui/reminder_overlay.dart | 7 - apps/lib/main.dart | 1 - .../auth_session_bootstrapper_test.dart | 58 --- .../reminder_notification_bridge_test.dart | 146 ------- .../reminder_permission_fallback_test.dart | 392 ------------------ 7 files changed, 23 insertions(+), 938 deletions(-) delete mode 100644 apps/test/core/startup/auth_session_bootstrapper_test.dart delete mode 100644 apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart delete mode 100644 apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart diff --git a/apps/lib/core/notifications/local_notification_service.dart b/apps/lib/core/notifications/local_notification_service.dart index 19e3b0d..ea463d1 100644 --- a/apps/lib/core/notifications/local_notification_service.dart +++ b/apps/lib/core/notifications/local_notification_service.dart @@ -1,8 +1,6 @@ -import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.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; @@ -18,45 +16,22 @@ typedef ReminderNotificationActionHandler = required ReminderPayload payload, }); -typedef ReminderPermissionFallbackTracker = - void Function({ - required String actionExecutionId, - required String permissionState, - required String appLifecycleState, - required String platform, - }); - -typedef ReminderInAppReminderHandler = - Future Function(ReminderPayload payload); - class LocalNotificationService { static const String _iosCategoryId = 'calendar_reminder_v2'; static const String _actionCancel = 'cancel'; static const String _actionSnooze = 'snooze10m'; final FlutterLocalNotificationsPlugin _plugin; - final ReminderPermissionFallbackTracker? _permissionFallbackTracker; bool _initialized = false; - bool _canDeliverSystemNotification = true; ReminderNotificationActionHandler? _actionHandler; - ReminderInAppReminderHandler? _inAppReminderHandler; - final Map> _inAppFallbackTimersByEventId = - >{}; - LocalNotificationService({ - FlutterLocalNotificationsPlugin? plugin, - ReminderPermissionFallbackTracker? permissionFallbackTracker, - }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(), - _permissionFallbackTracker = permissionFallbackTracker; + LocalNotificationService({FlutterLocalNotificationsPlugin? plugin}) + : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); void bindActionHandler(ReminderNotificationActionHandler handler) { _actionHandler = handler; } - void bindInAppReminderHandler(ReminderInAppReminderHandler handler) { - _inAppReminderHandler = handler; - } - Future initialize() async { if (_initialized) { return; @@ -98,18 +73,7 @@ class LocalNotificationService { .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); - final androidPermissionGranted = await androidImpl - ?.requestNotificationsPermission(); - if (defaultTargetPlatform == TargetPlatform.android && - androidPermissionGranted == false) { - _canDeliverSystemNotification = false; - _permissionFallbackTracker?.call( - actionExecutionId: 'permission_check', - permissionState: 'denied', - appLifecycleState: 'unknown', - platform: 'android', - ); - } + await androidImpl?.requestNotificationsPermission(); await androidImpl?.requestExactAlarmsPermission(); await androidImpl?.requestFullScreenIntentPermission(); @@ -122,24 +86,8 @@ class LocalNotificationService { _initialized = true; } - Future _refreshAndroidNotificationAvailability() async { - if (defaultTargetPlatform != TargetPlatform.android) { - return; - } - final androidImpl = _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); - final enabled = await androidImpl?.areNotificationsEnabled(); - if (enabled == null) { - return; - } - _canDeliverSystemNotification = enabled; - } - Future upsertEventReminder(ScheduleItemModel event) async { await initialize(); - await _refreshAndroidNotificationAvailability(); if (event.status != ScheduleStatus.active || event.metadata?.reminderMinutes == null) { await cancelEventReminder(event.id); @@ -154,14 +102,6 @@ class LocalNotificationService { return; } - if (!_canDeliverSystemNotification) { - await _scheduleInAppFallbackRemindersFrom( - event: event, - firstFireAt: fireAt, - ); - return; - } - await cancelEventReminder(event.id); await _scheduleRemindersFrom(event: event, firstFireAt: fireAt); } @@ -171,21 +111,12 @@ class LocalNotificationService { DateTime fireAt, ) async { await initialize(); - await _refreshAndroidNotificationAvailability(); - if (!_canDeliverSystemNotification) { - await _scheduleInAppFallbackRemindersFrom( - event: event, - firstFireAt: fireAt, - ); - return; - } await cancelEventReminder(event.id); await _scheduleRemindersFrom(event: event, firstFireAt: fireAt); } Future cancelEventReminder(String eventId) async { await initialize(); - _cancelInAppFallbackTimers(eventId); final pending = await _plugin.pendingNotificationRequests(); for (final request in pending) { @@ -206,21 +137,7 @@ class LocalNotificationService { Iterable events, ) async { await initialize(); - await _refreshAndroidNotificationAvailability(); for (final event in events) { - if (!_canDeliverSystemNotification) { - final reminderMinutes = event.metadata?.reminderMinutes ?? 0; - final fireAt = event.startAt.subtract( - Duration(minutes: reminderMinutes), - ); - if (fireAt.isAfter(DateTime.now())) { - await _scheduleInAppFallbackRemindersFrom( - event: event, - firstFireAt: fireAt, - ); - } - continue; - } await upsertEventReminder(event); } } @@ -241,10 +158,6 @@ class LocalNotificationService { } Future _resolveAndroidScheduleMode() async { - if (defaultTargetPlatform != TargetPlatform.android) { - return AndroidScheduleMode.exactAllowWhileIdle; - } - final androidImpl = _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin @@ -260,7 +173,7 @@ class LocalNotificationService { : AndroidScheduleMode.inexactAllowWhileIdle; } - NotificationDetails _buildNotificationDetails() { + NotificationDetails _buildNotificationDetails(DateTime fireAt) { return NotificationDetails( android: AndroidNotificationDetails( 'calendar_alarm_channel_v2', @@ -276,6 +189,7 @@ class LocalNotificationService { vibrationPattern: Int64List.fromList([0, 1000, 500, 1200]), timeoutAfter: 30000, autoCancel: true, + groupKey: _getGroupKey(fireAt), actions: [ AndroidNotificationAction(_actionCancel, '取消'), AndroidNotificationAction( @@ -285,15 +199,30 @@ class LocalNotificationService { ), ], ), - iOS: const DarwinNotificationDetails( + iOS: DarwinNotificationDetails( presentAlert: true, presentSound: true, presentBadge: true, categoryIdentifier: _iosCategoryId, + threadIdentifier: _getThreadIdentifier(fireAt), ), ); } + 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'; + } + Future _scheduleSingleReminder({ required ScheduleItemModel event, required DateTime fireAt, @@ -319,7 +248,7 @@ class LocalNotificationService { version: 1, ); - final details = _buildNotificationDetails(); + final details = _buildNotificationDetails(fireAt); final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); final mode = await _resolveAndroidScheduleMode(); @@ -350,185 +279,6 @@ class LocalNotificationService { } } - Future _scheduleInAppAggregateFallback( - List events, - DateTime fireAt, - ) async { - if (events.isEmpty) { - return; - } - - final aggregateIds = events.map((event) => event.id).toList(); - for (final eventId in aggregateIds) { - _cancelInAppFallbackTimers(eventId); - } - - final first = events.first; - final payload = ReminderPayload( - eventId: first.id, - title: '你有${events.length}个日程提醒', - startAt: first.startAt, - endAt: first.endAt, - timezone: first.timezone, - mode: ReminderPayloadMode.aggregate, - aggregateIds: aggregateIds, - fireTimeBucket: - fireAt.millisecondsSinceEpoch ~/ - const Duration(minutes: 1).inMilliseconds, - version: 1, - ); - await _scheduleInAppFallbackPayload( - eventId: first.id, - fireAt: fireAt, - payload: payload, - relatedEventIds: aggregateIds, - ); - } - - Future _scheduleInAppFallbackRemindersFrom({ - required ScheduleItemModel event, - required DateTime firstFireAt, - }) async { - _cancelInAppFallbackTimers(event.id); - - final endAt = event.endAt; - var cursor = firstFireAt; - Future scheduleAt(DateTime fireAt) async { - 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, - fireTimeBucket: - fireAt.millisecondsSinceEpoch ~/ - const Duration(minutes: 1).inMilliseconds, - version: 1, - ); - await _scheduleInAppFallbackPayload( - eventId: event.id, - fireAt: fireAt, - payload: payload, - relatedEventIds: [event.id], - ); - } - - if (endAt == null) { - await scheduleAt(cursor); - return; - } - - while (cursor.isBefore(endAt)) { - await scheduleAt(cursor); - cursor = cursor.add(const Duration(minutes: 10)); - } - } - - Future _scheduleInAppFallbackPayload({ - required String eventId, - required DateTime fireAt, - required ReminderPayload payload, - required List relatedEventIds, - }) async { - final handler = _inAppReminderHandler; - final bucket = - fireAt.millisecondsSinceEpoch ~/ - const Duration(minutes: 1).inMilliseconds; - final actionExecutionId = '$eventId|fallback|$bucket'; - _trackFallback( - actionExecutionId: actionExecutionId, - permissionState: 'denied', - ); - - if (handler == null) { - return null; - } - - final now = DateTime.now(); - final delay = fireAt.isAfter(now) ? fireAt.difference(now) : Duration.zero; - late final Timer timer; - timer = Timer(delay, () { - final activeHandler = _inAppReminderHandler; - if (activeHandler == null) { - _unregisterInAppFallbackTimer(relatedEventIds, timer); - return; - } - activeHandler(payload); - _unregisterInAppFallbackTimer(relatedEventIds, timer); - }); - _registerInAppFallbackTimer(relatedEventIds, timer); - return timer; - } - - void _registerInAppFallbackTimer(List eventIds, Timer timer) { - for (final eventId in eventIds) { - final timers = _inAppFallbackTimersByEventId.putIfAbsent( - eventId, - () => [], - ); - timers.add(timer); - } - } - - void _unregisterInAppFallbackTimer(List eventIds, Timer timer) { - for (final eventId in eventIds) { - final timers = _inAppFallbackTimersByEventId[eventId]; - if (timers == null) { - continue; - } - timers.remove(timer); - if (timers.isEmpty) { - _inAppFallbackTimersByEventId.remove(eventId); - } - } - } - - void _cancelInAppFallbackTimers(String eventId) { - final timers = _inAppFallbackTimersByEventId.remove(eventId); - if (timers == null) { - return; - } - - for (final timer in timers.toSet()) { - for (final entry in _inAppFallbackTimersByEventId.entries) { - entry.value.remove(timer); - } - timer.cancel(); - } - - _inAppFallbackTimersByEventId.removeWhere((_, value) => value.isEmpty); - } - - void _clearAllInAppFallbackTimers() { - final allTimers = {}; - for (final timers in _inAppFallbackTimersByEventId.values) { - allTimers.addAll(timers); - } - _inAppFallbackTimersByEventId.clear(); - - for (final timer in allTimers) { - timer.cancel(); - } - } - - void _trackFallback({ - required String actionExecutionId, - required String permissionState, - }) { - final lifecycleState = - WidgetsBinding.instance.lifecycleState?.name ?? 'unknown'; - _permissionFallbackTracker?.call( - actionExecutionId: actionExecutionId, - permissionState: permissionState, - appLifecycleState: lifecycleState, - platform: 'android', - ); - } - Future _scheduleRemindersFrom({ required ScheduleItemModel event, required DateTime firstFireAt, @@ -546,56 +296,6 @@ class LocalNotificationService { } } - 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, - fireTimeBucket: - fireAt.millisecondsSinceEpoch ~/ - const Duration(minutes: 1).inMilliseconds, - 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; @@ -650,13 +350,6 @@ class LocalNotificationService { } if (action == null) { - if (response.notificationResponseType == - NotificationResponseType.selectedNotification) { - final presenter = _inAppReminderHandler; - if (presenter != null) { - await presenter(payload); - } - } return; } diff --git a/apps/lib/core/startup/auth_session_bootstrapper.dart b/apps/lib/core/startup/auth_session_bootstrapper.dart index 0fb74fe..003fa69 100644 --- a/apps/lib/core/startup/auth_session_bootstrapper.dart +++ b/apps/lib/core/startup/auth_session_bootstrapper.dart @@ -1,20 +1,16 @@ 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, - _reminderActionExecutor = reminderActionExecutor; + _notificationService = notificationService; final CalendarService _calendarService; final LocalNotificationService _notificationService; - final ReminderActionExecutor _reminderActionExecutor; String? _syncedUserId; diff --git a/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart b/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart index 788a3a2..c2cb2b7 100644 --- a/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart +++ b/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart @@ -24,7 +24,6 @@ class ReminderOverlay extends StatefulWidget { } class _ReminderOverlayState extends State { - bool _showSnoozeOptions = false; OverlayEntry? _overlayEntry; ReminderPayload? get _currentPayload => widget.queueManager.currentPayload; @@ -38,9 +37,6 @@ class _ReminderOverlayState extends State { void _hideSnoozeOptions() { _overlayEntry?.remove(); _overlayEntry = null; - setState(() { - _showSnoozeOptions = false; - }); } void _showSnoozeDropdown() { @@ -91,9 +87,6 @@ class _ReminderOverlayState extends State { ); Overlay.of(context).insert(_overlayEntry!); - setState(() { - _showSnoozeOptions = true; - }); } void _handleComplete() { diff --git a/apps/lib/main.dart b/apps/lib/main.dart index c7832fa..6fee5af 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -81,7 +81,6 @@ void main() async { sessionBootstrapper: AuthSessionBootstrapper( calendarService: sl(), notificationService: sl(), - reminderActionExecutor: sl(), ), queueManager: queueManager, payloadBridge: payloadBridge, diff --git a/apps/test/core/startup/auth_session_bootstrapper_test.dart b/apps/test/core/startup/auth_session_bootstrapper_test.dart deleted file mode 100644 index bd1169d..0000000 --- a/apps/test/core/startup/auth_session_bootstrapper_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:social_app/core/notifications/local_notification_service.dart'; -import 'package:social_app/core/startup/auth_session_bootstrapper.dart'; -import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; -import 'package:social_app/features/calendar/data/services/calendar_service.dart'; -import 'package:social_app/features/calendar/reminders/reminder_action_executor.dart'; - -class MockCalendarService extends Mock implements CalendarService {} - -class MockLocalNotificationService extends Mock - implements LocalNotificationService {} - -class MockReminderActionExecutor extends Mock - implements ReminderActionExecutor {} - -void main() { - late MockCalendarService calendarService; - late MockLocalNotificationService notificationService; - late MockReminderActionExecutor reminderActionExecutor; - late AuthSessionBootstrapper bootstrapper; - - setUp(() { - calendarService = MockCalendarService(); - notificationService = MockLocalNotificationService(); - reminderActionExecutor = MockReminderActionExecutor(); - bootstrapper = AuthSessionBootstrapper( - calendarService: calendarService, - notificationService: notificationService, - reminderActionExecutor: reminderActionExecutor, - ); - }); - - test('does not fetch calendar events for unauthenticated state', () async { - await bootstrapper.syncForAuthState(AuthUnauthenticated()); - - verifyNever(() => calendarService.getEventsForRange(any(), any())); - verifyNever(() => notificationService.rebuildUpcomingReminders(any())); - }); - - test('fetches upcoming events after authenticated state', () async { - when( - () => calendarService.getEventsForRange(any(), any()), - ).thenAnswer((_) async => []); - when( - () => notificationService.rebuildUpcomingReminders(any()), - ).thenAnswer((_) async {}); - - await bootstrapper.syncForAuthState( - const AuthAuthenticated( - user: AuthUser(id: 'u1', phone: 'a@test.com'), - ), - ); - - verify(() => calendarService.getEventsForRange(any(), any())).called(1); - verify(() => notificationService.rebuildUpcomingReminders(any())).called(1); - }); -} diff --git a/apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart b/apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart deleted file mode 100644 index 7d95c4c..0000000 --- a/apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:social_app/core/notifications/local_notification_service.dart'; -import 'package:social_app/core/notifications/reminder_notification_callbacks.dart'; -import 'package:social_app/features/calendar/reminders/models/reminder_action.dart'; -import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart'; - -class MockFlutterLocalNotificationsPlugin extends Mock - implements FlutterLocalNotificationsPlugin {} - -void main() { - setUpAll(() { - registerFallbackValue( - const InitializationSettings( - android: AndroidInitializationSettings('@mipmap/ic_launcher'), - iOS: DarwinInitializationSettings(), - ), - ); - }); - - late MockFlutterLocalNotificationsPlugin plugin; - late LocalNotificationService service; - late List handledActions; - late List presentedPayloads; - DidReceiveNotificationResponseCallback? callback; - - setUp(() async { - SharedPreferences.setMockInitialValues({}); - plugin = MockFlutterLocalNotificationsPlugin(); - service = LocalNotificationService(plugin: plugin); - handledActions = []; - presentedPayloads = []; - - when( - () => plugin.initialize( - any(), - onDidReceiveNotificationResponse: any( - named: 'onDidReceiveNotificationResponse', - ), - onDidReceiveBackgroundNotificationResponse: any( - named: 'onDidReceiveBackgroundNotificationResponse', - ), - ), - ).thenAnswer((invocation) async { - callback = - invocation.namedArguments[#onDidReceiveNotificationResponse] - as DidReceiveNotificationResponseCallback?; - return true; - }); - - when( - () => plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - when( - () => plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - - service.bindActionHandler(({required action, required payload}) async { - handledActions.add(action); - }); - service.bindInAppReminderHandler((payload) async { - presentedPayloads.add(payload); - }); - await ReminderNotificationCallbacks.bindResponseHandler( - service.handleNotificationResponse, - ); - - await service.initialize(); - }); - - test('cancel action from system notification maps to archive', () async { - callback!( - NotificationResponse( - notificationResponseType: - NotificationResponseType.selectedNotificationAction, - id: 101, - actionId: 'cancel', - payload: jsonEncode( - ReminderPayload( - eventId: 'evt_1', - title: 'sync', - startAt: DateTime.parse('2026-03-19T10:00:00+08:00'), - timezone: 'Asia/Shanghai', - ).toJson(), - ), - ), - ); - await Future.delayed(Duration.zero); - - expect(handledActions, [ReminderAction.archive]); - }); - - test('duplicate notification response is handled only once', () async { - final response = NotificationResponse( - notificationResponseType: - NotificationResponseType.selectedNotificationAction, - id: 201, - actionId: 'cancel', - payload: jsonEncode( - ReminderPayload( - eventId: 'evt_2', - title: 'retro', - startAt: DateTime.parse('2026-03-19T11:00:00+08:00'), - timezone: 'Asia/Shanghai', - ).toJson(), - ), - ); - - callback!(response); - callback!(response); - await Future.delayed(Duration.zero); - - expect(handledActions, [ReminderAction.archive]); - }); - - test('notification body tap forwards payload to in-app presenter', () async { - callback!( - NotificationResponse( - notificationResponseType: NotificationResponseType.selectedNotification, - id: 301, - payload: jsonEncode( - ReminderPayload( - eventId: 'evt_3', - title: 'daily sync', - startAt: DateTime.parse('2026-03-19T12:00:00+08:00'), - timezone: 'Asia/Shanghai', - ).toJson(), - ), - ), - ); - await Future.delayed(Duration.zero); - - expect(presentedPayloads.map((item) => item.eventId), ['evt_3']); - expect(handledActions, isEmpty); - }); -} diff --git a/apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart b/apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart deleted file mode 100644 index ad8fb25..0000000 --- a/apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart +++ /dev/null @@ -1,392 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:social_app/core/notifications/local_notification_service.dart'; -import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; -import 'package:timezone/data/latest.dart' as tz_data; -import 'package:timezone/timezone.dart' as tz; - -class MockFlutterLocalNotificationsPlugin extends Mock - implements FlutterLocalNotificationsPlugin {} - -class MockAndroidFlutterLocalNotificationsPlugin extends Mock - implements AndroidFlutterLocalNotificationsPlugin {} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - setUpAll(() { - tz_data.initializeTimeZones(); - registerFallbackValue(tz.TZDateTime.now(tz.local)); - registerFallbackValue(const NotificationDetails()); - registerFallbackValue( - const InitializationSettings( - android: AndroidInitializationSettings('@mipmap/ic_launcher'), - iOS: DarwinInitializationSettings(), - ), - ); - }); - - setUp(() { - SharedPreferences.setMockInitialValues({}); - debugDefaultTargetPlatformOverride = TargetPlatform.android; - }); - - tearDown(() { - debugDefaultTargetPlatformOverride = null; - }); - - test( - 'tracks fallback when Android notifications permission is denied', - () async { - final plugin = MockFlutterLocalNotificationsPlugin(); - final androidImpl = MockAndroidFlutterLocalNotificationsPlugin(); - final fallbackEvents = >[]; - - when( - () => plugin.initialize( - any(), - onDidReceiveNotificationResponse: any( - named: 'onDidReceiveNotificationResponse', - ), - onDidReceiveBackgroundNotificationResponse: any( - named: 'onDidReceiveBackgroundNotificationResponse', - ), - ), - ).thenAnswer((_) async => true); - when( - () => plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), - ).thenReturn(androidImpl); - when( - () => plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - - when( - () => androidImpl.requestNotificationsPermission(), - ).thenAnswer((_) async => false); - when( - () => androidImpl.requestExactAlarmsPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.requestFullScreenIntentPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.areNotificationsEnabled(), - ).thenAnswer((_) async => false); - - final service = LocalNotificationService( - plugin: plugin, - permissionFallbackTracker: - ({ - required actionExecutionId, - required permissionState, - required appLifecycleState, - required platform, - }) { - fallbackEvents.add({ - 'actionExecutionId': actionExecutionId, - 'permissionState': permissionState, - 'appLifecycleState': appLifecycleState, - 'platform': platform, - }); - }, - ); - - await service.initialize(); - - expect(fallbackEvents.length, 1); - expect(fallbackEvents.first['permissionState'], 'denied'); - expect(fallbackEvents.first['platform'], 'android'); - }, - ); - - test( - 'skips reminder scheduling when Android notifications are denied', - () async { - final plugin = MockFlutterLocalNotificationsPlugin(); - final androidImpl = MockAndroidFlutterLocalNotificationsPlugin(); - - when( - () => plugin.initialize( - any(), - onDidReceiveNotificationResponse: any( - named: 'onDidReceiveNotificationResponse', - ), - onDidReceiveBackgroundNotificationResponse: any( - named: 'onDidReceiveBackgroundNotificationResponse', - ), - ), - ).thenAnswer((_) async => true); - when( - () => plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), - ).thenReturn(androidImpl); - when( - () => plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - - when( - () => androidImpl.requestNotificationsPermission(), - ).thenAnswer((_) async => false); - when( - () => androidImpl.requestExactAlarmsPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.requestFullScreenIntentPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.areNotificationsEnabled(), - ).thenAnswer((_) async => false); - - final service = LocalNotificationService(plugin: plugin); - final event = ScheduleItemModel( - id: 'evt_1', - ownerId: 'u1', - title: 'sync', - startAt: DateTime.now().add(const Duration(minutes: 20)), - endAt: DateTime.now().add(const Duration(minutes: 50)), - metadata: ScheduleMetadata(reminderMinutes: 15), - ); - - await service.upsertEventReminder(event); - - verifyNever(() => plugin.pendingNotificationRequests()); - }, - ); - - test( - 'dispatches in-app reminder callback when notifications are denied', - () async { - final plugin = MockFlutterLocalNotificationsPlugin(); - final androidImpl = MockAndroidFlutterLocalNotificationsPlugin(); - final presentedEventIds = []; - - when( - () => plugin.initialize( - any(), - onDidReceiveNotificationResponse: any( - named: 'onDidReceiveNotificationResponse', - ), - onDidReceiveBackgroundNotificationResponse: any( - named: 'onDidReceiveBackgroundNotificationResponse', - ), - ), - ).thenAnswer((_) async => true); - when( - () => plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), - ).thenReturn(androidImpl); - when( - () => plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - - when( - () => androidImpl.requestNotificationsPermission(), - ).thenAnswer((_) async => false); - when( - () => androidImpl.requestExactAlarmsPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.requestFullScreenIntentPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.areNotificationsEnabled(), - ).thenAnswer((_) async => false); - - final service = LocalNotificationService(plugin: plugin); - service.bindInAppReminderHandler((payload) async { - presentedEventIds.add(payload.eventId); - }); - - final event = ScheduleItemModel( - id: 'evt_2', - ownerId: 'u1', - title: 'retro', - startAt: DateTime.now().add(const Duration(minutes: 20)), - endAt: DateTime.now().add(const Duration(minutes: 50)), - metadata: ScheduleMetadata(reminderMinutes: 15), - ); - - await service.scheduleReminderAt( - event, - DateTime.now().add(const Duration(milliseconds: 20)), - ); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(presentedEventIds, contains('evt_2')); - verifyNever( - () => plugin.zonedSchedule( - any(), - any(), - any(), - any(), - any(), - payload: any(named: 'payload'), - androidScheduleMode: any(named: 'androidScheduleMode'), - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - ), - ); - }, - ); - - test('rebuild twice only dispatches one aggregate in-app fallback', () async { - final plugin = MockFlutterLocalNotificationsPlugin(); - final androidImpl = MockAndroidFlutterLocalNotificationsPlugin(); - final presentedPayloads = []; - - when( - () => plugin.initialize( - any(), - onDidReceiveNotificationResponse: any( - named: 'onDidReceiveNotificationResponse', - ), - onDidReceiveBackgroundNotificationResponse: any( - named: 'onDidReceiveBackgroundNotificationResponse', - ), - ), - ).thenAnswer((_) async => true); - when( - () => plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), - ).thenReturn(androidImpl); - when( - () => plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - - when( - () => androidImpl.requestNotificationsPermission(), - ).thenAnswer((_) async => false); - when( - () => androidImpl.requestExactAlarmsPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.requestFullScreenIntentPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.areNotificationsEnabled(), - ).thenAnswer((_) async => false); - - final service = LocalNotificationService(plugin: plugin); - service.bindInAppReminderHandler((payload) async { - presentedPayloads.add(payload.title); - }); - - final startAt = DateTime.now().add(const Duration(milliseconds: 50)); - final event1 = ScheduleItemModel( - id: 'evt_a', - ownerId: 'u1', - title: 'evt_a', - startAt: startAt, - endAt: startAt.add(const Duration(minutes: 30)), - metadata: ScheduleMetadata(reminderMinutes: 0), - ); - final event2 = ScheduleItemModel( - id: 'evt_b', - ownerId: 'u1', - title: 'evt_b', - startAt: startAt, - endAt: startAt.add(const Duration(minutes: 30)), - metadata: ScheduleMetadata(reminderMinutes: 0), - ); - - await service.rebuildUpcomingReminders([event1, event2]); - await service.rebuildUpcomingReminders([event1, event2]); - await Future.delayed(const Duration(milliseconds: 180)); - - expect( - presentedPayloads.where((title) => title.contains('你有2个日程提醒')).length, - 1, - ); - }); - - test( - 'rebuild clears stale in-app fallback timers for removed events', - () async { - final plugin = MockFlutterLocalNotificationsPlugin(); - final androidImpl = MockAndroidFlutterLocalNotificationsPlugin(); - final presentedEventIds = []; - - when( - () => plugin.initialize( - any(), - onDidReceiveNotificationResponse: any( - named: 'onDidReceiveNotificationResponse', - ), - onDidReceiveBackgroundNotificationResponse: any( - named: 'onDidReceiveBackgroundNotificationResponse', - ), - ), - ).thenAnswer((_) async => true); - when( - () => plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), - ).thenReturn(androidImpl); - when( - () => plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - - when( - () => androidImpl.requestNotificationsPermission(), - ).thenAnswer((_) async => false); - when( - () => androidImpl.requestExactAlarmsPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.requestFullScreenIntentPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.areNotificationsEnabled(), - ).thenAnswer((_) async => false); - - final service = LocalNotificationService(plugin: plugin); - service.bindInAppReminderHandler((payload) async { - presentedEventIds.add(payload.eventId); - }); - - final startAt = DateTime.now().add(const Duration(milliseconds: 80)); - final staleEvent = ScheduleItemModel( - id: 'evt_stale', - ownerId: 'u1', - title: 'evt_stale', - startAt: startAt, - endAt: startAt.add(const Duration(minutes: 20)), - metadata: ScheduleMetadata(reminderMinutes: 0), - ); - - await service.rebuildUpcomingReminders([staleEvent]); - await service.rebuildUpcomingReminders(const []); - await Future.delayed(const Duration(milliseconds: 220)); - - expect(presentedEventIds, isNot(contains('evt_stale'))); - }, - ); -}