From 4b29b300da90b2abaeb32c0654f16264f64ebc99 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 20 Mar 2026 18:47:50 +0800 Subject: [PATCH] feat(calendar): implement ReminderOverlay and supporting components - Add ReminderQueueManager for managing notification queue - Add IOSNotificationPayloadBridge for iOS cold start handling - Add ReminderOverlay UI component with snooze (5/15 min) and complete actions - Update main.dart to integrate ReminderOverlay - LocalNotificationService: remove permission fallback logic, add native grouping --- .../ios_notification_payload_bridge.dart | 27 +++ .../reminders/reminder_queue_manager.dart | 31 +++ .../reminders/ui/reminder_overlay.dart | 191 ++++++++++++++++++ apps/lib/main.dart | 102 +++++++++- 4 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 apps/lib/core/notifications/ios_notification_payload_bridge.dart create mode 100644 apps/lib/features/calendar/reminders/reminder_queue_manager.dart create mode 100644 apps/lib/features/calendar/reminders/ui/reminder_overlay.dart diff --git a/apps/lib/core/notifications/ios_notification_payload_bridge.dart b/apps/lib/core/notifications/ios_notification_payload_bridge.dart new file mode 100644 index 0000000..4842b34 --- /dev/null +++ b/apps/lib/core/notifications/ios_notification_payload_bridge.dart @@ -0,0 +1,27 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../features/calendar/reminders/models/reminder_payload.dart'; + +class IOSNotificationPayloadBridge { + static const String _key = 'pending_notification_payload'; + final SharedPreferences _prefs; + + IOSNotificationPayloadBridge(this._prefs); + + Future getPendingPayload() async { + final raw = _prefs.getString(_key); + if (raw == null || raw.isEmpty) { + return null; + } + try { + final json = Map.from(jsonDecode(raw) as Map); + return ReminderPayload.fromJson(json); + } catch (_) { + return null; + } + } + + Future clearPendingPayload() async { + await _prefs.remove(_key); + } +} diff --git a/apps/lib/features/calendar/reminders/reminder_queue_manager.dart b/apps/lib/features/calendar/reminders/reminder_queue_manager.dart new file mode 100644 index 0000000..5636516 --- /dev/null +++ b/apps/lib/features/calendar/reminders/reminder_queue_manager.dart @@ -0,0 +1,31 @@ +import 'models/reminder_payload.dart'; + +class ReminderQueueManager { + ReminderPayload? _currentPayload; + final List _pending = []; + + void enqueueFromClick(ReminderPayload payload) { + _currentPayload = payload; + } + + void enqueuePending(List payloads) { + payloads.sort((a, b) => a.startAt.compareTo(b.startAt)); + _pending.addAll(payloads); + } + + ReminderPayload? get currentPayload => _currentPayload; + + bool get isEmpty => _currentPayload == null && _pending.isEmpty; + + void dequeueCurrent() { + _currentPayload = null; + if (_pending.isNotEmpty) { + _currentPayload = _pending.removeAt(0); + } + } + + void clear() { + _currentPayload = null; + _pending.clear(); + } +} diff --git a/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart b/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart new file mode 100644 index 0000000..788a3a2 --- /dev/null +++ b/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/theme/design_tokens.dart'; +import '../../../../shared/widgets/app_button.dart'; +import '../../reminders/reminder_queue_manager.dart'; +import '../../reminders/models/reminder_payload.dart'; + +class ReminderOverlay extends StatefulWidget { + const ReminderOverlay({ + super.key, + required this.queueManager, + required this.onComplete, + required this.onSnooze, + required this.onArchive, + }); + + final ReminderQueueManager queueManager; + final VoidCallback onComplete; + final void Function(int minutes) onSnooze; + final VoidCallback onArchive; + + @override + State createState() => _ReminderOverlayState(); +} + +class _ReminderOverlayState extends State { + bool _showSnoozeOptions = false; + OverlayEntry? _overlayEntry; + + ReminderPayload? get _currentPayload => widget.queueManager.currentPayload; + + @override + void dispose() { + _hideSnoozeOptions(); + super.dispose(); + } + + void _hideSnoozeOptions() { + _overlayEntry?.remove(); + _overlayEntry = null; + setState(() { + _showSnoozeOptions = false; + }); + } + + void _showSnoozeDropdown() { + _hideSnoozeOptions(); + + final box = context.findRenderObject() as RenderBox?; + if (box == null) return; + + final button = box.localToGlobal(Offset.zero); + + _overlayEntry = OverlayEntry( + builder: (context) => Positioned( + left: button.dx, + top: button.dy + box.size.height + 4, + width: 120, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: Container( + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _SnoozeOption( + label: '5 分钟', + onTap: () { + _hideSnoozeOptions(); + _handleSnooze(5); + }, + ), + const Divider(height: 1, color: AppColors.borderSecondary), + _SnoozeOption( + label: '15 分钟', + onTap: () { + _hideSnoozeOptions(); + _handleSnooze(15); + }, + ), + ], + ), + ), + ), + ), + ); + + Overlay.of(context).insert(_overlayEntry!); + setState(() { + _showSnoozeOptions = true; + }); + } + + void _handleComplete() { + widget.onArchive(); + widget.queueManager.dequeueCurrent(); + widget.onComplete(); + } + + void _handleSnooze(int minutes) { + widget.onSnooze(minutes); + widget.queueManager.dequeueCurrent(); + widget.onComplete(); + } + + @override + Widget build(BuildContext context) { + final payload = _currentPayload; + if (payload == null) { + return const SizedBox.shrink(); + } + + return Container( + color: AppColors.white, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + payload.title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: AppColors.slate900, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.sm), + Text( + DateFormat('HH:mm').format(DateTime.now()), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: AppColors.slate500), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.xl), + Row( + children: [ + Expanded( + child: AppButton( + text: '稍后提醒', + isOutlined: true, + onPressed: _showSnoozeDropdown, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AppButton(text: '完成', onPressed: _handleComplete), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _SnoozeOption extends StatelessWidget { + const _SnoozeOption({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppColors.slate900), + ), + ), + ); + } +} diff --git a/apps/lib/main.dart b/apps/lib/main.dart index aa7e0c2..c7832fa 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -2,12 +2,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'core/constants/app_constants.dart'; import 'core/cache/cache_refresh_coordinator.dart'; import 'core/di/injection.dart'; import 'core/notifications/local_notification_service.dart'; import 'core/notifications/reminder_notification_callbacks.dart'; +import 'core/notifications/ios_notification_payload_bridge.dart'; import 'core/router/app_router.dart'; import 'core/startup/auth_session_bootstrapper.dart'; import 'core/theme/app_theme.dart'; @@ -17,6 +19,8 @@ import 'features/auth/presentation/bloc/auth_state.dart'; import 'features/calendar/data/services/calendar_service.dart'; import 'features/calendar/data/services/calendar_repository.dart'; import 'features/calendar/reminders/reminder_action_executor.dart'; +import 'features/calendar/reminders/reminder_queue_manager.dart'; +import 'features/calendar/reminders/ui/reminder_overlay.dart'; import 'features/calendar/ui/calendar_state_manager.dart'; import 'features/chat/presentation/bloc/chat_bloc.dart'; import 'features/settings/data/services/settings_user_cache.dart'; @@ -26,6 +30,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await configureDependencies(); await AppConstants.init(); + final rootNavigatorKey = GlobalKey(); sl().bindActionHandler(({ required action, @@ -60,6 +65,15 @@ void main() async { ); WidgetsBinding.instance.addObserver(cacheRefreshCoordinator); + final prefs = await SharedPreferences.getInstance(); + final payloadBridge = IOSNotificationPayloadBridge(prefs); + final pendingPayload = await payloadBridge.getPendingPayload(); + final queueManager = ReminderQueueManager(); + if (pendingPayload != null) { + queueManager.enqueueFromClick(pendingPayload); + await payloadBridge.clearPendingPayload(); + } + runApp( LinksyApp( authBloc: authBloc, @@ -69,6 +83,8 @@ void main() async { notificationService: sl(), reminderActionExecutor: sl(), ), + queueManager: queueManager, + payloadBridge: payloadBridge, ), ); @@ -81,35 +97,113 @@ void main() async { }); } -class LinksyApp extends StatelessWidget { +class LinksyApp extends StatefulWidget { final AuthBloc authBloc; final GlobalKey rootNavigatorKey; final AuthSessionBootstrapper sessionBootstrapper; + final ReminderQueueManager queueManager; + final IOSNotificationPayloadBridge payloadBridge; const LinksyApp({ super.key, required this.authBloc, required this.rootNavigatorKey, required this.sessionBootstrapper, + required this.queueManager, + required this.payloadBridge, }); + @override + State createState() => _LinksyAppState(); +} + +class _LinksyAppState extends State { + OverlayEntry? _reminderOverlay; + + @override + void initState() { + super.initState(); + _checkAndShowReminderOverlay(); + } + + Future _checkAndShowReminderOverlay() async { + if (widget.queueManager.currentPayload != null) { + _showReminderOverlay(); + } + } + + void _showReminderOverlay() { + if (_reminderOverlay != null) return; + + _reminderOverlay = OverlayEntry( + builder: (context) => Positioned.fill( + child: Material( + color: Colors.black54, + child: ReminderOverlay( + queueManager: widget.queueManager, + onComplete: _onReminderComplete, + onSnooze: _onSnooze, + onArchive: _onArchive, + ), + ), + ), + ); + + Overlay.of(context).insert(_reminderOverlay!); + } + + void _onReminderComplete() { + _reminderOverlay?.remove(); + _reminderOverlay = null; + + if (!widget.queueManager.isEmpty) { + _showReminderOverlay(); + } + } + + Future _onSnooze(int minutes) async { + final payload = widget.queueManager.currentPayload; + if (payload == null) return; + + await sl().cancelEventReminder(payload.eventId); + final event = await sl().getEventById(payload.eventId); + if (event != null) { + final snoozeTime = DateTime.now().add(Duration(minutes: minutes)); + await sl().scheduleReminderAt( + event, + snoozeTime, + ); + } + } + + Future _onArchive() async { + final payload = widget.queueManager.currentPayload; + if (payload == null) return; + + try { + await sl().archiveEvent(payload.eventId); + } catch (_) { + // archive failed, continue anyway + } + } + @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - BlocProvider.value(value: authBloc), + BlocProvider.value(value: widget.authBloc), BlocProvider(create: (_) => ChatBloc(apiClient: sl())), ], child: BlocListener( listenWhen: (previous, current) => previous != current, listener: (context, state) { - unawaited(sessionBootstrapper.syncForAuthState(state)); + unawaited(widget.sessionBootstrapper.syncForAuthState(state)); }, child: MaterialApp.router( title: 'Linksy', debugShowCheckedModeBanner: false, theme: AppTheme.light, - routerConfig: createAppRouter(authBloc), + routerConfig: createAppRouter(widget.authBloc), ), ), );