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, - ); - }); -}