diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index db2d5ab..98a8f89 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,9 @@ + diff --git a/apps/ios/Runner/AppDelegate.swift b/apps/ios/Runner/AppDelegate.swift index bde714f..1e8d67e 100644 --- a/apps/ios/Runner/AppDelegate.swift +++ b/apps/ios/Runner/AppDelegate.swift @@ -1,4 +1,5 @@ import Flutter +import flutter_local_notifications import UIKit import UserNotifications @@ -8,6 +9,9 @@ import UserNotifications _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { registry in + GeneratedPluginRegistrant.register(with: registry) + } GeneratedPluginRegistrant.register(with: self) if #available(iOS 10.0, *) { UNUserNotificationCenter.current().delegate = self diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 01d26ee..60c3822 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -20,6 +20,7 @@ import '../../features/calendar/ui/calendar_state_manager.dart'; import '../../features/friends/data/friends_api.dart'; import '../../features/messages/data/inbox_api.dart'; import '../../features/settings/data/settings_api.dart'; +import '../../features/settings/data/services/settings_user_cache.dart'; import '../../features/users/data/users_api.dart'; import '../../features/todo/data/todo_api.dart'; @@ -82,6 +83,8 @@ Future configureDependencies() async { final settingsApi = SettingsApi(apiClient); sl.registerSingleton(settingsApi); + sl.registerSingleton(SettingsUserCache()); + final inboxApi = InboxApi(apiClient); sl.registerSingleton(inboxApi); @@ -93,6 +96,9 @@ Future configureDependencies() async { tokenStorage: tokenStorage, onLogout: () async { apiClient.resetInterceptor(); + if (sl.isRegistered()) { + sl().invalidate(); + } }, ); sl.registerSingleton(authRepository); @@ -110,6 +116,9 @@ Future configureDependencies() async { }); apiClient.setAuthFailureCallback(() async { + if (sl.isRegistered()) { + sl().invalidate(); + } authBloc.add( const AuthSessionInvalidated( source: AuthInvalidationSource.unauthorized401, diff --git a/apps/lib/core/notifications/local_notification_service.dart b/apps/lib/core/notifications/local_notification_service.dart index 424b51d..2a9de1e 100644 --- a/apps/lib/core/notifications/local_notification_service.dart +++ b/apps/lib/core/notifications/local_notification_service.dart @@ -1,13 +1,18 @@ +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:shared_preferences/shared_preferences.dart'; import 'package:timezone/data/latest.dart' as tz_data; import 'package:timezone/timezone.dart' as tz; +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 = @@ -16,26 +21,51 @@ 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_actions_v1'; + static const String _iosCategoryId = 'calendar_reminder_v2'; static const String _actionCancel = 'cancel'; - static const String _actionSnooze = 'snooze_10m'; + static const String _actionSnooze = 'snooze10m'; final FlutterLocalNotificationsPlugin _plugin; final ReminderOverlapPolicy _overlapPolicy; + final ReminderPermissionFallbackTracker? _permissionFallbackTracker; + ReminderActionDedupeStore? _dedupeStore; bool _initialized = false; + bool _canDeliverSystemNotification = true; ReminderNotificationActionHandler? _actionHandler; + ReminderInAppReminderHandler? _inAppReminderHandler; + final Map> _inAppFallbackTimersByEventId = + >{}; LocalNotificationService({ FlutterLocalNotificationsPlugin? plugin, ReminderOverlapPolicy? overlapPolicy, + ReminderActionDedupeStore? dedupeStore, + ReminderPermissionFallbackTracker? permissionFallbackTracker, }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(), - _overlapPolicy = overlapPolicy ?? const ReminderOverlapPolicy(); + _overlapPolicy = overlapPolicy ?? const ReminderOverlapPolicy(), + _dedupeStore = dedupeStore, + _permissionFallbackTracker = permissionFallbackTracker; void bindActionHandler(ReminderNotificationActionHandler handler) { _actionHandler = handler; } + void bindInAppReminderHandler(ReminderInAppReminderHandler handler) { + _inAppReminderHandler = handler; + } + Future initialize() async { if (_initialized) { return; @@ -67,14 +97,28 @@ class LocalNotificationService { await _plugin.initialize( settings, - onDidReceiveNotificationResponse: _onNotificationResponse, + onDidReceiveNotificationResponse: + ReminderNotificationCallbacks.onForegroundResponse, + onDidReceiveBackgroundNotificationResponse: + reminderNotificationTapBackground, ); final androidImpl = _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); - await androidImpl?.requestNotificationsPermission(); + 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?.requestExactAlarmsPermission(); await androidImpl?.requestFullScreenIntentPermission(); @@ -84,11 +128,37 @@ 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; + } + 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); @@ -102,6 +172,14 @@ class LocalNotificationService { return; } + if (!_canDeliverSystemNotification) { + await _scheduleInAppFallbackRemindersFrom( + event: event, + firstFireAt: fireAt, + ); + return; + } + await cancelEventReminder(event.id); await _scheduleRemindersFrom(event: event, firstFireAt: fireAt); } @@ -111,12 +189,22 @@ 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) { final payload = _decodePayload(request.payload); @@ -136,6 +224,23 @@ class LocalNotificationService { Iterable events, ) 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, + ); + } + return; + } final now = DateTime.now(); final groups = _overlapPolicy.groupByMinute(events, now: now); @@ -236,6 +341,9 @@ class LocalNotificationService { notes: event.metadata?.notes, color: event.metadata?.color, mode: ReminderPayloadMode.single, + fireTimeBucket: + fireAt.millisecondsSinceEpoch ~/ + const Duration(minutes: 1).inMilliseconds, version: 1, ); @@ -270,6 +378,185 @@ 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, @@ -309,6 +596,9 @@ class LocalNotificationService { timezone: first.timezone, mode: ReminderPayloadMode.aggregate, aggregateIds: aggregateIds, + fireTimeBucket: + fireAt.millisecondsSinceEpoch ~/ + const Duration(minutes: 1).inMilliseconds, version: 1, ); @@ -364,31 +654,60 @@ class LocalNotificationService { return buffer.toString(); } - Future _onNotificationResponse(NotificationResponse response) async { + Future handleNotificationResponse(NotificationResponse response) async { final payloadRaw = response.payload; if (payloadRaw == null || payloadRaw.isEmpty) { return; } + ReminderPayload payload; + try { + payload = ReminderPayload.fromJson( + Map.from(jsonDecode(payloadRaw) as Map), + ); + } catch (_) { + debugPrint('failed to handle reminder notification response'); + return; + } + + final actionId = response.actionId; + ReminderAction? action; + if (actionId == _actionCancel) { + action = ReminderAction.archive; + } else if (actionId == _actionSnooze) { + action = ReminderAction.snooze10m; + } + + if (action == null) { + if (response.notificationResponseType == + NotificationResponseType.selectedNotification) { + final presenter = _inAppReminderHandler; + if (presenter != null) { + await presenter(payload); + } + } + return; + } + final handler = _actionHandler; if (handler == null) { return; } - try { - final payload = ReminderPayload.fromJson( - Map.from(jsonDecode(payloadRaw) as Map), - ); - final actionId = response.actionId; - if (actionId == _actionCancel) { - await handler(action: ReminderAction.cancel, payload: payload); + 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; } - if (actionId == _actionSnooze) { - await handler(action: ReminderAction.snooze10m, payload: payload); - } - } catch (_) { - debugPrint('failed to handle reminder notification response'); - 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 new file mode 100644 index 0000000..f8f16dd --- /dev/null +++ b/apps/lib/core/notifications/reminder_notification_callbacks.dart @@ -0,0 +1,156 @@ +import 'dart:async'; +import 'dart:convert'; + +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); + +class ReminderNotificationCallbacks { + static const String _pendingKey = + 'calendar_reminder_pending_notification_responses_v1'; + static ReminderNotificationResponseHandler? _responseHandler; + static Future _pendingStorageLock = Future.value(); + static final ReminderColdStartQueue _coldStartQueue = + ReminderColdStartQueue(); + + @visibleForTesting + static Future resetForTest() async { + _responseHandler = null; + _pendingStorageLock = Future.value(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_pendingKey); + } + + static Future bindResponseHandler( + ReminderNotificationResponseHandler handler, + ) async { + _responseHandler = handler; + await _drainPendingResponses(); + } + + static Future onForegroundResponse( + NotificationResponse response, + ) async { + final handler = _responseHandler; + if (handler == null) { + await _enqueuePendingResponse(response); + return; + } + try { + await handler(response); + } catch (_) { + await _enqueuePendingResponse(response); + } + } + + static Future onBackgroundResponse( + NotificationResponse response, + ) async { + final handler = _responseHandler; + if (handler == null) { + await _enqueuePendingResponse(response); + return; + } + try { + await handler(response); + } catch (_) { + await _enqueuePendingResponse(response); + } + } + + static Future _withPendingStorageLock(Future Function() operation) { + final completer = Completer(); + final waitForTurn = _pendingStorageLock; + _pendingStorageLock = waitForTurn.then((_) => completer.future); + + return waitForTurn.then((_) => operation()).whenComplete(() { + if (!completer.isCompleted) { + completer.complete(); + } + }); + } + + static Future _enqueuePendingResponse( + NotificationResponse response, + ) async { + await _withPendingStorageLock(() async { + final prefs = await SharedPreferences.getInstance(); + final current = prefs.getStringList(_pendingKey) ?? const []; + final encoded = jsonEncode({ + 'id': response.id, + 'actionId': response.actionId, + 'payload': response.payload, + 'type': response.notificationResponseType.index, + 'input': response.input, + }); + await prefs.setStringList(_pendingKey, [...current, encoded]); + }); + } + + static Future _drainPendingResponses() async { + final handler = _responseHandler; + if (handler == null) { + return; + } + await _withPendingStorageLock(() async { + final prefs = await SharedPreferences.getInstance(); + final pending = prefs.getStringList(_pendingKey) ?? const []; + if (pending.isEmpty) { + return; + } + + final remaining = []; + for (final raw in pending) { + _coldStartQueue.enqueue(() async { + Map parsed; + try { + parsed = Map.from(jsonDecode(raw) as Map); + } catch (_) { + return; + } + + 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); + } + }); + } + + await _coldStartQueue.replay(); + if (remaining.isEmpty) { + await prefs.remove(_pendingKey); + return; + } + + await prefs.setStringList(_pendingKey, remaining); + }); + } +} + +@pragma('vm:entry-point') +Future reminderNotificationTapBackground( + NotificationResponse response, +) async { + await ReminderNotificationCallbacks.onBackgroundResponse(response); +} diff --git a/apps/lib/core/router/app_router.dart b/apps/lib/core/router/app_router.dart index 88fed0c..7ad6485 100644 --- a/apps/lib/core/router/app_router.dart +++ b/apps/lib/core/router/app_router.dart @@ -24,7 +24,6 @@ import '../../features/todo/ui/screens/todo_edit_screen.dart'; import '../../features/settings/ui/screens/settings_screen.dart'; import '../../features/settings/ui/screens/features_screen.dart'; import '../../features/settings/ui/screens/memory_screen.dart'; -import '../../features/settings/ui/screens/account_screen.dart'; import '../../features/settings/ui/screens/edit_profile_screen.dart'; final _protectedRoutes = [ @@ -38,7 +37,6 @@ final _protectedRoutes = [ AppRoutes.settingsMain, AppRoutes.settingsFeatures, AppRoutes.settingsMemory, - AppRoutes.settingsAccount, AppRoutes.settingsEditProfile, AppRoutes.messageInviteList, ]; @@ -174,10 +172,6 @@ GoRouter createAppRouter(AuthBloc authBloc) { path: AppRoutes.settingsMemory, builder: (context, state) => const MemoryScreen(), ), - GoRoute( - path: AppRoutes.settingsAccount, - builder: (context, state) => const AccountScreen(), - ), GoRoute( path: AppRoutes.settingsEditProfile, builder: (context, state) => const EditProfileScreen(), diff --git a/apps/lib/core/router/app_routes.dart b/apps/lib/core/router/app_routes.dart index 9585b7e..70f819f 100644 --- a/apps/lib/core/router/app_routes.dart +++ b/apps/lib/core/router/app_routes.dart @@ -27,6 +27,5 @@ class AppRoutes { static const settingsMain = '/settings'; static const settingsFeatures = '/settings/features'; static const settingsMemory = '/settings/memory'; - static const settingsAccount = '/settings/account'; static const settingsEditProfile = '/edit-profile'; } diff --git a/apps/lib/features/auth/ui/screens/login_screen.dart b/apps/lib/features/auth/ui/screens/login_screen.dart index 8a7bf76..a9e673d 100644 --- a/apps/lib/features/auth/ui/screens/login_screen.dart +++ b/apps/lib/features/auth/ui/screens/login_screen.dart @@ -175,6 +175,9 @@ class _LoginViewState extends State { mainContent: LayoutBuilder( builder: (context, constraints) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final minContentHeight = constraints.hasBoundedHeight + ? constraints.maxHeight + : AppSpacing.none; return SingleChildScrollView( padding: EdgeInsets.fromLTRB( AppSpacing.lg, @@ -183,7 +186,7 @@ class _LoginViewState extends State { bottomInset + AppSpacing.lg, ), child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), + constraints: BoxConstraints(minHeight: minContentHeight), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 320), diff --git a/apps/lib/features/calendar/reminders/models/reminder_action.dart b/apps/lib/features/calendar/reminders/models/reminder_action.dart index 2fadcce..6e9da43 100644 --- a/apps/lib/features/calendar/reminders/models/reminder_action.dart +++ b/apps/lib/features/calendar/reminders/models/reminder_action.dart @@ -1,17 +1,23 @@ enum ReminderAction { - cancel('cancel'), - snooze10m('snooze_10m'), - timeout30s('timeout_30s'), - autoArchive('auto_archive'); + archive('archive'), + snooze10m('snooze10m'); const ReminderAction(this.value); final String value; static ReminderAction fromValue(String raw) { - return ReminderAction.values.firstWhere( - (item) => item.value == raw, - orElse: () => ReminderAction.timeout30s, - ); + switch (raw) { + case 'archive': + case 'cancel': + case 'auto_archive': + return ReminderAction.archive; + case 'snooze10m': + case 'snooze_10m': + case 'timeout_30s': + return ReminderAction.snooze10m; + default: + throw ArgumentError.value(raw, 'raw', 'Unsupported reminder action'); + } } } diff --git a/apps/lib/features/calendar/reminders/models/reminder_payload.dart b/apps/lib/features/calendar/reminders/models/reminder_payload.dart index c427ea9..af09c6e 100644 --- a/apps/lib/features/calendar/reminders/models/reminder_payload.dart +++ b/apps/lib/features/calendar/reminders/models/reminder_payload.dart @@ -9,6 +9,7 @@ class ReminderPayload { final String? color; final ReminderPayloadMode mode; final List aggregateIds; + final int? fireTimeBucket; final int version; const ReminderPayload({ @@ -22,6 +23,7 @@ class ReminderPayload { this.color, this.mode = ReminderPayloadMode.single, this.aggregateIds = const [], + this.fireTimeBucket, this.version = 1, }); @@ -36,6 +38,7 @@ class ReminderPayload { String? color, ReminderPayloadMode? mode, List? aggregateIds, + int? fireTimeBucket, int? version, }) { return ReminderPayload( @@ -49,6 +52,7 @@ class ReminderPayload { color: color ?? this.color, mode: mode ?? this.mode, aggregateIds: aggregateIds ?? this.aggregateIds, + fireTimeBucket: fireTimeBucket ?? this.fireTimeBucket, version: version ?? this.version, ); } @@ -65,6 +69,7 @@ class ReminderPayload { 'color': color, 'mode': mode.value, 'aggregateIds': aggregateIds, + 'fireTimeBucket': fireTimeBucket, 'version': version, }; } @@ -104,6 +109,7 @@ class ReminderPayload { color: json['color'] as String?, mode: mode, aggregateIds: aggregateIds, + fireTimeBucket: json['fireTimeBucket'] as int?, version: (json['version'] as int?) ?? 1, ); } @@ -124,6 +130,7 @@ class ReminderPayload { other.color == color && other.mode == mode && _listEquals(other.aggregateIds, aggregateIds) && + other.fireTimeBucket == fireTimeBucket && other.version == version; } @@ -140,6 +147,7 @@ class ReminderPayload { color, mode, Object.hashAll(aggregateIds), + fireTimeBucket, version, ); } diff --git a/apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart b/apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart new file mode 100644 index 0000000..f7c23df --- /dev/null +++ b/apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart @@ -0,0 +1,52 @@ +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 ee90689..c956cae 100644 --- a/apps/lib/features/calendar/reminders/reminder_action_executor.dart +++ b/apps/lib/features/calendar/reminders/reminder_action_executor.dart @@ -27,19 +27,20 @@ class ReminderActionExecutor { required ReminderPayload payload, }) async { final ids = payload.mode == ReminderPayloadMode.aggregate - ? payload.aggregateIds + ? (payload.aggregateIds.isNotEmpty + ? payload.aggregateIds + : [payload.eventId]) : [payload.eventId]; - if (action == ReminderAction.cancel) { + if (action == ReminderAction.archive) { for (final id in ids) { await _notificationService.cancelEventReminder(id); - await _archiveEvent(id, ReminderAction.cancel); + await _archiveEvent(id, ReminderAction.archive); } return; } - if (action == ReminderAction.snooze10m || - action == ReminderAction.timeout30s) { + if (action == ReminderAction.snooze10m) { for (final id in ids) { await _snoozeEvent(id); } @@ -50,7 +51,6 @@ class ReminderActionExecutor { final pending = await _outboxStore.listPending(); for (final item in pending) { if (item.targetStatus != 'archived') { - await _outboxStore.markDone(item.opId); continue; } try { @@ -71,14 +71,14 @@ class ReminderActionExecutor { final endAt = event.endAt; if (endAt != null && !now.isBefore(endAt)) { await _notificationService.cancelEventReminder(eventId); - await _archiveEvent(eventId, ReminderAction.autoArchive); + await _archiveEvent(eventId, ReminderAction.archive); return; } final nextAt = now.add(const Duration(minutes: 10)); if (endAt != null && !nextAt.isBefore(endAt)) { await _notificationService.cancelEventReminder(eventId); - await _archiveEvent(eventId, ReminderAction.autoArchive); + await _archiveEvent(eventId, ReminderAction.archive); return; } diff --git a/apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart b/apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart new file mode 100644 index 0000000..823531a --- /dev/null +++ b/apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart @@ -0,0 +1,57 @@ +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/ui/reminder_foreground_presenter.dart b/apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart new file mode 100644 index 0000000..7253566 --- /dev/null +++ b/apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart @@ -0,0 +1,73 @@ +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 new file mode 100644 index 0000000..fc5e85b --- /dev/null +++ b/apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..911530e --- /dev/null +++ b/apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart @@ -0,0 +1,58 @@ +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/features/calendar/ui/screens/calendar_event_detail_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart index 7165744..8c1e6eb 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart @@ -9,11 +9,13 @@ import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/back_title_page_header.dart'; import '../../../../shared/widgets/detail_header_action_menu.dart'; import '../../../../shared/widgets/destructive_action_sheet.dart'; +import '../../../../shared/widgets/toast/toast.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; import '../../data/services/calendar_service.dart'; import '../../data/models/schedule_item_model.dart'; import '../utils/event_color_resolver.dart'; -enum _CalendarHeaderAction { edit, delete, share } +enum _CalendarHeaderAction { edit, delete, share, archive } class CalendarEventDetailScreen extends StatefulWidget { final String eventId; @@ -190,6 +192,17 @@ class _CalendarEventDetailScreenState extends State { ), ); } + if (event.status != ScheduleStatus.archived && event.canEdit) { + final isExpired = _isEventExpired(event); + items.add( + DetailHeaderActionItem<_CalendarHeaderAction>( + value: _CalendarHeaderAction.archive, + label: '归档', + icon: LucideIcons.archive, + enabled: !isExpired, + ), + ); + } return DetailHeaderActionMenu<_CalendarHeaderAction>( items: items, @@ -197,6 +210,14 @@ class _CalendarEventDetailScreenState extends State { ); } + bool _isEventExpired(ScheduleItemModel event) { + final now = DateTime.now(); + if (event.endAt != null) { + return event.endAt!.isBefore(now); + } + return event.startAt.isBefore(now); + } + void _handleHeaderAction( _CalendarHeaderAction action, ScheduleItemModel event, @@ -213,6 +234,9 @@ class _CalendarEventDetailScreenState extends State { case _CalendarHeaderAction.share: context.push(AppRoutes.calendarEventShare(event.id)); return; + case _CalendarHeaderAction.archive: + _archiveEvent(); + return; } } @@ -460,6 +484,29 @@ class _CalendarEventDetailScreenState extends State { context.pop(); } + Future _archiveEvent() async { + final confirmed = await showDestructiveActionSheet( + context, + title: '归档日程', + message: '归档后此日程将标记为过期,确定要归档吗?', + confirmText: '确认归档', + ); + if (!confirmed) { + return; + } + try { + await sl().archiveEvent(widget.eventId); + await _loadEvent(); + if (mounted) { + Toast.show(context, '已归档', type: ToastType.success); + } + } catch (e) { + if (mounted) { + Toast.show(context, '归档失败', type: ToastType.error); + } + } + } + String _formatRangeLabel(DateTime startAt, DateTime? endAt) { final dateLabel = '${startAt.month}月${startAt.day}日 ${_getWeekday(startAt.weekday)}'; diff --git a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart index 1944604..07ed0b8 100644 --- a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart @@ -4,6 +4,7 @@ import '../../../../core/di/injection.dart'; import '../../../../core/notifications/local_notification_service.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; +import '../../../../shared/widgets/app_selection_sheet.dart'; import '../../../../shared/widgets/app_sheet_input_field.dart'; import '../../../../shared/widgets/back_title_page_header.dart'; import '../../../../shared/widgets/toast/toast.dart'; @@ -107,12 +108,21 @@ class _CreateEventSheetState extends State event.metadata?.reminderMinutes, ); } else { - final now = - widget.initialDate ?? _roundToNearestMinute(DateTime.now(), 5); - _startDate = now; - _startTime = now; - _endDate = now; - _endTime = now.add(const Duration(hours: 1)); + final now = DateTime.now(); + final initial = widget.initialDate; + final rounded = _roundToNearestMinute(now, 5); + _startDate = initial != null + ? DateTime( + initial.year, + initial.month, + initial.day, + rounded.hour, + rounded.minute, + ) + : rounded; + _startTime = _startDate; + _endDate = _startDate; + _endTime = _startDate.add(const Duration(hours: 1)); } } @@ -139,15 +149,19 @@ class _CreateEventSheetState extends State @override Widget build(BuildContext context) { if (widget.pageMode) { - return Container( - color: AppColors.background, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildPageHeader(), - _buildTabBar(), - Expanded(child: _buildTabContent()), - ], + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + color: AppColors.background, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPageHeader(), + _buildTabBar(), + Expanded(child: _buildTabContent()), + ], + ), ), ); } @@ -331,12 +345,7 @@ class _CreateEventSheetState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildTextField( - '标题', - _titleController, - '请输入日程标题', - autofocus: !_isEditing, - ), + _buildTextField('标题', _titleController, '请输入日程标题'), const SizedBox(height: 20), _buildDateTimePicker('开始', _startDate, _startTime, (date, time) { setState(() { @@ -580,7 +589,6 @@ class _CreateEventSheetState extends State } Widget _buildReminderPicker() { - final options = _buildReminderOptions(); String labelOf(int? value) { if (value == null) { return '无提醒'; @@ -603,37 +611,51 @@ class _CreateEventSheetState extends State ), ), const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: AppColors.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _reminderMinutes, - isExpanded: true, + InkWell( + onTap: () async { + final options = _buildReminderOptions(); + final selected = await showAppSelectionSheet( + context, + title: '选择提醒时间', items: options - .map( - (value) => DropdownMenuItem( - value: value, - child: Text( - labelOf(value), - style: const TextStyle( - fontSize: 14, - color: AppColors.slate700, - ), - ), - ), - ) + .map((v) => AppSelectionItem(value: v, label: labelOf(v))) .toList(), - onChanged: (value) { - setState(() { - _reminderMinutes = value; - }); - }, + selectedValue: _reminderMinutes, + ); + if (selected != null) { + setState(() { + _reminderMinutes = selected; + }); + } + }, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.slate50, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + labelOf(_reminderMinutes), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: AppColors.slate900, + ), + ), + ), + const Icon( + LucideIcons.chevronRight, + size: 16, + color: AppColors.slate400, + ), + ], ), ), ), diff --git a/apps/lib/features/calendar/ui/widgets/date_time_picker_sheet.dart b/apps/lib/features/calendar/ui/widgets/date_time_picker_sheet.dart index 598727c..b861e07 100644 --- a/apps/lib/features/calendar/ui/widgets/date_time_picker_sheet.dart +++ b/apps/lib/features/calendar/ui/widgets/date_time_picker_sheet.dart @@ -54,7 +54,7 @@ class _DateTimePickerSheetState extends State { if (_selectedYear == minDate.year && _selectedMonth == minDate.month && _selectedDay == minDate.day) { - return _allHours.where((h) => h > minDate.hour).toList(); + return _allHours.where((h) => h >= minDate.hour).toList(); } return _allHours; } @@ -73,7 +73,7 @@ class _DateTimePickerSheetState extends State { _selectedMonth == minDate.month && _selectedDay == minDate.day && _selectedHour == minDate.hour) { - return _allMinutes.where((m) => m > minDate.minute).toList(); + return _allMinutes.where((m) => m >= minDate.minute).toList(); } return _allMinutes; } @@ -100,6 +100,12 @@ class _DateTimePickerSheetState extends State { _hourController = FixedExtentScrollController( initialItem: _filteredHours.indexOf(_selectedHour), ); + + if (_filteredMinutes.isEmpty) { + _selectedMinute = 0; + } else if (!_filteredMinutes.contains(_selectedMinute)) { + _selectedMinute = _filteredMinutes.first; + } _minuteController = FixedExtentScrollController( initialItem: _filteredMinutes.indexOf(_selectedMinute), ); diff --git a/apps/lib/features/home/ui/controllers/home_keyboard_inset_calculator.dart b/apps/lib/features/home/ui/controllers/home_keyboard_inset_calculator.dart new file mode 100644 index 0000000..a0a7ba3 --- /dev/null +++ b/apps/lib/features/home/ui/controllers/home_keyboard_inset_calculator.dart @@ -0,0 +1,19 @@ +import '../../../../core/theme/design_tokens.dart'; + +class HomeKeyboardInsetCalculator { + static double compute({ + required double rawViewInsetBottom, + required double bottomViewPadding, + }) { + if (rawViewInsetBottom <= AppSpacing.xs) { + return 0; + } + + final adjustedInset = rawViewInsetBottom - bottomViewPadding; + if (adjustedInset <= AppSpacing.xs) { + return 0; + } + + return adjustedInset; + } +} diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index d303c1c..fce7278 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -14,6 +14,7 @@ import '../../../chat/presentation/bloc/agent_stage.dart'; import '../../../chat/presentation/bloc/chat_bloc.dart'; import '../../../messages/data/inbox_api.dart'; import '../../data/voice_recorder.dart'; +import '../controllers/home_keyboard_inset_calculator.dart'; import '../controllers/home_message_viewport_controller.dart'; import '../controllers/home_viewport_coordinator.dart'; import '../../../../shared/widgets/app_pull_refresh_feedback.dart'; @@ -100,7 +101,6 @@ class _HomeScreenState extends State double? _historyViewportMaxExtent; final GlobalKey _inputHostKey = GlobalKey(); - double _stableKeyboardInset = 0; @override void initState() { @@ -541,16 +541,10 @@ class _HomeScreenState extends State double _effectiveKeyboardInset(BuildContext context) { final mediaQuery = MediaQuery.of(context); - final rawInset = mediaQuery.viewInsets.bottom; - if (rawInset <= AppSpacing.xs) { - _stableKeyboardInset = 0; - return 0; - } - // Only update stable if new value is larger (never decrease on jitter down) - if (rawInset > _stableKeyboardInset) { - _stableKeyboardInset = rawInset; - } - return _stableKeyboardInset; + return HomeKeyboardInsetCalculator.compute( + rawViewInsetBottom: mediaQuery.viewInsets.bottom, + bottomViewPadding: mediaQuery.viewPadding.bottom, + ); } void _dismissKeyboard() { diff --git a/apps/lib/features/settings/data/services/settings_user_cache.dart b/apps/lib/features/settings/data/services/settings_user_cache.dart new file mode 100644 index 0000000..4cc1e90 --- /dev/null +++ b/apps/lib/features/settings/data/services/settings_user_cache.dart @@ -0,0 +1,49 @@ +import '../../../users/data/models/user_response.dart'; + +class SettingsUserCache { + UserResponse? _cachedUser; + Future? _inflight; + int _generation = 0; + + UserResponse? get cachedUser => _cachedUser; + + Future getOrLoad(Future Function() loader) { + final cached = _cachedUser; + if (cached != null) { + return Future.value(cached); + } + + final inflight = _inflight; + if (inflight != null) { + return inflight; + } + + final generation = _generation; + late final Future request; + request = loader() + .then((user) { + if (generation == _generation) { + _cachedUser = user; + } + return user; + }) + .whenComplete(() { + if (identical(_inflight, request)) { + _inflight = null; + } + }); + + _inflight = request; + return request; + } + + void set(UserResponse user) { + _cachedUser = user; + } + + void invalidate() { + _generation += 1; + _cachedUser = null; + _inflight = null; + } +} diff --git a/apps/lib/features/settings/ui/screens/account_screen.dart b/apps/lib/features/settings/ui/screens/account_screen.dart deleted file mode 100644 index c592571..0000000 --- a/apps/lib/features/settings/ui/screens/account_screen.dart +++ /dev/null @@ -1,223 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/app_pressable.dart'; -import '../../../../shared/widgets/toast/toast.dart'; -import '../../../../shared/widgets/toast/toast_type.dart'; -import '../../../auth/presentation/bloc/auth_bloc.dart'; -import '../../../auth/presentation/bloc/auth_event.dart'; -import '../../../auth/presentation/bloc/auth_state.dart'; -import '../../../../shared/widgets/app_button.dart'; -import '../widgets/account_section_card.dart'; -import '../widgets/settings_page_scaffold.dart'; - -class AccountScreen extends StatelessWidget { - const AccountScreen({super.key}); - - static const double _menuItemHeight = AppSpacing.xl * 2 + AppSpacing.md; - static const double _menuItemHorizontalPadding = AppSpacing.md; - static const double _menuIconSize = 20; - static const double _menuChevronSize = 18; - - @override - Widget build(BuildContext context) { - return SettingsPageScaffold( - title: '账户', - onBack: () => context.pop(), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [_buildListSurface(context)], - ), - ); - } - - Widget _buildListSurface(BuildContext context) { - return AccountSectionCard( - backgroundColor: AppColors.white, - borderColor: AppColors.borderSecondary, - contentPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildMenuItem( - icon: Icons.edit, - title: '编辑资料', - onTap: () => context.push('/edit-profile'), - ), - _buildDivider(), - _buildMenuItem( - icon: Icons.logout, - title: '退出登录', - titleColor: AppColors.feedbackErrorText, - iconColor: AppColors.feedbackErrorIcon, - trailingColor: AppColors.feedbackErrorIcon, - onTap: () => _showLogoutSheet(context), - ), - ], - ), - ); - } - - Widget _buildMenuItem({ - required IconData icon, - required String title, - required VoidCallback onTap, - Color titleColor = AppColors.slate900, - Color iconColor = AppColors.slate500, - Color trailingColor = AppColors.slate400, - }) { - return AppPressable( - onTap: onTap, - borderRadius: BorderRadius.circular(AppRadius.md), - child: Container( - constraints: const BoxConstraints(minHeight: _menuItemHeight), - padding: const EdgeInsets.symmetric( - horizontal: _menuItemHorizontalPadding, - vertical: AppSpacing.sm, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: _menuIconSize, - child: Icon(icon, size: _menuIconSize, color: iconColor), - ), - const SizedBox(width: AppSpacing.md), - Text( - title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: titleColor, - ), - ), - ], - ), - Icon( - Icons.chevron_right, - size: _menuChevronSize, - color: trailingColor, - ), - ], - ), - ), - ); - } - - Widget _buildDivider() { - return Container( - height: 1, - margin: const EdgeInsets.only( - left: _menuItemHorizontalPadding + _menuIconSize + AppSpacing.md, - right: _menuItemHorizontalPadding, - ), - color: AppColors.borderTertiary, - ); - } - - void _showLogoutSheet(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (sheetContext) => SafeArea( - top: false, - child: Container( - margin: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.none, - AppSpacing.md, - AppSpacing.md, - ), - 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: [ - const Text( - '退出登录', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppColors.slate900, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: AppSpacing.xs), - const Text( - '确定退出当前账户吗?', - style: TextStyle(fontSize: 14, color: AppColors.slate500), - textAlign: TextAlign.center, - ), - const SizedBox(height: AppSpacing.lg), - SizedBox( - height: 52, - child: GestureDetector( - onTap: () async { - Navigator.of(sheetContext).pop(); - final authBloc = context.read(); - authBloc.add(AuthLoggedOut()); - try { - await authBloc.stream - .firstWhere((state) => state is AuthUnauthenticated) - .timeout(const Duration(seconds: 5)); - } catch (_) { - if (context.mounted) { - Toast.show( - context, - '退出失败,请稍后重试', - type: ToastType.error, - ); - } - return; - } - if (context.mounted) { - context.go('/'); - } - }, - child: Container( - decoration: BoxDecoration( - color: AppColors.feedbackErrorIcon, - borderRadius: BorderRadius.circular(AppRadius.full), - ), - alignment: Alignment.center, - child: const Text( - '确认退出', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: AppColors.white, - ), - ), - ), - ), - ), - const SizedBox(height: AppSpacing.sm), - SizedBox( - height: 52, - child: AppButton( - text: '取消', - isOutlined: true, - onPressed: () => Navigator.of(sheetContext).pop(), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart index 8f516fc..4b51bdf 100644 --- a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart +++ b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart @@ -6,6 +6,7 @@ import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../data/services/settings_user_cache.dart'; import '../../../users/data/models/user_response.dart'; import '../../../users/data/users_api.dart'; import '../widgets/account_section_card.dart'; @@ -22,6 +23,7 @@ class _EditProfileScreenState extends State { final _usernameController = TextEditingController(); final _bioController = TextEditingController(); final _usersApi = sl(); + final _userCache = sl(); UserResponse? _user; bool _isLoading = true; @@ -35,9 +37,21 @@ class _EditProfileScreenState extends State { } Future _loadUser() async { + final cached = _userCache.cachedUser; + if (cached != null) { + setState(() { + _user = cached; + _usernameController.text = cached.username; + _bioController.text = cached.bio ?? ''; + _isLoading = false; + }); + return; + } + try { final user = await _usersApi.getMe(); if (mounted) { + _userCache.set(user); setState(() { _user = user; _usernameController.text = user.username; @@ -91,7 +105,8 @@ class _EditProfileScreenState extends State { username: newUsername, bio: newBio.isEmpty ? null : newBio, ); - await _usersApi.updateMe(request); + final updatedUser = await _usersApi.updateMe(request); + _userCache.set(updatedUser); if (mounted) { Toast.show(context, '保存成功', type: ToastType.success); diff --git a/apps/lib/features/settings/ui/screens/settings_screen.dart b/apps/lib/features/settings/ui/screens/settings_screen.dart index 6b5f631..f645069 100644 --- a/apps/lib/features/settings/ui/screens/settings_screen.dart +++ b/apps/lib/features/settings/ui/screens/settings_screen.dart @@ -1,18 +1,30 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:social_app/core/constants/app_constants.dart'; import 'package:social_app/core/di/injection.dart'; +import 'package:social_app/core/router/app_routes.dart'; import 'package:social_app/core/theme/design_tokens.dart'; +import 'package:social_app/shared/widgets/app_button.dart'; import 'package:social_app/shared/widgets/app_loading_indicator.dart'; +import 'package:social_app/shared/widgets/app_pressable.dart'; +import 'package:social_app/shared/widgets/destructive_action_sheet.dart'; import 'package:social_app/shared/widgets/toast/toast.dart'; import 'package:social_app/shared/widgets/toast/toast_type.dart'; import 'package:social_app/shared/utils/phone_display_formatter.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_event.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; import 'package:social_app/features/friends/data/friends_api.dart'; import 'package:social_app/features/settings/data/settings_api.dart'; +import 'package:social_app/features/settings/data/services/settings_user_cache.dart'; import 'package:social_app/features/users/data/models/user_response.dart'; import 'package:social_app/features/users/data/users_api.dart'; import '../widgets/settings_page_scaffold.dart'; +const settingsProfileEditButtonKey = ValueKey('settings_profile_edit_button'); +const settingsLogoutButtonKey = ValueKey('settings_logout_button'); + class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -21,6 +33,10 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { + final UsersApi _usersApi = sl(); + final FriendsApi _friendsApi = sl(); + final SettingsUserCache _userCache = sl(); + UserResponse? _user; bool _isLoading = true; int _friendsCount = 0; @@ -29,39 +45,45 @@ class _SettingsScreenState extends State { @override void initState() { super.initState(); + final cachedUser = _userCache.cachedUser; + if (cachedUser != null) { + _user = cachedUser; + _isLoading = false; + } _loadData(); } Future _loadData() async { try { - final usersApi = sl(); - final friendsApi = sl(); - - final results = await Future.wait([ - usersApi.getMe(), - friendsApi.getFriends(), - ]); - - final user = results[0] as UserResponse; - final friends = results[1] as List; - + final user = await _userCache.getOrLoad(_usersApi.getMe); if (mounted) { setState(() { _user = user; - _friendsCount = friends.length; - _firstFriendName = friends.isNotEmpty - ? friends.first.friend.username - : null; _isLoading = false; }); } } catch (e) { - if (mounted) { + if (mounted && _user == null) { setState(() { _isLoading = false; }); } } + + try { + final friends = await _friendsApi.getFriends(); + + if (mounted) { + setState(() { + _friendsCount = friends.length; + _firstFriendName = friends.isNotEmpty + ? friends.first.friend.username + : null; + }); + } + } catch (e) { + // Keep profile available even when contacts fail. + } } @override @@ -73,17 +95,33 @@ class _SettingsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildProfileHero(), - const SizedBox(height: 16), + const SizedBox(height: AppSpacing.lg), _buildQuickActions(context), - const SizedBox(height: 16), + const SizedBox(height: AppSpacing.lg), _buildSubscriptionCard(), - const SizedBox(height: 16), + const SizedBox(height: AppSpacing.lg), _buildMenuCard(context), + const SizedBox(height: AppSpacing.xl), + _buildLogoutAction(), ], ), ); } + Widget _buildSectionLabel(String label) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: Text( + label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.slate500, + ), + ), + ); + } + Widget _buildProfileHero() { if (_isLoading) { return Container( @@ -92,7 +130,7 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(AppSpacing.xl), decoration: BoxDecoration( color: AppColors.white, - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(AppRadius.xxl), ), child: const Center(child: AppLoadingIndicator(size: 22)), ); @@ -110,109 +148,147 @@ class _SettingsScreenState extends State { gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [AppColors.white, Color(0xF8F9FCFF)], + colors: [AppColors.white, AppColors.surfaceInfoLight], ), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: AppColors.borderSecondary), - boxShadow: const [ + borderRadius: BorderRadius.circular(AppRadius.xxl), + border: Border.all(color: AppColors.borderTertiary), + boxShadow: [ BoxShadow( - color: Color(0x05000000), - blurRadius: 12, - offset: Offset(0, 3), + color: AppColors.blue100.withValues(alpha: 0.35), + blurRadius: 14, + offset: const Offset(0, 4), ), ], ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.blue100, AppColors.blue50], - ), - borderRadius: BorderRadius.circular(32), - boxShadow: [ - BoxShadow( - color: Color.fromRGBO( - AppColors.blue400.r.toInt(), - AppColors.blue400.g.toInt(), - AppColors.blue400.b.toInt(), - 0.2, + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue100, AppColors.blue50], ), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: const Icon(Icons.person, size: 28, color: AppColors.blue600), - ), - const SizedBox(width: AppSpacing.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - username, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.slate900, - ), - overflow: TextOverflow.ellipsis, + borderRadius: BorderRadius.circular(32), + boxShadow: [ + BoxShadow( + color: Color.fromRGBO( + AppColors.blue400.r.toInt(), + AppColors.blue400.g.toInt(), + AppColors.blue400.b.toInt(), + 0.2, ), + blurRadius: 12, + offset: const Offset(0, 4), ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, + ], + ), + child: const Icon( + Icons.person, + size: 28, + color: AppColors.blue600, + ), + ), + const SizedBox(width: AppSpacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + username, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.slate900, ), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppColors.blue50, - AppColors.surfaceInfoLight, - ], - ), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderQuaternary), - ), - child: const Text( - 'Free', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppColors.blue600, - ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Text( + phone, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate500, ), ), ], ), - const SizedBox(height: 6), - Text( - phone, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppColors.slate500, + ), + const SizedBox(width: AppSpacing.md), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + AppPressable( + key: settingsProfileEditButtonKey, + onTap: _onTapEditProfile, + borderRadius: BorderRadius.circular(AppRadius.lg), + child: SizedBox( + width: AppSpacing.xl * 2, + height: AppSpacing.xl * 2, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.edit, + size: 14, + color: AppColors.slate500, + ), + const SizedBox(height: 3), + Container( + width: 12, + height: 1.5, + decoration: BoxDecoration( + color: AppColors.slate400, + borderRadius: BorderRadius.circular( + AppRadius.full, + ), + ), + ), + ], + ), + ), ), - ), - ], - ), + const SizedBox(height: AppSpacing.sm), + _buildFreeBadge(), + ], + ), + ], ), ], ), ); } + Widget _buildFreeBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.blue50, AppColors.surfaceInfoLight], + ), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderQuaternary), + ), + child: const Text( + 'Free', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.blue600, + ), + ), + ); + } + String _buildFriendsSubtitle() { if (_friendsCount == 0) { return '暂无联系人'; @@ -232,17 +308,17 @@ class _SettingsScreenState extends State { iconColor: AppColors.blue500, title: '联系人', subtitle: _buildFriendsSubtitle(), - onTap: () => context.push('/contacts'), + onTap: () => context.push(AppRoutes.contactsList), ), ), const SizedBox(width: AppSpacing.md), Expanded( child: _buildActionCard( icon: Icons.auto_awesome, - iconColor: const Color(0xFF8B5CF6), + iconColor: AppColors.violet500, title: '周期计划', subtitle: '已启用:会议提醒', - onTap: () => context.push('/settings/features'), + onTap: () => context.push(AppRoutes.settingsFeatures), ), ), ], @@ -256,19 +332,21 @@ class _SettingsScreenState extends State { required String subtitle, required VoidCallback onTap, }) { - return GestureDetector( + return AppPressable( onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.xl), child: Container( + constraints: const BoxConstraints(minHeight: 136), padding: const EdgeInsets.all(AppSpacing.lg), decoration: BoxDecoration( color: AppColors.white, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(AppRadius.xl), border: Border.all(color: AppColors.borderSecondary), - boxShadow: const [ + boxShadow: [ BoxShadow( - color: Color(0x04000000), - blurRadius: 6, - offset: Offset(0, 1), + color: AppColors.slate200.withValues(alpha: 0.45), + blurRadius: 8, + offset: const Offset(0, 2), ), ], ), @@ -323,15 +401,15 @@ class _SettingsScreenState extends State { gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [AppColors.white, const Color(0xFFFAFBFF)], + colors: [AppColors.white, AppColors.surfaceInfoLight], ), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.borderSecondary), - boxShadow: const [ + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.borderTertiary), + boxShadow: [ BoxShadow( - color: Color(0x03000000), - blurRadius: 6, - offset: Offset(0, 1), + color: AppColors.slate200.withValues(alpha: 0.4), + blurRadius: 8, + offset: const Offset(0, 2), ), ], ), @@ -419,7 +497,7 @@ class _SettingsScreenState extends State { return Container( decoration: BoxDecoration( color: AppColors.white, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(AppRadius.xl), border: Border.all(color: AppColors.borderSecondary), ), child: Column( @@ -433,13 +511,7 @@ class _SettingsScreenState extends State { _buildMenuItem( icon: Icons.bookmark, title: '我的记忆', - onTap: () => context.push('/settings/memory'), - ), - _buildDivider(), - _buildMenuItem( - icon: Icons.person, - title: '我的账户', - onTap: () => context.push('/settings/account'), + onTap: () => context.push(AppRoutes.settingsMemory), ), _buildDivider(), _buildMenuItem( @@ -459,9 +531,9 @@ class _SettingsScreenState extends State { String? trailing, required VoidCallback onTap, }) { - return GestureDetector( + return AppPressable( onTap: onTap, - behavior: HitTestBehavior.opaque, + borderRadius: BorderRadius.circular(AppRadius.md), child: Container( height: 56, padding: const EdgeInsets.symmetric(horizontal: 14), @@ -515,6 +587,45 @@ class _SettingsScreenState extends State { ); } + Future _onTapEditProfile() async { + final changed = await context.push(AppRoutes.settingsEditProfile); + if (changed == true && mounted) { + final cached = _userCache.cachedUser; + if (cached != null) { + setState(() { + _user = cached; + }); + } + } + } + + Future _onTapLogout() async { + final confirmed = await showDestructiveActionSheet( + context, + title: '退出登录', + message: '确定退出当前账户吗?', + confirmText: '确认退出', + ); + if (!confirmed || !mounted) { + return; + } + + _userCache.invalidate(); + final authBloc = context.read(); + authBloc.add(AuthLoggedOut()); + try { + await authBloc.stream + .firstWhere((state) => state is AuthUnauthenticated) + .timeout(const Duration(seconds: 5)); + } catch (_) { + if (!mounted) return; + Toast.show(context, '退出失败,请稍后重试', type: ToastType.error); + return; + } + if (!mounted) return; + context.go(AppRoutes.authLogin); + } + Future _checkForUpdates() async { try { final settingsApi = sl(); @@ -566,4 +677,17 @@ class _SettingsScreenState extends State { Toast.show(context, '检查更新失败', type: ToastType.error); } } + + Widget _buildLogoutAction() { + return SizedBox( + width: double.infinity, + height: 52, + child: AppButton( + key: settingsLogoutButtonKey, + text: '退出登录', + isOutlined: true, + onPressed: () => _onTapLogout(), + ), + ); + } } diff --git a/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart b/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart index 5f2a300..b8d9284 100644 --- a/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart +++ b/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart @@ -51,15 +51,7 @@ class SettingsPageScaffold extends StatelessWidget { AppSpacing.xl, AppSpacing.xl, ), - child: Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.surfaceInfoLight, - borderRadius: BorderRadius.circular(AppRadius.xl), - border: Border.all(color: AppColors.borderTertiary), - ), - child: footer, - ), + child: footer, ), ], ), diff --git a/apps/lib/features/todo/data/todo_api.dart b/apps/lib/features/todo/data/todo_api.dart index c4543f8..49e9ef9 100644 --- a/apps/lib/features/todo/data/todo_api.dart +++ b/apps/lib/features/todo/data/todo_api.dart @@ -59,6 +59,14 @@ class TodoApi { return TodoResponse.fromJson(response.data); } + Future updateTodoPriority(String id, int priority) async { + try { + await _client.patch('$_prefix/$id', data: {'priority': priority}); + } catch (_) { + // Ignore response parsing errors, just need to know if request succeeded + } + } + Future completeTodo(String id) async { final response = await _client.post('$_prefix/$id/complete', data: {}); return TodoResponse.fromJson(response.data); diff --git a/apps/lib/features/todo/ui/screens/todo_edit_screen.dart b/apps/lib/features/todo/ui/screens/todo_edit_screen.dart index 438efa1..5da20c3 100644 --- a/apps/lib/features/todo/ui/screens/todo_edit_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_edit_screen.dart @@ -12,6 +12,7 @@ import '../../../../shared/widgets/full_screen_loading.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../calendar/data/calendar_api.dart'; +import '../../../calendar/data/models/schedule_item_model.dart'; import '../../data/todo_api.dart'; class TodoEditScreen extends StatefulWidget { @@ -88,6 +89,7 @@ class _TodoEditScreenState extends State { ..clear() ..addAll(todo?.scheduleItems.map((item) => item.id) ?? const []); _scheduleItems = scheduleItems + .where((item) => item.status == ScheduleStatus.active) .map( (item) => _ScheduleItemSimple( id: item.id, @@ -115,22 +117,29 @@ class _TodoEditScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.todoBg, + resizeToAvoidBottomInset: false, body: SafeArea( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [AppColors.homeBackgroundTop, AppColors.todoBg], + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.homeBackgroundTop, AppColors.todoBg], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BackTitlePageHeader( + title: widget.isCreateMode ? '新建待办' : '编辑待办', + ), + Expanded(child: _buildBody()), + _buildBottomAction(), + ], ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - BackTitlePageHeader(title: widget.isCreateMode ? '新建待办' : '编辑待办'), - Expanded(child: _buildBody()), - _buildBottomAction(), - ], ), ), ), @@ -406,11 +415,6 @@ class _TodoEditScreenState extends State { if (!mounted) { return; } - Toast.show( - context, - widget.isCreateMode ? '待办已创建' : '待办已更新', - type: ToastType.success, - ); context.pop(true); } catch (error) { if (!mounted) { diff --git a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart index 075af86..aaecf4a 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -15,6 +15,7 @@ import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../calendar/ui/calendar_state_manager.dart'; import '../../../calendar/ui/widgets/bottom_dock.dart'; import '../../data/todo_api.dart'; +import '../widgets/todo_drag_item.dart'; class TodoQuadrantsScreen extends StatefulWidget { const TodoQuadrantsScreen({super.key}); @@ -32,6 +33,78 @@ class _TodoQuadrantsScreenState extends State { bool _loadingTodosRequest = false; String? _error; + String? _draggingTodoId; + int? _dragTargetQuadrant; + int? _dragInsertIndex; + + bool get _isDragging => _draggingTodoId != null; + + void _onDragStart(String todoId) { + setState(() { + _draggingTodoId = todoId; + _dragTargetQuadrant = null; + _dragInsertIndex = null; + }); + } + + void _onDragEnd() { + setState(() { + _draggingTodoId = null; + _dragTargetQuadrant = null; + _dragInsertIndex = null; + }); + } + + void _onDragEnterQuadrant(int quadrant) { + setState(() { + _dragTargetQuadrant = quadrant; + }); + } + + void _onDragUpdateInsertIndex(int index) { + setState(() { + _dragInsertIndex = index; + }); + } + + Future _onDrop( + String todoId, + int targetQuadrant, + int insertIndex, + ) async { + final previousTodos = List.from(_todos); + try { + final todo = _todos.firstWhere((t) => t.id == todoId); + final sourceQuadrant = todo.priority; + + if (sourceQuadrant == targetQuadrant) { + _onDragEnd(); + return; + } + + setState(() { + final index = _todos.indexWhere((t) => t.id == todoId); + if (index != -1) { + _todos[index] = _todos[index].copyWith(priority: targetQuadrant); + } + }); + + await _todoApi.updateTodoPriority(todoId, targetQuadrant); + } catch (e) { + if (!mounted) return; + setState(() { + _todos = previousTodos; + }); + Toast.show(context, '移动失败', type: ToastType.error); + } finally { + if (mounted) _onDragEnd(); + } + } + + void _onDragLeave() { + // 清除高亮 + } + @override void initState() { super.initState(); @@ -140,7 +213,7 @@ class _TodoQuadrantsScreenState extends State { child: Column( children: [ _buildHeader(), - Expanded(child: _buildContent()), + Expanded(child: _buildContent(withScroll: true)), _buildBottomDock(), ], ), @@ -205,7 +278,7 @@ class _TodoQuadrantsScreenState extends State { ); } - Widget _buildContent() { + Widget _buildContent({bool withScroll = false}) { if (_isLoading) { return const FullScreenLoading(); } @@ -214,58 +287,71 @@ class _TodoQuadrantsScreenState extends State { return ErrorRetrySurface(message: '加载失败: $_error', onRetry: _loadTodos); } - return Stack( + Widget content = Column( + mainAxisSize: MainAxisSize.min, children: [ - RefreshIndicator.noSpinner( - onRefresh: _onPullRefresh, - child: Padding( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 4, - bottom: 96, - ), - child: ListView( - children: [ - _buildQuadrant( - title: '重要紧急', - textColor: AppColors.g1Text, - dividerColor: AppColors.g1Divider, - borderColor: AppColors.g1Border, - items: _importantUrgent, - onComplete: _completeTodo, - onTap: _navigateToDetail, - ), - const SizedBox(height: 12), - _buildQuadrant( - title: '紧急不重要', - textColor: AppColors.g2Text, - dividerColor: AppColors.g2Divider, - borderColor: AppColors.g2Border, - items: _urgentNotImportant, - onComplete: _completeTodo, - onTap: _navigateToDetail, - ), - const SizedBox(height: 12), - _buildQuadrant( - title: '重要不紧急', - textColor: AppColors.g3Text, - dividerColor: AppColors.g3Divider, - borderColor: AppColors.g3Border, - items: _importantNotUrgent, - onComplete: _completeTodo, - onTap: _navigateToDetail, - ), - ], - ), - ), + _buildQuadrant( + title: '重要紧急', + textColor: AppColors.g1Text, + dividerColor: AppColors.g1Divider, + borderColor: AppColors.g1Border, + items: _importantUrgent, + quadrantValue: 1, + onComplete: _completeTodo, + onTap: _navigateToDetail, ), - Align( - alignment: Alignment.topCenter, - child: AppPullRefreshFeedback(visible: _isPullRefreshing), + const SizedBox(height: 12), + _buildQuadrant( + title: '紧急不重要', + textColor: AppColors.g2Text, + dividerColor: AppColors.g2Divider, + borderColor: AppColors.g2Border, + items: _urgentNotImportant, + quadrantValue: 3, + onComplete: _completeTodo, + onTap: _navigateToDetail, + ), + const SizedBox(height: 12), + _buildQuadrant( + title: '重要不紧急', + textColor: AppColors.g3Text, + dividerColor: AppColors.g3Divider, + borderColor: AppColors.g3Border, + items: _importantNotUrgent, + quadrantValue: 2, + onComplete: _completeTodo, + onTap: _navigateToDetail, ), ], ); + + if (withScroll) { + return Stack( + children: [ + RefreshIndicator.noSpinner( + onRefresh: _onPullRefresh, + child: SingleChildScrollView( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 4, + bottom: 96, + ), + child: content, + ), + ), + Align( + alignment: Alignment.topCenter, + child: AppPullRefreshFeedback(visible: _isPullRefreshing), + ), + ], + ); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: content, + ); } Widget _buildQuadrant({ @@ -274,73 +360,132 @@ class _TodoQuadrantsScreenState extends State { required Color dividerColor, required Color borderColor, required List items, + required int quadrantValue, required Future Function(TodoResponse) onComplete, required void Function(TodoResponse) onTap, }) { return Container( - width: double.infinity, - padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: AppColors.todoCardBg, borderRadius: BorderRadius.circular(14), border: Border.all(color: borderColor, width: 1), ), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title, - style: TextStyle( - fontFamily: 'Inter', - fontSize: 15, - fontWeight: FontWeight.w700, - color: textColor, - ), - ), - Text( - '${items.length}项', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 12, - fontWeight: FontWeight.w700, - color: textColor, - ), - ), - ], - ), - const SizedBox(height: 8), + _buildQuadrantHeader(title, textColor, items.length), Container(height: 1, color: dividerColor), const SizedBox(height: 8), - if (items.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: Text( - '暂无待办', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 13, - color: AppColors.slate400, + Padding( + padding: const EdgeInsets.fromLTRB(6, 0, 6, 8), + child: DragTarget( + onWillAcceptWithDetails: (details) { + _onDragEnterQuadrant(quadrantValue); + return true; + }, + onAcceptWithDetails: (details) { + final parts = details.data.split(':'); + final todoId = parts[0]; + _onDrop(todoId, quadrantValue, 0); + }, + onLeave: (_) { + _onDragLeave(); + }, + builder: (context, candidateData, rejectedData) { + final isDragOver = candidateData.isNotEmpty; + return Container( + decoration: BoxDecoration( + color: isDragOver + ? AppColors.blue50.withValues(alpha: 0.3) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: isDragOver + ? Border.all(color: AppColors.blue400, width: 2) + : null, ), - ), - ), - ) - else - ...items.map( - (item) => _TodoItemWidget( - item: item, - onComplete: () => onComplete(item), - onTap: () => onTap(item), - ), + child: items.isEmpty + ? SizedBox( + height: 60, + child: Center( + child: Text( + '暂无待办', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + color: AppColors.slate400, + ), + ), + ), + ) + : _buildQuadrantItemList( + items, + quadrantValue, + onComplete, + onTap, + ), + ); + }, ), + ), ], ), ); } + Widget _buildQuadrantHeader(String title, Color textColor, int itemCount) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w700, + color: textColor, + ), + ), + Text( + '${itemCount}项', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w700, + color: textColor, + ), + ), + ], + ), + ); + } + + Widget _buildQuadrantItemList( + List items, + int quadrantValue, + Future Function(TodoResponse) onComplete, + void Function(TodoResponse) onTap, + ) { + return Column( + mainAxisSize: MainAxisSize.min, + children: items.map((item) { + return TodoDragItem( + todo: item, + quadrant: quadrantValue, + onDragStarted: () => _onDragStart(item.id), + onDragEnd: _onDragEnd, + child: _TodoItemWidget( + item: item, + onComplete: () => onComplete(item), + onTap: () => onTap(item), + ), + ); + }).toList(), + ); + } + Widget _buildBottomDock() { return BottomDock( activeTab: DockTab.todo, diff --git a/apps/lib/features/todo/ui/widgets/todo_drag_item.dart b/apps/lib/features/todo/ui/widgets/todo_drag_item.dart new file mode 100644 index 0000000..51c6391 --- /dev/null +++ b/apps/lib/features/todo/ui/widgets/todo_drag_item.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:social_app/core/theme/design_tokens.dart'; +import 'package:social_app/features/todo/data/todo_api.dart'; + +class TodoDragItem extends StatelessWidget { + final TodoResponse todo; + final int quadrant; + final VoidCallback onDragStarted; + final VoidCallback onDragEnd; + final Widget child; + + const TodoDragItem({ + super.key, + required this.todo, + required this.quadrant, + required this.onDragStarted, + required this.onDragEnd, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return LongPressDraggable( + data: '${todo.id}:$quadrant', + delay: const Duration(milliseconds: 150), + feedback: Material( + elevation: 8, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Transform.scale( + scale: 1.03, + child: SizedBox(width: 280, child: _buildDragFeedback()), + ), + ), + childWhenDragging: AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: 0.3, + child: child, + ), + onDragStarted: onDragStarted, + onDragEnd: (_) => onDragEnd(), + child: child, + ); + } + + Widget _buildDragFeedback() { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.md), + boxShadow: [ + BoxShadow( + color: AppColors.slate400.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Text( + todo.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.slate700, + ), + ), + ); + } +} diff --git a/apps/lib/main.dart b/apps/lib/main.dart index cf23d3e..2636733 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'core/constants/app_constants.dart'; import 'core/di/injection.dart'; import 'core/notifications/local_notification_service.dart'; +import 'core/notifications/reminder_notification_callbacks.dart'; import 'core/router/app_router.dart'; import 'core/startup/auth_session_bootstrapper.dart'; import 'core/theme/app_theme.dart'; @@ -14,12 +15,18 @@ import 'features/auth/presentation/bloc/auth_event.dart'; 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 'features/calendar/reminders/ui/reminder_foreground_presenter.dart'; import 'features/chat/presentation/bloc/chat_bloc.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await configureDependencies(); await AppConstants.init(); + final rootNavigatorKey = GlobalKey(); + final reminderForegroundPresenter = ReminderForegroundPresenter( + navigatorKey: rootNavigatorKey, + executor: sl(), + ); sl().bindActionHandler(({ required action, required payload, @@ -29,6 +36,9 @@ void main() async { payload: payload, ); }); + sl().bindInAppReminderHandler( + reminderForegroundPresenter.present, + ); await sl().initialize(); final authBloc = sl(); @@ -37,6 +47,7 @@ void main() async { runApp( LinksyApp( authBloc: authBloc, + rootNavigatorKey: rootNavigatorKey, sessionBootstrapper: AuthSessionBootstrapper( calendarService: sl(), notificationService: sl(), @@ -44,15 +55,25 @@ void main() async { ), ), ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited( + ReminderNotificationCallbacks.bindResponseHandler( + sl().handleNotificationResponse, + ), + ); + }); } class LinksyApp extends StatelessWidget { final AuthBloc authBloc; + final GlobalKey rootNavigatorKey; final AuthSessionBootstrapper sessionBootstrapper; const LinksyApp({ super.key, required this.authBloc, + required this.rootNavigatorKey, required this.sessionBootstrapper, }); diff --git a/apps/lib/shared/widgets/app_selection_sheet.dart b/apps/lib/shared/widgets/app_selection_sheet.dart new file mode 100644 index 0000000..429005c --- /dev/null +++ b/apps/lib/shared/widgets/app_selection_sheet.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/design_tokens.dart'; +import 'app_button.dart'; + +class AppSelectionItem { + const AppSelectionItem({required this.value, required this.label}); + + final T value; + final String label; +} + +Future showAppSelectionSheet( + BuildContext context, { + required String title, + required List> items, + required T? selectedValue, +}) async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return SafeArea( + top: false, + child: Container( + margin: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.none, + AppSpacing.md, + AppSpacing.md, + ), + padding: const EdgeInsets.symmetric(vertical: 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: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + ), + ), + const SizedBox(height: AppSpacing.md), + ...items.map((item) { + final isSelected = item.value == selectedValue; + return _buildItem( + sheetContext, + item: item, + isSelected: isSelected, + ); + }), + const SizedBox(height: AppSpacing.sm), + const Divider(height: 1, color: AppColors.border), + const SizedBox(height: AppSpacing.sm), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: SizedBox( + height: 48, + child: AppButton( + text: '取消', + isOutlined: true, + onPressed: () => Navigator.of(sheetContext).pop(), + ), + ), + ), + ], + ), + ), + ); + }, + ); + return result; +} + +Widget _buildItem( + BuildContext sheetContext, { + required AppSelectionItem item, + required bool isSelected, +}) { + return InkWell( + onTap: () => Navigator.of(sheetContext).pop(item.value), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: Row( + children: [ + Expanded( + child: Text( + item.label, + style: TextStyle( + fontSize: 15, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? AppColors.blue600 : AppColors.slate800, + ), + ), + ), + if (isSelected) + const Icon(Icons.check, size: 20, color: AppColors.blue600), + ], + ), + ), + ); +} diff --git a/apps/lib/shared/widgets/detail_header_action_menu.dart b/apps/lib/shared/widgets/detail_header_action_menu.dart index 0db7cf9..d37ddb6 100644 --- a/apps/lib/shared/widgets/detail_header_action_menu.dart +++ b/apps/lib/shared/widgets/detail_header_action_menu.dart @@ -8,12 +8,14 @@ class DetailHeaderActionItem { required this.label, required this.icon, this.isDestructive = false, + this.enabled = true, }); final T value; final String label; final IconData icon; final bool isDestructive; + final bool enabled; } class DetailHeaderActionMenu extends StatefulWidget { @@ -141,7 +143,7 @@ class _DetailHeaderActionMenuState extends State> { Widget _buildMenuItem(DetailHeaderActionItem item) { final textColor = item.isDestructive ? AppColors.red500 - : AppColors.slate700; + : (item.enabled ? AppColors.slate700 : AppColors.slate400); final pressedColor = item.isDestructive ? AppColors.feedbackErrorSurface : AppColors.surfaceInfoLight; @@ -152,9 +154,9 @@ class _DetailHeaderActionMenuState extends State> { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(AppRadius.md), - splashColor: pressedColor, - highlightColor: pressedColor, - onTap: () => _handleSelect(item.value), + splashColor: item.enabled ? pressedColor : Colors.transparent, + highlightColor: item.enabled ? pressedColor : Colors.transparent, + onTap: item.enabled ? () => _handleSelect(item.value) : null, child: Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), child: Row( 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 new file mode 100644 index 0000000..34af9a9 --- /dev/null +++ b/apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart @@ -0,0 +1,78 @@ +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 248f50b..02a5a06 100644 --- a/apps/test/features/calendar/reminders/reminder_action_executor_test.dart +++ b/apps/test/features/calendar/reminders/reminder_action_executor_test.dart @@ -33,7 +33,7 @@ void main() { ); }); - test('cancel archives remotely and cancels local reminder', () async { + test('archive archives remotely and cancels local reminder', () async { when( () => notificationService.cancelEventReminder('evt_1'), ).thenAnswer((_) async {}); @@ -42,7 +42,7 @@ void main() { ).thenAnswer((_) async => null); await executor.handleAction( - action: ReminderAction.cancel, + action: ReminderAction.archive, payload: ReminderPayload( eventId: 'evt_1', title: 'sync', @@ -66,7 +66,7 @@ void main() { ).thenThrow(Exception('offline')); await executor.handleAction( - action: ReminderAction.cancel, + action: ReminderAction.archive, payload: ReminderPayload( eventId: 'evt_1', title: 'sync', @@ -114,4 +114,60 @@ void main() { ).called(1); verifyNever(() => calendarService.archiveEvent(any())); }); + + test('fromValue throws on unknown action', () { + expect( + () => ReminderAction.fromValue('unknown_action'), + throwsA(isA()), + ); + }); + + test( + 'aggregate action falls back to eventId when aggregateIds is empty', + () async { + when( + () => notificationService.cancelEventReminder('evt_fallback'), + ).thenAnswer((_) async {}); + when( + () => calendarService.archiveEvent('evt_fallback'), + ).thenAnswer((_) async => null); + + await executor.handleAction( + action: ReminderAction.archive, + payload: ReminderPayload( + eventId: 'evt_fallback', + title: 'sync', + startAt: DateTime.parse('2026-03-18T16:00:00+08:00'), + timezone: 'Asia/Shanghai', + mode: ReminderPayloadMode.aggregate, + aggregateIds: const [], + ), + ); + + verify( + () => notificationService.cancelEventReminder('evt_fallback'), + ).called(1); + 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 new file mode 100644 index 0000000..9d2a284 --- /dev/null +++ b/apps/test/features/calendar/reminders/reminder_action_sheet_test.dart @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..2019791 --- /dev/null +++ b/apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart @@ -0,0 +1,123 @@ +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_notification_bridge_test.dart b/apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart new file mode 100644 index 0000000..7d95c4c --- /dev/null +++ b/apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart @@ -0,0 +1,146 @@ +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_notification_callbacks_test.dart b/apps/test/features/calendar/reminders/reminder_notification_callbacks_test.dart new file mode 100644 index 0000000..9ef90d8 --- /dev/null +++ b/apps/test/features/calendar/reminders/reminder_notification_callbacks_test.dart @@ -0,0 +1,142 @@ +import 'dart:io'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:social_app/core/notifications/reminder_notification_callbacks.dart'; + +void main() { + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await ReminderNotificationCallbacks.resetForTest(); + }); + + test('contains top-level vm entry-point background callback', () async { + final source = await File( + 'lib/core/notifications/reminder_notification_callbacks.dart', + ).readAsString(); + + expect(source, contains("@pragma('vm:entry-point')")); + expect(source, contains('Future reminderNotificationTapBackground(')); + }); + + test( + 'dispatches foreground and background responses to bound handler', + () async { + final handledIds = []; + await ReminderNotificationCallbacks.bindResponseHandler((response) async { + handledIds.add(response.id); + }); + + await ReminderNotificationCallbacks.onForegroundResponse( + NotificationResponse( + notificationResponseType: + NotificationResponseType.selectedNotificationAction, + id: 10, + ), + ); + await reminderNotificationTapBackground( + NotificationResponse( + notificationResponseType: + NotificationResponseType.selectedNotificationAction, + id: 20, + ), + ); + + expect(handledIds, [10, 20]); + }, + ); + + test( + 'queues background response when handler is unbound and drains later', + () async { + await reminderNotificationTapBackground( + NotificationResponse( + notificationResponseType: + NotificationResponseType.selectedNotificationAction, + id: 99, + ), + ); + + final handledIds = []; + await ReminderNotificationCallbacks.bindResponseHandler((response) async { + handledIds.add(response.id); + }); + + expect(handledIds, [99]); + }, + ); + + test( + 'queues foreground response when handler is unbound and drains later', + () async { + await ReminderNotificationCallbacks.onForegroundResponse( + NotificationResponse( + notificationResponseType: + NotificationResponseType.selectedNotificationAction, + id: 55, + ), + ); + + final handledIds = []; + await ReminderNotificationCallbacks.bindResponseHandler((response) async { + handledIds.add(response.id); + }); + + expect(handledIds, [55]); + }, + ); + + test('failed pending item stays queued for next bind retry', () async { + await reminderNotificationTapBackground( + NotificationResponse( + notificationResponseType: + NotificationResponseType.selectedNotificationAction, + id: 77, + ), + ); + + var firstAttempt = true; + await ReminderNotificationCallbacks.bindResponseHandler((response) async { + if (firstAttempt) { + firstAttempt = false; + throw Exception('temporary failure'); + } + }); + + final handledIds = []; + await ReminderNotificationCallbacks.bindResponseHandler((response) async { + handledIds.add(response.id); + }); + + expect(handledIds, [77]); + }); + + test( + 'background handler failure while bound is enqueued for retry', + () async { + var firstAttempt = true; + await ReminderNotificationCallbacks.bindResponseHandler((response) async { + if (firstAttempt) { + firstAttempt = false; + throw Exception('temporary failure'); + } + }); + + await reminderNotificationTapBackground( + NotificationResponse( + notificationResponseType: + NotificationResponseType.selectedNotificationAction, + id: 123, + ), + ); + + final handledIds = []; + await ReminderNotificationCallbacks.bindResponseHandler((response) async { + handledIds.add(response.id); + }); + + expect(handledIds, [123]); + }, + ); +} diff --git a/apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart b/apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart new file mode 100644 index 0000000..ad8fb25 --- /dev/null +++ b/apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart @@ -0,0 +1,392 @@ +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'))); + }, + ); +} diff --git a/apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart b/apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart new file mode 100644 index 0000000..325cdc3 --- /dev/null +++ b/apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart @@ -0,0 +1,60 @@ +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, + ); + }); +} diff --git a/apps/test/features/home/ui/controllers/home_keyboard_inset_calculator_test.dart b/apps/test/features/home/ui/controllers/home_keyboard_inset_calculator_test.dart new file mode 100644 index 0000000..c48418d --- /dev/null +++ b/apps/test/features/home/ui/controllers/home_keyboard_inset_calculator_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/home/ui/controllers/home_keyboard_inset_calculator.dart'; + +void main() { + test('subtracts bottom safe area from keyboard inset', () { + final inset = HomeKeyboardInsetCalculator.compute( + rawViewInsetBottom: 336, + bottomViewPadding: 34, + ); + + expect(inset, 302); + }); + + test('returns zero when keyboard is effectively hidden', () { + final inset = HomeKeyboardInsetCalculator.compute( + rawViewInsetBottom: 6, + bottomViewPadding: 34, + ); + + expect(inset, 0); + }); + + test('follows keyboard fallback immediately when inset decreases', () { + final openedInset = HomeKeyboardInsetCalculator.compute( + rawViewInsetBottom: 336, + bottomViewPadding: 34, + ); + final collapsedInset = HomeKeyboardInsetCalculator.compute( + rawViewInsetBottom: 120, + bottomViewPadding: 34, + ); + + expect(openedInset, 302); + expect(collapsedInset, 86); + }); +} diff --git a/apps/test/features/settings/data/services/settings_user_cache_test.dart b/apps/test/features/settings/data/services/settings_user_cache_test.dart new file mode 100644 index 0000000..3e37475 --- /dev/null +++ b/apps/test/features/settings/data/services/settings_user_cache_test.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/settings/data/services/settings_user_cache.dart'; +import 'package:social_app/features/users/data/models/user_response.dart'; + +void main() { + test('getOrLoad calls loader only once when cache exists', () async { + final cache = SettingsUserCache(); + var loadCalls = 0; + + Future loader() async { + loadCalls += 1; + return const UserResponse(id: 'u1', username: 'first'); + } + + final first = await cache.getOrLoad(loader); + final second = await cache.getOrLoad(loader); + + expect(first.username, 'first'); + expect(second.username, 'first'); + expect(loadCalls, 1); + }); + + test('invalidate forces next load', () async { + final cache = SettingsUserCache(); + var loadCalls = 0; + + Future loader() async { + loadCalls += 1; + return UserResponse(id: 'u$loadCalls', username: 'user$loadCalls'); + } + + final first = await cache.getOrLoad(loader); + cache.invalidate(); + final second = await cache.getOrLoad(loader); + + expect(first.id, 'u1'); + expect(second.id, 'u2'); + expect(loadCalls, 2); + }); + + test( + 'invalidate blocks stale inflight response from repopulating cache', + () async { + final cache = SettingsUserCache(); + final completer = Completer(); + var loadCalls = 0; + + Future slowLoader() { + loadCalls += 1; + return completer.future; + } + + final pending = cache.getOrLoad(slowLoader); + cache.invalidate(); + completer.complete(const UserResponse(id: 'u1', username: 'stale')); + await pending; + + final fresh = await cache.getOrLoad(() async { + loadCalls += 1; + return const UserResponse(id: 'u2', username: 'fresh'); + }); + + expect(fresh.id, 'u2'); + expect(cache.cachedUser?.id, 'u2'); + expect(loadCalls, 2); + }, + ); +} diff --git a/apps/test/features/settings/ui/screens/settings_screen_test.dart b/apps/test/features/settings/ui/screens/settings_screen_test.dart new file mode 100644 index 0000000..781217e --- /dev/null +++ b/apps/test/features/settings/ui/screens/settings_screen_test.dart @@ -0,0 +1,130 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/api/i_api_client.dart'; +import 'package:social_app/core/di/injection.dart'; +import 'package:social_app/features/friends/data/friends_api.dart'; +import 'package:social_app/features/settings/data/services/settings_user_cache.dart'; +import 'package:social_app/features/settings/ui/screens/settings_screen.dart'; +import 'package:social_app/features/users/data/models/user_response.dart'; +import 'package:social_app/features/users/data/users_api.dart'; + +class _TestApiClient implements IApiClient { + @override + Future> delete(String path, {data, Options? options}) async { + return Response(requestOptions: RequestOptions(path: path)); + } + + @override + Future> get(String path, {Options? options}) async { + return Response(requestOptions: RequestOptions(path: path)); + } + + @override + Future> getSseLines( + String path, { + Map? headers, + }) async { + return const Stream.empty(); + } + + @override + Future> patch(String path, {data, Options? options}) async { + return Response(requestOptions: RequestOptions(path: path)); + } + + @override + Future> post(String path, {data, Options? options}) async { + return Response(requestOptions: RequestOptions(path: path)); + } +} + +class _FakeUsersApi extends UsersApi { + _FakeUsersApi(super.client); + + int getMeCalls = 0; + + @override + Future getMe() async { + getMeCalls += 1; + return const UserResponse( + id: 'u1', + username: 'Linksy', + phone: '13800000000', + ); + } +} + +class _FakeFriendsApi extends FriendsApi { + _FakeFriendsApi(super.client); + + @override + Future> getFriends() async { + return const []; + } +} + +void main() { + late _FakeUsersApi usersApi; + + setUp(() { + final apiClient = _TestApiClient(); + if (sl.isRegistered()) { + sl.unregister(); + } + if (sl.isRegistered()) { + sl.unregister(); + } + if (sl.isRegistered()) { + sl.unregister(); + } + usersApi = _FakeUsersApi(apiClient); + sl.registerSingleton(usersApi); + sl.registerSingleton(_FakeFriendsApi(apiClient)); + sl.registerSingleton(SettingsUserCache()); + }); + + tearDown(() async { + if (sl.isRegistered()) { + await sl.unregister(); + } + if (sl.isRegistered()) { + await sl.unregister(); + } + if (sl.isRegistered()) { + await sl.unregister(); + } + }); + + testWidgets('settings screen removes account row and shows logout button', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await tester.pump(); + + expect(find.text('我的账户'), findsNothing); + expect(find.text('退出登录'), findsOneWidget); + }); + + testWidgets('settings profile hero shows edit icon entry', (tester) async { + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await tester.pump(); + + expect(find.byKey(settingsProfileEditButtonKey), findsOneWidget); + }); + + testWidgets('settings screen re-entry uses cached user', (tester) async { + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await tester.pump(); + await tester.pump(); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await tester.pump(); + await tester.pump(); + + expect(usersApi.getMeCalls, 1); + }); +} diff --git a/apps/test/features/todo/quadrant_drag_test.dart b/apps/test/features/todo/quadrant_drag_test.dart new file mode 100644 index 0000000..2a4398f --- /dev/null +++ b/apps/test/features/todo/quadrant_drag_test.dart @@ -0,0 +1,135 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/features/todo/data/todo_api.dart'; +import 'package:social_app/core/api/i_api_client.dart'; + +class MockApiClient extends Mock implements IApiClient {} + +class FakeRequestOptions extends Fake implements RequestOptions {} + +void main() { + late TodoApi todoApi; + late MockApiClient mockClient; + + setUpAll(() { + registerFallbackValue(FakeRequestOptions()); + }); + + setUp(() { + mockClient = MockApiClient(); + todoApi = TodoApi(mockClient); + }); + + group('TodoApi.updateTodo - cross-quadrant drag', () { + test( + 'calls PATCH with priority when moving to different quadrant', + () async { + const todoId = 'todo-123'; + const targetPriority = 2; + + when( + () => mockClient.patch(any(), data: any(named: 'data')), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/api/v1/todos/$todoId'), + data: { + 'id': todoId, + 'owner_id': 'user-1', + 'title': 'Test Todo', + 'priority': targetPriority, + 'status': 'pending', + 'created_at': '2024-01-01T00:00:00Z', + 'updated_at': '2024-01-01T00:00:00Z', + }, + ), + ); + + final result = await todoApi.updateTodo( + todoId, + priority: targetPriority, + ); + + expect(result.priority, targetPriority); + verify( + () => mockClient.patch( + '/api/v1/todos/$todoId', + data: {'priority': targetPriority}, + ), + ).called(1); + }, + ); + + test('throws when API fails - triggers rollback', () async { + const todoId = 'todo-123'; + + when( + () => mockClient.patch(any(), data: any(named: 'data')), + ).thenThrow(Exception('Network error')); + + expect(() => todoApi.updateTodo(todoId, priority: 2), throwsException); + }); + }); + + group('Quadrant priority mapping', () { + test('priority 1 = important urgent (Q1)', () async { + when(() => mockClient.patch(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/api/v1/todos/todo-1'), + data: { + 'id': 'todo-1', + 'owner_id': 'user-1', + 'title': 'Q1 Todo', + 'priority': 1, + 'status': 'pending', + 'created_at': '2024-01-01T00:00:00Z', + 'updated_at': '2024-01-01T00:00:00Z', + }, + ), + ); + + final result = await todoApi.updateTodo('todo-1', priority: 1); + expect(result.priority, 1); + }); + + test('priority 2 = important not urgent (Q3)', () async { + when(() => mockClient.patch(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/api/v1/todos/todo-2'), + data: { + 'id': 'todo-2', + 'owner_id': 'user-1', + 'title': 'Q3 Todo', + 'priority': 2, + 'status': 'pending', + 'created_at': '2024-01-01T00:00:00Z', + 'updated_at': '2024-01-01T00:00:00Z', + }, + ), + ); + + final result = await todoApi.updateTodo('todo-2', priority: 2); + expect(result.priority, 2); + }); + + test('priority 3 = urgent not important (Q2)', () async { + when(() => mockClient.patch(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/api/v1/todos/todo-3'), + data: { + 'id': 'todo-3', + 'owner_id': 'user-1', + 'title': 'Q2 Todo', + 'priority': 3, + 'status': 'pending', + 'created_at': '2024-01-01T00:00:00Z', + 'updated_at': '2024-01-01T00:00:00Z', + }, + ), + ); + + final result = await todoApi.updateTodo('todo-3', priority: 3); + expect(result.priority, 3); + }); + }); +} diff --git a/apps/test/platform/android_manifest_notification_action_test.dart b/apps/test/platform/android_manifest_notification_action_test.dart new file mode 100644 index 0000000..4b30675 --- /dev/null +++ b/apps/test/platform/android_manifest_notification_action_test.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('AndroidManifest declares ActionBroadcastReceiver', () { + final manifestFile = File('android/app/src/main/AndroidManifest.xml'); + + expect(manifestFile.existsSync(), isTrue); + + final manifestContent = manifestFile.readAsStringSync(); + + expect( + manifestContent, + contains( + 'com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver', + ), + ); + }); +} diff --git a/apps/test/platform/ios_app_delegate_notification_callback_test.dart b/apps/test/platform/ios_app_delegate_notification_callback_test.dart new file mode 100644 index 0000000..5d2429a --- /dev/null +++ b/apps/test/platform/ios_app_delegate_notification_callback_test.dart @@ -0,0 +1,22 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('AppDelegate registers flutter local notifications callback', () { + final appDelegateFile = File('ios/Runner/AppDelegate.swift'); + + expect(appDelegateFile.existsSync(), isTrue); + + final appDelegateContent = appDelegateFile.readAsStringSync(); + + expect( + appDelegateContent, + contains('FlutterLocalNotificationsPlugin.setPluginRegistrantCallback'), + ); + expect( + appDelegateContent, + contains('GeneratedPluginRegistrant.register(with: registry)'), + ); + }); +} diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 4e78376..ee5cb16 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -58,38 +58,16 @@ services: timeout: 5s retries: 10 - worker-critical: + worker-agent: image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} - container_name: social-prod-worker-critical + container_name: social-prod-worker-agent restart: unless-stopped env_file: - ./.env.prod environment: - PYTHONPATH=/app/backend/src - PYTHONDONTWRITEBYTECODE=1 - - SOCIAL_RUNTIME__SERVICE_NAME=worker-critical - - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} - - SOCIAL_REDIS__HOST=redis - - SOCIAL_REDIS__PORT=6379 - command: > - sh -c '.venv/bin/taskiq worker core.taskiq.app:critical_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__CRITICAL__CONCURRENCY:-2}' - depends_on: - redis: - condition: service_healthy - volumes: - - ../logs:/app/logs - - ./static/releases:/app/deploy/static/releases:ro - - worker-default: - image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} - container_name: social-prod-worker-default - restart: unless-stopped - env_file: - - ./.env.prod - environment: - - PYTHONPATH=/app/backend/src - - PYTHONDONTWRITEBYTECODE=1 - - SOCIAL_RUNTIME__SERVICE_NAME=worker-default + - SOCIAL_RUNTIME__SERVICE_NAME=worker-agent - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} - SOCIAL_REDIS__HOST=redis - SOCIAL_REDIS__PORT=6379 @@ -102,16 +80,16 @@ services: - ../logs:/app/logs - ./static/releases:/app/deploy/static/releases:ro - worker-bulk: + worker-automation: image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} - container_name: social-prod-worker-bulk + container_name: social-prod-worker-automation restart: unless-stopped env_file: - ./.env.prod environment: - PYTHONPATH=/app/backend/src - PYTHONDONTWRITEBYTECODE=1 - - SOCIAL_RUNTIME__SERVICE_NAME=worker-bulk + - SOCIAL_RUNTIME__SERVICE_NAME=worker-automation - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} - SOCIAL_REDIS__HOST=redis - SOCIAL_REDIS__PORT=6379 @@ -124,6 +102,27 @@ services: - ../logs:/app/logs - ./static/releases:/app/deploy/static/releases:ro + scheduler: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-scheduler + restart: unless-stopped + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + - SOCIAL_RUNTIME__SERVICE_NAME=scheduler + - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} + - SOCIAL_REDIS__HOST=redis + - SOCIAL_REDIS__PORT=6379 + command: .venv/bin/python -m core.runtime.cli automation-scheduler + depends_on: + redis: + condition: service_healthy + volumes: + - ../logs:/app/logs + - ./static/releases:/app/deploy/static/releases:ro + init-job: image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} container_name: social-prod-init-job diff --git a/docs/protocols/calendar/reminder-alert-lifecycle.md b/docs/protocols/calendar/reminder-alert-lifecycle.md index d6d1925..f221dcc 100644 --- a/docs/protocols/calendar/reminder-alert-lifecycle.md +++ b/docs/protocols/calendar/reminder-alert-lifecycle.md @@ -9,18 +9,16 @@ ## Goal -定义日程提醒弹窗在 Android/iOS 的统一行为语义,覆盖提醒触发、用户操作、超时、离线补偿、归档与重装恢复,确保多端一致性和可恢复性。 +定义日程提醒弹窗在 Android/iOS 的统一行为语义,覆盖提醒触发、用户操作、离线补偿、归档与重装恢复,确保多端一致性和可恢复性。 --- ## Canonical Rules -1. 提醒弹窗动作语义跨平台一致:`cancel`、`snooze_10m`、`timeout_30s`。 -2. `timeout_30s` 语义固定为忽略当前弹窗并按 10 分钟后再次提醒,不直接归档。 -3. `cancel` 必须归档对应日程(`status=archived`)。 -4. 到达事件结束时间后(`now >= end_at`)必须停止提醒并归档。 -5. 归档后的 UI 渲染必须灰色显示;不强制改写原始 metadata 颜色。 -6. 前端动作上报后端采用最终一致性:本地 outbox + 重放机制。 +1. 提醒弹窗动作语义跨平台一致:`archive`、`snooze10m`。 +2. 展示文案“取消”必须映射到内部动作 `archive`,并归档对应日程(`status=archived`)。 +3. 归档后的 UI 渲染必须灰色显示;不强制改写原始 metadata 颜色。 +4. 前端动作上报后端采用最终一致性:本地 outbox + 重放机制。 --- @@ -55,10 +53,13 @@ 动作枚举: -- `cancel`: 用户取消提醒并归档事件 -- `snooze_10m`: 用户点击稍后提醒,重排到 `now + 10m` -- `timeout_30s`: 用户 30s 未处理,按 `snooze_10m` 处理 -- `auto_archive`: 系统判定事件已过期,自动归档 +- `archive`: 内部归档动作(UI 展示文案为“取消”) +- `snooze10m`: 用户点击稍后提醒,重排到 `now + 10m` + +### UI Label Mapping + +- 按钮展示文案“取消”仅为 UI 文案,不作为协议动作值。 +- “取消”按钮点击后的上报动作值必须为 `archive`。 --- @@ -68,7 +69,7 @@ { "opId": "uuid", "eventId": "uuid", - "action": "cancel|snooze_10m|timeout_30s|auto_archive", + "action": "archive|snooze10m", "targetStatus": "archived|null", "occurredAt": "iso8601-with-offset", "retryCount": 0, @@ -80,11 +81,23 @@ ### Constraints -- 幂等键:`(eventId, action, occurredAtBucket)`。 -- 重试策略:指数退避,最大重试次数可配置。 -- `cancel` 和 `auto_archive` 都映射到后端 `PATCH status=archived`。 +- 幂等键:`actionExecutionId = notificationId + "|" + actionId + "|" + fireTimeBucket`。 +- `notificationId`: 本地通知唯一 id(整数或其字符串化值)。 +- `actionId`: 内部动作 id,取值仅允许 `archive` 或 `snooze10m`。 +- `fireTimeBucket`: 触发时间按“分钟”向下取整后的 epoch minute(`millisecondsSinceEpoch / 60000`)。 +- 执行前必须先按 `actionExecutionId` 查重;若命中历史执行,直接 ACK,不得产生任何业务副作用。 +- 重试策略:指数退避。默认参数:首次重试 0 分钟,之后依次 1/2/4/8/16/32/64 分钟;最大重试次数默认 8(可配置但需跨端一致)。 +- `archive` 映射到后端 `PATCH status=archived`。 - Outbox 记录必须本地持久化,App 重启后可恢复。 +### cold-start Queue Replay Contract + +- App cold-start 恢复 outbox/动作队列时,必须按入队顺序进行回放。 +- 单条回放失败只记录失败并进入重试,不得阻塞后续条目继续回放。 +- 回放过程必须复用 `actionExecutionId` 做去重,防止重复归档或重复稍后提醒。 +- 回放执行模型为全局串行(concurrency=1),处理顺序与入队顺序一致。 +- ACK 时序:仅在动作被成功执行或命中幂等去重时返回 ACK;失败条目进入重试状态并继续处理下一条。 + --- ## Scheduling and Compensation Rules @@ -99,13 +112,15 @@ 1. `now < remindAt`:按 `remindAt` 正常调度。 2. `remindAt <= now < endAt`:立刻补偿提醒(建议 `+5s`),然后进入 10 分钟节奏。 -3. `now >= endAt`:不再提醒,走归档流程。 +3. `now >= endAt`:不再补发提醒;后续状态流转由上层业务策略决定(不在本协议强制范围内)。 +4. `endAt = null`:视为无结束时间,沿用规则 1/2,不适用规则 3。 --- ## Uniqueness and Dedupe Rules - 通知唯一键:`hash(eventId + cycleStartEpochMinutes + mode)`。 +- `cycleStartEpochMinutes` 定义为“该轮提醒触发时间(fireAt)按分钟向下取整后的 epoch minute”。 - 每次创建提醒前必须取消同 dedupe key 的旧提醒(upsert 语义)。 - 补偿提醒在同一 cycle 窗口内最多触发一次。 - 启动恢复时要同时参考 pending notification 和 outbox 状态,避免重复调度。 diff --git a/docs/superpowers/plans/2026-03-19-calendar-reminder-unified-interaction-implementation.md b/docs/superpowers/plans/2026-03-19-calendar-reminder-unified-interaction-implementation.md new file mode 100644 index 0000000..3763f6c --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-calendar-reminder-unified-interaction-implementation.md @@ -0,0 +1,391 @@ +# Calendar Reminder Unified Interaction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 交付“系统通知主触达 + 前台统一提醒面板”的提醒链路,保证 iOS/Android 在前台、后台、终止态都可执行“稍后提醒”和“取消并归档(内部 archive)”。 + +**Architecture:** `LocalNotificationService` 仅做调度与平台桥接;`ReminderActionExecutor` 作为唯一动作执行器;新增 `ReminderPresentationCoordinator` + `ReminderActionSheet` 负责前台展示;新增持久化幂等与 cold-start 回放,避免动作丢失/重复执行。 + +**Tech Stack:** Flutter, flutter_local_notifications, SharedPreferences, Dart isolate callback, AndroidManifest receiver, iOS AppDelegate callback, Flutter tests. + +--- + +## File Structure (Locked Before Tasks) + +### Protocol (Must First) +- Modify: `docs/protocols/calendar/reminder-alert-lifecycle.md` + +### Reminder Core +- Modify: `apps/lib/core/notifications/local_notification_service.dart` +- Create: `apps/lib/core/notifications/reminder_notification_callbacks.dart` +- Modify: `apps/lib/features/calendar/reminders/models/reminder_action.dart` +- Modify: `apps/lib/features/calendar/reminders/reminder_action_executor.dart` +- Create: `apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart` +- Create: `apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart` + +### Foreground UI +- Create: `apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart` +- Create: `apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart` + +### App & Platform Wiring +- Modify: `apps/lib/main.dart` +- Modify: `apps/android/app/src/main/AndroidManifest.xml` +- Modify: `apps/ios/Runner/AppDelegate.swift` + +### Tests +- Modify: `apps/test/features/calendar/reminders/reminder_action_executor_test.dart` +- Create: `apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart` +- Create: `apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart` +- Create: `apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart` +- Create: `apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart` +- Create: `apps/test/features/calendar/reminders/reminder_action_sheet_test.dart` +- Create: `apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart` +- Create: `apps/test/platform/android_manifest_notification_action_test.dart` +- Create: `apps/test/platform/ios_app_delegate_notification_callback_test.dart` + +### Cleanup +- Create: `docs/todo/calendar-reminder-migration-checklist.md` + +--- + +### Task 0: 协议先行更新(必须) + +**Files:** +- Modify: `docs/protocols/calendar/reminder-alert-lifecycle.md` + +- [ ] **Step 1: 更新动作语义文案** +```text +展示文案“取消”映射到内部动作 archive +``` +- [ ] **Step 2: 增加幂等键协议** +```text +actionExecutionId = notificationId + actionId + fireTimeBucket +``` +- [ ] **Step 3: 增加 cold-start queue 回放协议** +```text +顺序回放,单条失败不阻塞后续 +``` +- [ ] **Step 4: 运行协议自检** +Run: `rg "archive|actionExecutionId|cold-start" docs/protocols/calendar/reminder-alert-lifecycle.md` +Expected: 命中新增规则 +- [ ] **Step 5: Commit** +```bash +git add docs/protocols/calendar/reminder-alert-lifecycle.md +git commit -m "docs: align reminder protocol with archive and idempotency" +``` + +### Task 1: 动作语义收敛(TDD) + +**Files:** +- Modify: `apps/test/features/calendar/reminders/reminder_action_executor_test.dart` +- Modify: `apps/lib/features/calendar/reminders/models/reminder_action.dart` +- Modify: `apps/lib/features/calendar/reminders/reminder_action_executor.dart` + +- [ ] **Step 1: 写失败测试(archive 入口)** +```dart +test('archive action cancels reminder and archives event', () async {}); +``` +- [ ] **Step 2: 运行失败测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_action_executor_test.dart` +Expected: FAIL +- [ ] **Step 3: 最小实现(增加 archive 枚举并接入 executor)** +```dart +enum ReminderAction { archive('archive'), snooze10m('snooze_10m'), ... } +``` +- [ ] **Step 4: 运行通过测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_action_executor_test.dart` +Expected: PASS +- [ ] **Step 5: Commit** +```bash +git add apps/lib/features/calendar/reminders/models/reminder_action.dart apps/lib/features/calendar/reminders/reminder_action_executor.dart apps/test/features/calendar/reminders/reminder_action_executor_test.dart +git commit -m "refactor: unify reminder action semantics to archive" +``` + +### Task 2: 持久化幂等(TDD) + +**Files:** +- Create: `apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart` +- Create: `apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart` + +- [ ] **Step 1: 写失败测试(重启后仍去重)** +```dart +test('same actionExecutionId is rejected after restart', () async {}); +``` +- [ ] **Step 2: 跑失败测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_action_dedupe_store_test.dart` +Expected: FAIL +- [ ] **Step 3: 实现最小 store** +```dart +Future markIfNew(String actionExecutionId) +``` +- [ ] **Step 4: 跑通过测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_action_dedupe_store_test.dart` +Expected: PASS +- [ ] **Step 5: Commit** +```bash +git add apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart +git commit -m "feat: add persistent reminder action dedupe store" +``` + +### Task 3: 冷启动回放队列(TDD) + +**Files:** +- Create: `apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart` +- Create: `apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart` + +- [ ] **Step 1: 写失败测试(顺序回放)** +```dart +test('replays actions in receive order', () async {}); +``` +- [ ] **Step 2: 写失败测试(单条失败不阻塞)** +```dart +test('continues replay after one action failure', () async {}); +``` +- [ ] **Step 3: 跑失败测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_cold_start_queue_test.dart` +Expected: FAIL +- [ ] **Step 4: 最小实现队列** +```dart +Future replaySequentially(Future Function(...) handler) +``` +- [ ] **Step 5: 跑通过测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_cold_start_queue_test.dart` +Expected: PASS +- [ ] **Step 6: Commit** +```bash +git add apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart +git commit -m "feat: add reminder cold-start replay queue" +``` + +### Task 4: 通知桥接映射(TDD) + +**Files:** +- Create: `apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart` +- Modify: `apps/lib/core/notifications/local_notification_service.dart` + +- [ ] **Step 1: 写失败测试(cancel 文案映射 archive)** +```dart +test('maps cancel action button to ReminderAction.archive', () async {}); +``` +- [ ] **Step 2: 跑失败测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_notification_bridge_test.dart` +Expected: FAIL +- [ ] **Step 3: 接入 dedupe store + bridge** +```dart +if (!await dedupeStore.markIfNew(actionExecutionId)) return; +``` +- [ ] **Step 4: 跑通过测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_notification_bridge_test.dart` +Expected: PASS +- [ ] **Step 5: Commit** +```bash +git add apps/lib/core/notifications/local_notification_service.dart apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart +git commit -m "feat: map reminder notification actions through unified bridge" +``` + +### Task 5: 前台协调器(TDD) + +**Files:** +- Create: `apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart` +- Create: `apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart` + +- [ ] **Step 1: 写失败测试(仅前台展示)** +```dart +test('shows reminder sheet only in app active state', () async {}); +``` +- [ ] **Step 2: 写失败测试(去重窗口)** +```dart +test('suppresses duplicate presentation in dedupe window', () async {}); +``` +- [ ] **Step 3: 跑失败测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_presentation_coordinator_test.dart` +Expected: FAIL +- [ ] **Step 4: 最小实现协调器** +- [ ] **Step 5: 跑通过测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_presentation_coordinator_test.dart` +Expected: PASS +- [ ] **Step 6: Commit** +```bash +git add apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart +git commit -m "feat: add reminder foreground presentation coordinator" +``` + +### Task 6: 前台提醒面板组件(TDD) + +**Files:** +- Create: `apps/test/features/calendar/reminders/reminder_action_sheet_test.dart` +- Create: `apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart` + +- [ ] **Step 1: 写失败测试(稍后提醒按钮)** +```dart +testWidgets('tap snooze triggers snooze callback', (tester) async {}); +``` +- [ ] **Step 2: 写失败测试(归档按钮)** +```dart +testWidgets('tap archive triggers archive callback', (tester) async {}); +``` +- [ ] **Step 3: 跑失败测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_action_sheet_test.dart` +Expected: FAIL +- [ ] **Step 4: 最小实现 token 驱动 UI** +- [ ] **Step 5: 跑通过测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_action_sheet_test.dart` +Expected: PASS +- [ ] **Step 6: Commit** +```bash +git add apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart apps/test/features/calendar/reminders/reminder_action_sheet_test.dart +git commit -m "feat: add reusable reminder action sheet" +``` + +### Task 7: App 接线与后台入口(TDD) + +**Files:** +- Modify: `apps/lib/main.dart` +- Create/Modify: `apps/lib/core/notifications/reminder_notification_callbacks.dart` +- Modify: `apps/lib/core/notifications/local_notification_service.dart` +- Create: `apps/test/features/calendar/reminders/reminder_notification_callbacks_test.dart` + +- [ ] **Step 1: 写失败测试(后台入口是 top-level + pragma)** +```dart +test('background notification callback is top-level entry-point', () async {}); +``` +- [ ] **Step 2: 跑失败测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_notification_callbacks_test.dart` +Expected: FAIL +- [ ] **Step 3: 接线 initialize 注册前台/后台回调** +- [ ] **Step 4: 接线 foreground presenter 到 coordinator** +- [ ] **Step 5: 接线 action handler 到 executor** +- [ ] **Step 6: reminders 套件回归** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders` +Expected: PASS +- [ ] **Step 7: Commit** +```bash +git add apps/lib/main.dart apps/lib/core/notifications/reminder_notification_callbacks.dart apps/lib/core/notifications/local_notification_service.dart apps/test/features/calendar/reminders/reminder_notification_callbacks_test.dart +git commit -m "feat: wire reminder callbacks for foreground and background" +``` + +### Task 8: Android 平台配置可执行校验(TDD) + +**Files:** +- Create: `apps/test/platform/android_manifest_notification_action_test.dart` +- Modify: `apps/android/app/src/main/AndroidManifest.xml` + +- [ ] **Step 1: 写失败测试(缺 ActionBroadcastReceiver)** +```dart +test('android manifest contains ActionBroadcastReceiver', () async {}); +``` +- [ ] **Step 2: 跑失败测试** +Run (workdir=`apps`): `flutter test test/platform/android_manifest_notification_action_test.dart` +Expected: FAIL +- [ ] **Step 3: 增加 receiver 配置** +- [ ] **Step 4: 跑通过测试** +Run (workdir=`apps`): `flutter test test/platform/android_manifest_notification_action_test.dart` +Expected: PASS +- [ ] **Step 5: Commit** +```bash +git add apps/android/app/src/main/AndroidManifest.xml apps/test/platform/android_manifest_notification_action_test.dart +git commit -m "fix: register android action receiver for reminder notifications" +``` + +### Task 9: iOS 平台配置可执行校验(TDD) + +**Files:** +- Create: `apps/test/platform/ios_app_delegate_notification_callback_test.dart` +- Modify: `apps/ios/Runner/AppDelegate.swift` +- Modify: `apps/lib/core/notifications/local_notification_service.dart` + +- [ ] **Step 1: 写失败测试(registrant callback)** +```dart +test('ios app delegate registers flutter local notifications callback', () async {}); +``` +- [ ] **Step 2: 跑失败测试** +Run (workdir=`apps`): `flutter test test/platform/ios_app_delegate_notification_callback_test.dart` +Expected: FAIL +- [ ] **Step 3: 实现 callback 注册 + category version bump (`calendar_reminder_v2`)** +- [ ] **Step 4: 跑通过测试与 reminders 回归** +Run (workdir=`apps`): `flutter test test/platform/ios_app_delegate_notification_callback_test.dart` +Expected: PASS +Run (workdir=`apps`): `flutter test test/features/calendar/reminders` +Expected: PASS +- [ ] **Step 5: Commit** +```bash +git add apps/ios/Runner/AppDelegate.swift apps/lib/core/notifications/local_notification_service.dart apps/test/platform/ios_app_delegate_notification_callback_test.dart +git commit -m "fix: enable ios reminder action handling in background" +``` + +### Task 10: Android 13+ 权限降级与埋点(TDD) + +**Files:** +- Create: `apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart` +- Modify: `apps/lib/core/notifications/local_notification_service.dart` + +- [ ] **Step 1: 写失败测试(未授权降级到应用内)** +```dart +test('fallbacks to in-app path when notifications permission denied', () async {}); +``` +- [ ] **Step 2: 跑失败测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_permission_fallback_test.dart` +Expected: FAIL +- [ ] **Step 3: 最小实现权限检查 + 降级埋点** +- [ ] **Step 3: 最小实现权限检查 + 降级埋点** +```text +埋点字段至少包含:actionExecutionId、permissionState、appLifecycleState、platform +``` +- [ ] **Step 4: 跑通过测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders/reminder_permission_fallback_test.dart` +Expected: PASS +- [ ] **Step 5: Commit** +```bash +git add apps/lib/core/notifications/local_notification_service.dart apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart +git commit -m "feat: add reminder permission fallback path and telemetry" +``` + +### Task 11: 旧代码清单与即时清理 + +**Files:** +- Create: `docs/todo/calendar-reminder-migration-checklist.md` +- Modify/Delete: `apps/lib/**`, `apps/test/**`(以清单为准) + +- [ ] **Step 1: 建立迁移清单(文件/符号/决策/责任人)** +- [ ] **Step 2: 清理前扫描** +Run: `rg "calendar_reminder_actions_v1|ReminderAction.cancel|_oldReminderEntry|_legacyReminderRoute" apps/lib apps/test` +Expected: 输出待清理命中(仅代码引用,不含注释/文档示例) +- [ ] **Step 3: 删除无用旧代码、无效测试、旧 fixture** +- [ ] **Step 4: 清理后扫描** +Run: `rg "calendar_reminder_actions_v1|ReminderAction.cancel|_oldReminderEntry|_legacyReminderRoute" apps/lib apps/test` +Expected: no matches(注释/文档示例允许存在需在清单标注) +- [ ] **Step 5: Commit** +```bash +git add docs/todo/calendar-reminder-migration-checklist.md apps/lib apps/test +git commit -m "refactor: remove obsolete reminder paths after migration" +``` + +### Task 12: 最终验证与交付 + +**Files:** +- Verify only + +- [ ] **Step 1: reminders 全量测试** +Run (workdir=`apps`): `flutter test test/features/calendar/reminders` +Expected: PASS +- [ ] **Step 2: calendar 相关测试** +Run (workdir=`apps`): `flutter test test/features/calendar` +Expected: PASS +- [ ] **Step 3: 手工矩阵 6/6 验证** +```text +Android: 前台/后台/杀进程 +iOS: 前台/后台锁屏/升级后 +``` +- [ ] **Step 4: 输出证据与指标** +```text +命令结果、回放日志、重试日志、重复执行率=0(按 actionExecutionId 统计) +``` + +--- + +## Execution Notes + +- 任务顺序不可打乱:协议 -> 动作语义 -> 幂等 -> 回放 -> 桥接 -> UI -> 平台 -> 清理。 +- 每个任务只做最小改动并回归对应测试。 +- 视觉组件严格使用 `apps/lib/core/theme/design_tokens.dart`。 +- 若实现中与 spec 冲突,先改 spec/协议再继续写代码。 diff --git a/docs/superpowers/plans/2026-03-20-todo-quadrant-drag-implementation.md b/docs/superpowers/plans/2026-03-20-todo-quadrant-drag-implementation.md new file mode 100644 index 0000000..06f10e2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-todo-quadrant-drag-implementation.md @@ -0,0 +1,524 @@ +# 待办事项四象限拖拽实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现四象限待办页面的拖拽排序和跨象限移动功能 + +**Architecture:** 使用 Flutter `LongPressDraggable` + `DragTarget` 实现拖拽,`AnimatedContainer` 实现排序动画,乐观更新模式同步后端 + +**Tech Stack:** Flutter, go_router, provider/state management + +--- + +## 文件结构 + +``` +apps/lib/features/todo/ +├── ui/screens/todo_quadrants_screen.dart (修改: 添加拖拽状态管理) +├── ui/widgets/ +│ └── todo_drag_item.dart (创建: 可拖拽待办项组件) +└── data/todo_api.dart (检查: 确认 API 支持更新 priority) +``` + +--- + +## 前置检查 + +- [ ] **Step 1: 检查 TodoApi 支持更新 priority** + +文件: `apps/lib/features/todo/data/todo_api.dart` + +```dart +// 确认有 updateTodo 方法支持更新 priority +Future updateTodo(String id, {int? priority, ...}) +``` + +--- + +## Task 1: 添加拖拽状态管理 + +**文件:** +- 修改: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart:26-40` + +- [ ] **Step 1: 添加拖拽相关状态和回调** + +在 `_TodoQuadrantsScreenState` 中添加: + +```dart +class _TodoQuadrantsScreenState extends State { + // ... existing states ... + + // 拖拽状态 + String? _draggingTodoId; + int? _dragTargetQuadrant; // 1, 2, 3 + int? _dragInsertIndex; // 插入位置索引 + + // 辅助方法 + bool get _isDragging => _draggingTodoId != null; + + void _onDragStart(String todoId) { + setState(() { + _draggingTodoId = todoId; + }); + } + + void _onDragEnd() { + setState(() { + _draggingTodoId = null; + _dragTargetQuadrant = null; + _dragInsertIndex = null; + }); + } + + void _onDragEnterQuadrant(int quadrant) { + setState(() { + _dragTargetQuadrant = quadrant; + }); + } + + void _onDragUpdateInsertIndex(int index) { + setState(() { + _dragInsertIndex = index; + }); + } + + Future _onDrop(String todoId, int targetQuadrant, int insertIndex) async { + // 实现乐观更新 + 后端同步 + } +} +``` + +- [ ] **Step 2: 将 TodoDragItem 回调传入正确的 State 方法** + +在 `_buildQuadrant` 构建 TodoDragItem 时传入: + +```dart +TodoDragItem( + todo: item, + quadrant: quadrantValue, + onDragStarted: () => _onDragStart(item.id), + onDragEnd: _onDragEnd, +) +``` + +- [ ] **Step 3: 运行测试验证编译通过** + +```bash +cd apps && flutter analyze lib/features/todo/ui/screens/todo_quadrants_screen.dart +``` + +--- + +## Task 2: 创建 TodoDragItem 组件 + +**文件:** +- 创建: `apps/lib/features/todo/ui/widgets/todo_drag_item.dart` + +- [ ] **Step 1: 创建 Stateful 拖拽组件** + +```dart +class TodoDragItem extends StatefulWidget { + final TodoResponse todo; + final int quadrant; // 1, 2, 3 + final VoidCallback onDragStarted; + final VoidCallback onDragEnd; + + const TodoDragItem({ + super.key, + required this.todo, + required this.quadrant, + required this.onDragStarted, + required this.onDragEnd, + }); + + @override + State createState() => _TodoDragItemState(); +} + +class _TodoDragItemState extends State { + @override + Widget build(BuildContext context) { + return LongPressDraggable( + data: '${widget.todo.id}:${widget.quadrant}', + delay: const Duration(milliseconds: 150), + feedback: Material( + elevation: 8, + borderRadius: BorderRadius.circular(8), + child: Transform.scale( + scale: 1.03, + child: SizedBox( + width: 280, + child: _buildDragFeedback(), + ), + ), + ), + childWhenDragging: Opacity( + opacity: 0.5, // Spec: 占位框 opacity 0.5 + child: widget.child, + ), + onDragStarted: widget.onDragStarted, + onDragEnd: (_) => widget.onDragEnd(), + child: widget.child, + ); + } + + Widget _buildDragFeedback() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: AppColors.slate400.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Text( + widget.todo.title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.slate700, + ), + ), + ); + } +} +``` + +- [ ] **Step 2: 验证编译** + +```bash +flutter analyze lib/features/todo/ui/widgets/todo_drag_item.dart +``` + +--- + +## Task 3: 实现 DragTarget 象限接收 + +**文件:** +- 修改: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart` 的 `_buildQuadrant` 方法 + +- [ ] **Step 1: 将象限容器改为 DragTarget** + +```dart +Widget _buildQuadrant({ + required String title, + required Color textColor, + required Color dividerColor, + required Color borderColor, + required List items, + required int quadrantValue, // 1, 2, 3 + required Future Function(TodoResponse) onComplete, + required void Function(TodoResponse) onTap, +}) { + return DragTarget( + onWillAcceptWithDetails: (details) { + // 解析拖拽数据 + final parts = details.data.split(':'); + final todoId = parts[0]; + // 标记目标象限 + _onDragEnterQuadrant(quadrantValue); + return true; + }, + onAcceptWithDetails: (details) { + final parts = details.data.split(':'); + final todoId = parts[0]; + _onDrop(todoId, quadrantValue, 0); // TODO: 计算插入位置 + }, + onLeave: (_) { + // 清除高亮 + }, + builder: (context, candidateData, rejectedData) { + final isDragOver = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + border: Border.all( + color: isDragOver ? AppColors.blue400 : borderColor, + width: isDragOver ? 2 : 1, + ), + boxShadow: isDragOver ? [ + BoxShadow( + color: AppColors.blue200.withValues(alpha: 0.4), + blurRadius: 12, + ), + ] : null, + ), + child: _buildQuadrantContent(...), + ); + }, + ); +} +``` + +- [ ] **Step 2: 验证编译** + +```bash +flutter analyze lib/features/todo/ui/screens/todo_quadrants_screen.dart +``` + +--- + +## Task 4: 实现跨象限移动和象限内排序逻辑 + +**文件:** +- 修改: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart` + +- [ ] **Step 1: 实现 _onDrop 方法(支持跨象限移动和象限内排序)** + +```dart +Future _onDrop(String todoId, int targetQuadrant, int insertIndex) async { + final todo = _todos.firstWhere((t) => t.id == todoId); + final sourceQuadrant = todo.priority; + + // 乐观更新:先保存当前状态用于回滚 + final previousTodos = List.from(_todos); + + if (sourceQuadrant == targetQuadrant) { + // 象限内排序:重新排列列表顺序 + setState(() { + final currentIndex = _todos.indexWhere((t) => t.id == todoId); + if (currentIndex != insertIndex) { + final item = _todos.removeAt(currentIndex); + _todos.insert(insertIndex, item); + // 更新 sort_order (前端维护的排序索引) + for (int i = 0; i < _todos.length; i++) { + _todos[i] = _todos[i].copyWith(sortOrder: i); + } + } + _onDragEnd(); + }); + + // 后端同步 sort_order + try { + // 只更新 sort_order 字段 + await _todoApi.updateTodo(todoId, sortOrder: insertIndex); + } catch (e) { + _rollbackAndShowError(previousTodos, '排序失败'); + } + } else { + // 跨象限移动:更新 priority + setState(() { + final index = _todos.indexWhere((t) => t.id == todoId); + if (index != -1) { + _todos[index] = _todos[index].copyWith(priority: targetQuadrant); + } + _onDragEnd(); + }); + + // 后端同步 priority + try { + await _todoApi.updateTodo(todoId, priority: targetQuadrant); + if (mounted) { + Toast.show(context, '已移动', type: ToastType.success); + } + } catch (e) { + _rollbackAndShowError(previousTodos, '移动失败'); + } + } +} + +void _rollbackAndShowError(List previousTodos, String message) { + setState(() { + _todos = previousTodos; + }); + if (mounted) { + Toast.show(context, message, type: ToastType.error); + } +} +``` + +- [ ] **Step 2: 检查 TodoApi.updateTodo 签名** + +如果 `updateTodo` 不支持 `sortOrder` 参数,需要在 `TodoApi` 中添加: + +```dart +// apps/lib/features/todo/data/todo_api.dart +Future updateTodo( + String id, { + int? priority, + int? sortOrder, // 添加此参数 + String? title, + String? description, +}) async { + // 调用后端 API 更新 +} +``` + +- [ ] **Step 3: 验证编译和功能** + +```bash +flutter analyze lib/features/todo/ui/screens/todo_quadrants_screen.dart +flutter analyze lib/features/todo/data/todo_api.dart +``` + +--- + +## Task 5: 添加插入指示器 + +**文件:** +- 修改: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart` + +- [ ] **Step 1: 添加插入位置状态和指示器 Widget** + +```dart +// 在 State 中添加 +int? _dragInsertIndex; // 拖拽插入位置 + +// 添加插入指示器 Widget +Widget _buildInsertIndicator() { + return Container( + height: 2, + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: AppColors.blue500, + borderRadius: BorderRadius.circular(1), + boxShadow: [ + BoxShadow( + color: AppColors.blue400.withValues(alpha: 0.3), + blurRadius: 4, + ), + ], + ), + ); +} +``` + +- [ ] **Step 2: 在象限内容列表中渲染插入指示器** + +在 `_buildQuadrant` 的 items 列表中,根据 `_dragInsertIndex` 插入指示器: + +```dart +// items 列表构建 +final widgets = []; +for (int i = 0; i < items.length; i++) { + if (_dragInsertIndex == i && _dragTargetQuadrant == quadrantValue) { + widgets.add(_buildInsertIndicator()); + } + widgets.add(TodoDragItem( + todo: items[i], + quadrant: quadrantValue, + onDragStarted: () => _onDragStart(items[i].id), + onDragEnd: _onDragEnd, + )); +} +``` + +- [ ] **Step 3: 验证编译** + +```bash +flutter analyze lib/features/todo/ui/screens/todo_quadrants_screen.dart +``` + +--- + +## Task 6: 添加 Spring 动画和退出/进入动画比例 + +**文件:** +- 修改: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart` + +- [ ] **Step 1: 使用真正的 Spring 动画替代 elasticOut** + +Flutter 的 `SpringSimulation` 需要使用 `AnimationController`。对于跨象限移动的 feedback,可以使用 `Curves.bounceOut` 或自定义 spring: + +```dart +// 使用 SpringSimulation 的替代方案 - CurvedAnimation + bounceOut +// 跨象限移动动画 (进入时间短,退出时间长,符合 spec) + AnimatedContainer( + duration: Duration( + milliseconds: _isDragging ? 200 : 150, // 进入 150ms < 退出 200ms + ), + curve: Curves.easeOutCubic, + // ... + ) +``` + +对于真正的 spring 物理效果,可以在 `pubspec.yaml` 添加 `flutter_animate` 包: + +```yaml +dependencies: + flutter_animate: ^4.5.0 +``` + +然后使用: +```dart +import 'package:flutter_animate/flutter_animate.dart'; + +child.animate().spring( + type: SpringType.bouncy, + duration: 300.ms, +) +``` + +- [ ] **Step 2: 验证编译** + +```bash +flutter analyze lib/features/todo/ui/screens/todo_quadrants_screen.dart +``` + +--- + +## Task 7: 集成测试 + +**文件:** +- 创建: `apps/test/features/todo/quadrant_drag_test.dart` + +- [ ] **Step 1: 编写核心功能测试** + +```dart +testWidgets('跨象限拖拽 - 成功后显示 Toast', (tester) async { + // mock TodoApi + when(() => todoApi.updateTodo(any(), priority: any(named: 'priority'))) + .thenAnswer((_) async => mockTodo); + + await pumpWidgetWithScaffolding(tester, todoApi: todoApi); + + // 执行拖拽 + final todoItem = find.text('测试待办'); + await tester.drag(todoItem, const Offset(0, 200)); // 拖到下一象限 + await tester.pumpAndSettle(); + + expect(find.text('已移动'), findsOneWidget); +}); + +testWidgets('跨象限拖拽 - 失败后回滚并显示错误', (tester) async { + when(() => todoApi.updateTodo(any(), priority: any(named: 'priority'))) + .thenThrow(Exception('网络错误')); + + await pumpWidgetWithScaffolding(tester, todoApi: todoApi); + + final todoItem = find.text('测试待办'); + await tester.drag(todoItem, const Offset(0, 200)); + await tester.pumpAndSettle(); + + expect(find.text('移动失败'), findsOneWidget); + // 验证 UI 回滚到原始状态 +}); + +testWidgets('象限内排序 - 位置正确交换', (tester) async { + // 验证排序逻辑 +}); +``` + +- [ ] **Step 2: 运行测试** + +```bash +cd apps && flutter test test/features/todo/quadrant_drag_test.dart +``` + +--- + +## 验收标准 + +- [ ] 长按待办项 150ms 后启动拖拽 +- [ ] 拖拽时卡片 scale 1.03 + 阴影,原位置显示 opacity 0.5 占位 +- [ ] 拖到目标象限时,象限边框高亮发光 (2px blue400) +- [ ] 目标位置显示 2px 蓝色插入指示器 +- [ ] 释放后卡片以平滑动画到达新位置 +- [ ] 跨象限移动后本地 UI 立即更新 +- [ ] 后端同步失败时回滚本地状态并显示错误 Toast +- [ ] 编译无错误,测试通过 diff --git a/docs/superpowers/specs/2026-03-19-calendar-reminder-unified-interaction-design.md b/docs/superpowers/specs/2026-03-19-calendar-reminder-unified-interaction-design.md new file mode 100644 index 0000000..f6fa128 --- /dev/null +++ b/docs/superpowers/specs/2026-03-19-calendar-reminder-unified-interaction-design.md @@ -0,0 +1,215 @@ +# 日历提醒统一交互设计(iOS/Android) + +## 1. 背景与问题 + +当前日历提醒模块存在以下问题: + +1. iOS 通知动作("稍后提醒"/"取消")在横幅场景下可见性不稳定,用户误判为无按钮。 +2. Android 与 iOS 的通知动作回调链路不一致,导致按钮点击在部分状态下无效。 +3. 前台提醒体验依赖系统默认样式,Android 观感较弱,不符合产品视觉语言。 +4. 提醒动作与弹窗交互代码存在历史分叉,维护成本高,且存在潜在无用旧代码残留。 + +## 2. 目标与非目标 + +### 2.1 目标 + +- 支持 App 关闭状态下的到点提醒(系统通知主触达)。 +- 统一提醒动作语义: + - `稍后提醒` = 延后 10 分钟。 + - `取消` = 归档日历事件。 +- 前台状态提供一套跨平台复用的应用内提醒面板,提升视觉质量。 +- 无论动作来自系统通知还是应用内面板,都进入同一业务执行链路。 +- 在新链路稳定后,清除无用旧代码与重复入口。 + +### 2.2 非目标 + +- 不改变提醒策略(仍按当前 reminderMinutes + 重复提醒策略)。 +- 不改动日历事件核心数据结构与后端协议。 +- 不在本次引入新的提醒类型(例如自定义延后时长、多级动作)。 + +## 3. 总体方案 + +采用“系统通知主触达 + 前台应用内面板增强”的混合方案: + +1. **系统通知层(平台差异化)** + - Android/iOS 继续使用 `flutter_local_notifications`。 + - 平台分别补齐通知动作接收能力,确保前台/后台/终止态都可触发动作。 + +2. **动作执行层(跨平台统一)** + - 以 `ReminderActionExecutor` 作为唯一动作入口。 + - 内部动作 ID 固定为:`ReminderAction.snooze10m` 与 `ReminderAction.archive`。 + - UI 文案中的“取消”仅为展示文案,内部统一映射到 `archive`。 + +3. **前台呈现层(跨平台复用)** + - 新增应用内 `ReminderActionSheet`(共享组件,遵循设计 token)。 + - 仅在应用前台触发,用于替代系统默认弹窗体验。 + +4. **展示策略(避免双提醒)** + - 前台(App active):默认只展示 `ReminderActionSheet`,不展示系统通知横幅。 + - 后台/终止态:只展示系统通知。 + +## 4. 关键设计决策 + +### 4.1 是否需要 iOS/Android 各写一套弹窗组件 + +不需要。应用内提醒组件采用一套 Flutter 共享实现。 + +需要分平台处理的是系统通知配置与回调桥接,不是应用内 UI 组件本身。 + +### 4.2 到点提醒是否继续使用“弹窗” + +主流做法是系统通知,不是纯应用内弹窗。原因: + +- App 关闭态仅系统通知可达。 +- 锁屏、通知中心具备天然可达性与系统一致性。 +- 可直接承载动作按钮(稍后提醒、取消并归档)。 + +前台场景再补应用内面板,兼顾体验与一致行为。 + +### 4.3 iOS 动作按钮显示问题 + +iOS 横幅通常不保证直接展示全部动作按钮,需展开通知查看动作。该行为属于系统 UI 规则。 + +此外 iOS 通知 category 存在缓存特性,category 变更后可能需要重装或升级 category id 才能稳定生效。 + +## 5. 模块与职责划分 + +### 5.1 保留并增强 + +- `apps/lib/core/notifications/local_notification_service.dart` + - 仅负责通知调度与平台动作回调桥接。 + - 不负责前台 UI 展示。 + - 补齐后台动作回调接入。 + +- `apps/lib/features/calendar/reminders/reminder_action_executor.dart` + - 作为唯一动作执行器。 + - 保持归档 outbox 重试机制。 + +### 5.2 新增 + +- `apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart` + - 感知 app 前后台状态。 + - 前台触发应用内提醒面板。 + - 作为前台展示唯一入口,禁止其他模块直接弹出提醒面板。 + +- `ReminderActionSheet`(共享组件) + - 展示事件摘要 + 两个动作按钮。 + - 保证与系统通知动作语义一致。 + +### 5.3 平台接入补齐 + +- Android: + - 在 `apps/android/app/src/main/AndroidManifest.xml` 增加 `ActionBroadcastReceiver`。 + - 配置并接入 `onDidReceiveBackgroundNotificationResponse`。 + - Android 13+ 先做 `POST_NOTIFICATIONS` 授权检查;未授权时降级应用内提示并记录埋点。 + - 后台回调函数必须为 top-level 且加 `@pragma('vm:entry-point')`。 + +- iOS: + - 在 `apps/ios/Runner/AppDelegate.swift` 配置 plugin registrant callback(用于后台 action isolate)。 + - 后台回调函数必须为 top-level 且加 `@pragma('vm:entry-point')`。 + - `UNNotificationCategory` 在应用启动早期完成注册(早于提醒调度)。 + - 为 category id 增加版本化策略(`calendar_reminder_v{n}`),避免缓存导致动作更新不生效。 + +## 6. 动作流转(统一) + +### 6.1 稍后提醒 + +触发源(系统通知按钮或应用内面板按钮) +-> 统一映射为 `ReminderAction.snooze10m` +-> `ReminderActionExecutor._snoozeEvent` +-> 重新计算下一次时间并 `scheduleReminderAt` + +### 6.2 取消(归档) + +触发源(系统通知按钮或应用内面板按钮) +-> 统一映射为 `ReminderAction.archive` +-> 取消本地提醒 +-> 写 outbox 并调用归档接口 +-> 成功标记 done,失败进入 retry/backoff + +### 6.3 动作回传契约(前台/后台/终止态统一) + +- 每次动作生成幂等键:`actionExecutionId = notificationId + actionId + fireTimeBucket`。 +- 执行前先查重(本地持久化幂等表);命中时直接 ACK,不重复执行业务副作用。 +- 终止态动作进入 cold-start queue 回放,按接收时间顺序处理。 +- 单条动作失败不阻塞后续动作;失败进入 retry/backoff 并可观测。 + +## 7. 旧代码收集与清理计划 + +### 7.1 旧代码清单建立 + +改造前先建立“提醒模块迁移清单”,按三类标记: + +- `保留`:仍由新架构使用。 +- `替换`:保留接口,重写实现。 +- `删除`:无引用、重复职责、历史临时逻辑。 + +迁移清单字段必须包含:`文件路径`、`符号名`、`处理决策(保留/替换/删除)`、`责任人`。 + +### 7.2 清理时机 + +- 新链路在 Android+iOS 均验证通过后,立即执行删除。 +- 不做“先保留一版再说”的长期并存。 + +### 7.3 清理范围 + +- 无效弹窗触发入口。 +- 不再使用的提醒动作映射分支。 +- 重复回调注册与过时常量(旧 action id / 旧 category id)。 +- 不再有保护价值的旧测试与旧 fixture。 + +### 7.4 清理验收 + +- 以“旧标识 0 引用”为验收标准,至少覆盖:旧 action id、旧 category id、旧入口函数名。 +- 输出固定 grep 关键字清单并逐条验收。 +- 提醒链路测试通过。 +- 删除项对应测试通过,且无悬挂 fixture/snapshot 引用。 +- 手工回归覆盖前台/后台/终止态三种状态。 + +## 8. 测试与验证 + +### 8.1 自动化 + +- 新增/更新 reminders 相关单测: + - 动作映射正确性(notification/app sheet -> executor)。 + - `archive` 的 outbox 行为与重试退避逻辑。 + - `snooze10m` 在边界时间的调度行为。 + - 同一 `actionExecutionId` 重复投递仅执行一次(幂等)。 + - 终止态 cold-start queue 回放不丢失且顺序一致。 + - 前台面板与系统通知并发触发时仅产生一次业务副作用。 + +### 8.2 手工验证矩阵 + +- Android: + - 前台:面板按钮可用。 + - 后台:通知动作可用。 + - 杀进程:通知动作可用。 + +- iOS: + - 前台:面板按钮可用。 + - 后台/锁屏:通知展开后动作可用。 + - 安装升级后 category 动作可用。 + +## 9. 风险与缓解 + +- **风险**:iOS category 缓存导致动作更新不生效。 + - **缓解**:category id 版本化 + 明确重装验证步骤。 + +- **风险**:后台动作 isolate 未正确注册导致点击丢失。 + - **缓解**:AppDelegate/Manifest 严格按插件要求配置,并做终止态回归。 + +- **风险**:前台面板与系统通知并发触发造成重复操作。 + - **缓解**:PresentationCoordinator 增加去重窗口与事件级幂等保护。 + +- **风险**:通知权限未授权导致后台提醒不可达。 + - **缓解**:启动期权限检查 + 降级提示 + 埋点追踪。 + +## 10. 完成定义(DoD) + +- App 关闭状态下,系统通知可触达并可执行两个动作。 +- `取消` 在业务上严格等价于归档。 +- 前台统一提醒面板上线,Android 样式符合项目视觉语言。 +- 动作执行链路唯一,平台仅保留桥接差异。 +- 历史无用代码完成清理,且通过验证。 +- 手工矩阵 6/6 场景通过(Android 前台/后台/杀进程 + iOS 前台/后台锁屏/升级后)。 +- 动作日志可追踪,重复执行率=0(基于 `actionExecutionId` 统计)。 diff --git a/docs/superpowers/specs/2026-03-20-todo-quadrant-drag-design.md b/docs/superpowers/specs/2026-03-20-todo-quadrant-drag-design.md new file mode 100644 index 0000000..7d52e87 --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-todo-quadrant-drag-design.md @@ -0,0 +1,113 @@ +# 待办事项四象限拖拽交互设计 + +## 概述 + +四象限待办页面支持待办项在象限内排序以及跨象限拖拽移动,同时保持与后端的数据同步。 + +## 交互设计 + +### 拖拽状态 + +| 状态 | 视觉反馈 | +|------|----------| +| 按住(未拖拽) | 卡片 scale 1.0,轻微阴影 | +| 拖拽开始 | 卡片 scale 1.03 + 阴影加深,原位置保留半透明占位框 | +| 拖拽中 | 卡片跟随手指(transform),目标象限边框高亮发光 | +| 释放-象限内排序 | 卡片平滑移动到新位置(200ms ease-out) | +| 释放-跨象限移动 | 卡片以 spring 动画弹入目标位置 | +| 操作完成 | 显示成功 Toast | + +### 动画参数 + +- **micro-interaction**: 150-300ms +- **easing**: ease-out 进入,ease-in 退出 +- **spring**: 用于跨象限移动,natural feel +- **scale feedback**: 0.95-1.05 on press +- **exit faster than enter**: 退出时长是进入的 60-70% + +### 防误触 + +- 拖拽启动延迟:100-150ms 确认是长按而非点击 +- 仅在按住并移动超过阈值后启动拖拽 + +## 数据流 + +### 状态管理 + +``` +_QuadrantScreenState + ├── List _todos + ├── DragState _dragState (null / dragging) + └── int? _dragTargetQuadrant (1, 2, 3) +``` + +### API 交互 + +1. **象限内排序**:调用 `PUT /todos/{id}` 更新 `priority` 和 `sort_order` +2. **跨象限移动**:调用 `PUT /todos/{id}` 更新 `priority` + +### 乐观更新 + +- 用户释放后立即更新本地 UI +- 后端请求失败时回滚 + 显示错误 Toast + +## 组件结构 + +``` +TodoQuadrantsScreen +├── _QuadrantDragContainer (LongPressDraggable + DragTarget) +│ ├── _QuadrantCard (象限容器) +│ └── _TodoDragItem (可拖拽待办项) +└── _DragFeedbackWidget (拖拽中的视觉反馈) +``` + +## 状态定义 + +| 状态 | 描述 | +|------|------| +| `idle` | 正常显示 | +| `dragging` | 正在拖拽某项 | +| `dragOverQuadrant` | 拖拽到某象限上方 | +| `reordering` | 正在执行排序动画 | + +## 优先级定义 + +| 象限 | Priority Value | +|------|---------------| +| 重要紧急 | 1 | +| 紧急不重要 | 3 | +| 重要不紧急 | 2 | + +## 视觉规范 + +### 卡片样式 + +- **正常**: `color: AppColors.todoCardBg`, `borderRadius: 14px` +- **拖拽中**: `opacity: 0.5` 在原位置显示占位 +- **跟随手指**: `scale: 1.03`, `shadow: elevated` + +### 象限边框高亮 + +- **正常**: `border: 1px solid {quadrantBorderColor}` +- **dragOver**: `border: 2px solid AppColors.blue400`, `boxShadow: 0 0 12px AppColors.blue200` + +### 插入指示器 + +- 高度 2px,圆角 1px +- 颜色:`AppColors.blue500` +- 位置:两个待办项之间 + +## 错误处理 + +| 场景 | 处理方式 | +|------|----------| +| 后端请求失败 | 回滚本地状态,显示错误 Toast | +| 网络断开 | 显示网络错误提示 | +| 并发冲突 | 以最新数据为准,提示用户刷新 | + +## 实现要点 + +1. 使用 Flutter `LongPressDraggable` + `DragTarget` 实现拖拽 +2. 使用 `AnimatedContainer` / `AnimatedPositioned` 实现平滑动画 +3. 乐观更新:先更新 UI,后请求后端 +4. 拖拽反馈使用 `Transform` 而非改变位置,避免 CLS diff --git a/docs/todo/calendar-reminder-migration-checklist.md b/docs/todo/calendar-reminder-migration-checklist.md new file mode 100644 index 0000000..5cf70d4 --- /dev/null +++ b/docs/todo/calendar-reminder-migration-checklist.md @@ -0,0 +1,27 @@ +# Calendar Reminder Migration Checklist + +## Scope + +本清单用于跟踪提醒模块迁移后旧代码清理,字段固定为:文件路径、符号名、处理决策、责任人、状态。 + +## Items + +| File | Symbol | Decision | Owner | Status | Notes | +|---|---|---|---|---|---| +| `apps/lib/features/calendar/reminders/models/reminder_action.dart` | `ReminderAction.cancel` | delete | agent | done | 枚举项删除,保留字符串兼容映射到 `archive` | +| `apps/lib/features/calendar/reminders/models/reminder_action.dart` | `ReminderAction.timeout30s` | delete | agent | done | 枚举项删除,保留字符串兼容映射到 `snooze10m` | +| `apps/lib/features/calendar/reminders/models/reminder_action.dart` | `ReminderAction.autoArchive` | delete | agent | done | 枚举项删除,保留字符串兼容映射到 `archive` | +| `apps/lib/features/calendar/reminders/models/reminder_action.dart` | `ReminderAction.normalized` | delete | agent | done | canonical 枚举后不再需要 | +| `apps/lib/core/notifications/local_notification_service.dart` | `_actionSnooze = 'snooze_10m'` | replace | agent | done | 统一为 `_actionSnooze = 'snooze10m'` | +| `apps/lib/core/notifications/local_notification_service.dart` | `_iosCategoryId = 'calendar_reminder_actions_v1'` | replace | agent | done | 已升级为 `calendar_reminder_v2` | + +## Verification Commands + +```bash +rg "calendar_reminder_actions_v1|ReminderAction\.cancel|_oldReminderEntry|_legacyReminderRoute" apps/lib apps/test +rg "snooze_10m" apps/lib apps/test +``` + +Expected: +- 第一条:no matches +- 第二条:仅允许出现在兼容映射分支(若存在) diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh index d267f4a..3f23b90 100755 --- a/infra/scripts/app.sh +++ b/infra/scripts/app.sh @@ -159,27 +159,24 @@ start() { ${SOCIAL_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers \ ${SOCIAL_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" - WORKER_CRITICAL_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-critical uv run taskiq worker core.taskiq.app:critical_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__CRITICAL__CONCURRENCY:-2}" - WORKER_DEFAULT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-default uv run taskiq worker core.taskiq.app:default_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY:-2}" - WORKER_BULK_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-bulk uv run taskiq worker core.taskiq.app:bulk_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY:-1}" - AUTOMATION_SCHEDULER_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=automation-scheduler uv run python -m core.runtime.cli automation-scheduler" + WORKER_AGENT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-agent uv run taskiq worker core.taskiq.app:default_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY:-2}" + WORKER_AUTOMATION_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-automation uv run taskiq worker core.taskiq.app:bulk_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY:-1}" + SCHEDULER_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=scheduler uv run python -m core.runtime.cli automation-scheduler" echo "Starting tmux workers in session '$SESSION_NAME'..." tmux new-session -d -s "$SESSION_NAME" -n web "bash -lc \"$WEB_CMD; echo '[web] exited'; exec bash\"" - tmux new-window -t "$SESSION_NAME" -n worker-critical "bash -lc \"$WORKER_CRITICAL_CMD; echo '[worker-critical] exited'; exec bash\"" - tmux new-window -t "$SESSION_NAME" -n worker-default "bash -lc \"$WORKER_DEFAULT_CMD; echo '[worker-default] exited'; exec bash\"" - tmux new-window -t "$SESSION_NAME" -n worker-bulk "bash -lc \"$WORKER_BULK_CMD; echo '[worker-bulk] exited'; exec bash\"" - tmux new-window -t "$SESSION_NAME" -n automation-scheduler "bash -lc \"$AUTOMATION_SCHEDULER_CMD; echo '[automation-scheduler] exited'; exec bash\"" + tmux new-window -t "$SESSION_NAME" -n worker-agent "bash -lc \"$WORKER_AGENT_CMD; echo '[worker-agent] exited'; exec bash\"" + tmux new-window -t "$SESSION_NAME" -n worker-automation "bash -lc \"$WORKER_AUTOMATION_CMD; echo '[worker-automation] exited'; exec bash\"" + tmux new-window -t "$SESSION_NAME" -n scheduler "bash -lc \"$SCHEDULER_CMD; echo '[scheduler] exited'; exec bash\"" echo "" echo "=== App Started ===" echo "Log files will be created in logs/ directory:" echo " - web.log, web.error.log" - echo " - worker-critical.log, worker-critical.error.log" - echo " - worker-default.log, worker-default.error.log" - echo " - worker-bulk.log, worker-bulk.error.log" - echo " - automation-scheduler.log, automation-scheduler.error.log" + echo " - worker-agent.log, worker-agent.error.log" + echo " - worker-automation.log, worker-automation.error.log" + echo " - scheduler.log, scheduler.error.log" echo "" echo "tmux attach -t $SESSION_NAME" echo "tmux list-windows -t $SESSION_NAME" @@ -202,7 +199,7 @@ stop() { kill_matching_processes "uvicorn" "uv run uvicorn app:app" kill_matching_processes "taskiq workers" "uv run taskiq worker core.taskiq.app:" - kill_matching_processes "automation scheduler" "python -m core.runtime.cli automation-scheduler" + kill_matching_processes "scheduler" "python -m core.runtime.cli automation-scheduler" kill_listening_processes "port ${WEB_PORT} listeners" "$WEB_PORT"