From 9f2f1c0848ce0e4310d592cbe069b4e0100a6bda Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 6 Sep 2025 12:59:23 +0800 Subject: [PATCH] :sparkles: System notification for desktop and android --- lib/main.dart | 1 + lib/services/notify.dart | 186 ++++++++++++++---- lib/widgets/app_notification.dart | 4 +- lib/widgets/app_scaffold.dart | 80 ++++++-- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 6 + pubspec.lock | 40 +++- pubspec.yaml | 3 +- windows/flutter/generated_plugins.cmake | 1 + 9 files changed, 255 insertions(+), 68 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 673683d5..18d79d09 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -223,6 +223,7 @@ class IslandApp extends HookConsumerWidget { if (user.value != null) { final apiClient = ref.read(apiClientProvider); subscribePushNotification(apiClient); + initializeLocalNotifications(); final wsNotifier = ref.read(websocketStateProvider.notifier); wsNotifier.connect(); } diff --git a/lib/services/notify.dart b/lib/services/notify.dart index 36d820fc..dbb3db58 100644 --- a/lib/services/notify.dart +++ b/lib/services/notify.dart @@ -6,6 +6,7 @@ import 'package:dio/dio.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:island/main.dart'; @@ -16,54 +17,159 @@ import 'package:island/widgets/app_notification.dart'; import 'package:top_snackbar_flutter/top_snack_bar.dart'; import 'package:url_launcher/url_launcher_string.dart'; +final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + +AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; + +void _onAppLifecycleChanged(AppLifecycleState state) { + _appLifecycleState = state; +} + +Future initializeLocalNotifications() async { + const AndroidInitializationSettings initializationSettingsAndroid = + AndroidInitializationSettings('@mipmap/ic_launcher'); + + const DarwinInitializationSettings initializationSettingsIOS = + DarwinInitializationSettings(); + + const DarwinInitializationSettings initializationSettingsMacOS = + DarwinInitializationSettings(); + + const LinuxInitializationSettings initializationSettingsLinux = + LinuxInitializationSettings(defaultActionName: 'Open notification'); + + const WindowsInitializationSettings initializationSettingsWindows = + WindowsInitializationSettings( + appName: 'Island', + appUserModelId: 'dev.solsynth.solian', + guid: 'dev.solsynth.solian', + ); + + const InitializationSettings initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + iOS: initializationSettingsIOS, + macOS: initializationSettingsMacOS, + linux: initializationSettingsLinux, + windows: initializationSettingsWindows, + ); + + await flutterLocalNotificationsPlugin.initialize( + initializationSettings, + onDidReceiveNotificationResponse: (NotificationResponse response) async { + final payload = response.payload; + if (payload != null) { + if (payload.startsWith('/')) { + // In-app routes + rootNavigatorKey.currentContext?.push(payload); + } else { + // External URLs + launchUrlString(payload); + } + } + }, + ); + + WidgetsBinding.instance.addObserver( + LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged), + ); +} + +class LifecycleEventHandler extends WidgetsBindingObserver { + final void Function(AppLifecycleState) onAppLifecycleChanged; + + LifecycleEventHandler({required this.onAppLifecycleChanged}); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + onAppLifecycleChanged(state); + } +} + StreamSubscription setupNotificationListener( BuildContext context, WidgetRef ref, ) { final ws = ref.watch(websocketProvider); - return ws.dataStream.listen((pkt) { + return ws.dataStream.listen((pkt) async { if (pkt.type == "notifications.new") { final notification = SnNotification.fromJson(pkt.data!); - showTopSnackBar( - globalOverlay.currentState!, - Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: NotificationCard(notification: notification), + if (_appLifecycleState == AppLifecycleState.resumed) { + // App is focused, show in-app notification + log( + '[Notification] Showing in-app notification: ${notification.title}', + ); + showTopSnackBar( + globalOverlay.currentState!, + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: NotificationCard(notification: notification), + ), ), - ), - onTap: () { - if (notification.meta['action_uri'] != null) { - var uri = notification.meta['action_uri'] as String; - if (uri.startsWith('/')) { - // In-app routes - rootNavigatorKey.currentContext?.push( - notification.meta['action_uri'], - ); - } else { - // External URLs - launchUrlString(uri); + onTap: () { + if (notification.meta['action_uri'] != null) { + var uri = notification.meta['action_uri'] as String; + if (uri.startsWith('/')) { + // In-app routes + rootNavigatorKey.currentContext?.push( + notification.meta['action_uri'], + ); + } else { + // External URLs + launchUrlString(uri); + } } - } - }, - onDismissed: () {}, - dismissType: DismissType.onSwipe, - displayDuration: const Duration(seconds: 5), - snackBarPosition: SnackBarPosition.top, - padding: EdgeInsets.only( - left: 16, - right: 16, - top: - (!kIsWeb && - (Platform.isMacOS || - Platform.isWindows || - Platform.isLinux)) - ? 28 - // ignore: use_build_context_synchronously - : MediaQuery.of(context).padding.top + 16, - bottom: 16, - ), - ); + }, + onDismissed: () {}, + dismissType: DismissType.onSwipe, + displayDuration: const Duration(seconds: 5), + snackBarPosition: SnackBarPosition.top, + padding: EdgeInsets.only( + left: 16, + right: 16, + top: + (!kIsWeb && + (Platform.isMacOS || + Platform.isWindows || + Platform.isLinux)) + ? 28 + // ignore: use_build_context_synchronously + : MediaQuery.of(context).padding.top + 16, + bottom: 16, + ), + ); + } else { + // App is in background, show system notification (only on supported platforms) + if (!kIsWeb && !Platform.isIOS) { + log( + '[Notification] Showing system notification: ${notification.title}', + ); + const AndroidNotificationDetails androidNotificationDetails = + AndroidNotificationDetails( + 'channel_id', + 'channel_name', + channelDescription: 'channel_description', + importance: Importance.max, + priority: Priority.high, + ticker: 'ticker', + ); + const NotificationDetails notificationDetails = NotificationDetails( + android: androidNotificationDetails, + ); + await flutterLocalNotificationsPlugin.show( + 0, + notification.title, + notification.content, + notificationDetails, + payload: notification.meta['action_uri'] as String?, + ); + } else { + log( + '[Notification] Skipping system notification for unsupported platform: ${notification.title}', + ); + } + } } }); } @@ -72,7 +178,7 @@ Future subscribePushNotification( Dio apiClient, { bool detailedErrors = false, }) async { - if (Platform.isLinux) { + if (!kIsWeb && Platform.isLinux) { return; } await FirebaseMessaging.instance.requestPermission( diff --git a/lib/widgets/app_notification.dart b/lib/widgets/app_notification.dart index ae2545ac..d940e75c 100644 --- a/lib/widgets/app_notification.dart +++ b/lib/widgets/app_notification.dart @@ -17,8 +17,8 @@ class NotificationCard extends HookConsumerWidget { return Card( elevation: 4, margin: const EdgeInsets.only(bottom: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index d6811ee4..fa719bf9 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -3,6 +3,7 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -153,7 +154,7 @@ class _WindowSizeObserver extends WidgetsBindingObserver { final rootScaffoldKey = GlobalKey(); -class AppScaffold extends StatelessWidget { +class AppScaffold extends HookConsumerWidget { final Widget? body; final PreferredSizeWidget? bottomNavigationBar; final PreferredSizeWidget? bottomSheet; @@ -186,7 +187,14 @@ class AppScaffold extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final focusNode = useFocusNode(); + + useEffect(() { + focusNode.requestFocus(); + return null; + }, []); + final appBarHeight = appBar?.preferredSize.height ?? 0; final safeTop = MediaQuery.of(context).padding.top; @@ -201,29 +209,59 @@ class AppScaffold extends StatelessWidget { ], ); - return Scaffold( - extendBody: extendBody ?? true, - extendBodyBehindAppBar: true, - backgroundColor: - noBackground - ? Colors.transparent - : Theme.of(context).scaffoldBackgroundColor, - body: - noBackground ? content : AppBackground(isRoot: true, child: content), - appBar: appBar, - bottomNavigationBar: bottomNavigationBar, - bottomSheet: bottomSheet, - drawer: drawer, - endDrawer: endDrawer, - floatingActionButton: floatingActionButton, - floatingActionButtonAnimator: floatingActionButtonAnimator, - floatingActionButtonLocation: floatingActionButtonLocation, - onDrawerChanged: onDrawerChanged, - onEndDrawerChanged: onEndDrawerChanged, + return Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.escape): const PopIntent(), + }, + child: Actions( + actions: >{PopIntent: PopAction(context)}, + child: Focus( + focusNode: focusNode, + child: Scaffold( + extendBody: extendBody ?? true, + extendBodyBehindAppBar: true, + backgroundColor: + noBackground + ? Colors.transparent + : Theme.of(context).scaffoldBackgroundColor, + body: + noBackground + ? content + : AppBackground(isRoot: true, child: content), + appBar: appBar, + bottomNavigationBar: bottomNavigationBar, + bottomSheet: bottomSheet, + drawer: drawer, + endDrawer: endDrawer, + floatingActionButton: floatingActionButton, + floatingActionButtonAnimator: floatingActionButtonAnimator, + floatingActionButtonLocation: floatingActionButtonLocation, + onDrawerChanged: onDrawerChanged, + onEndDrawerChanged: onEndDrawerChanged, + ), + ), + ), ); } } +class PopIntent extends Intent { + const PopIntent(); +} + +class PopAction extends Action { + final BuildContext context; + + PopAction(this.context); + + @override + void invoke(PopIntent intent) { + if (context.canPop()) { + context.pop(); + } + } +} + class PageBackButton extends StatelessWidget { final Color? color; final List? shadows; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 06e5776c..bc5ed6a0 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -16,6 +16,7 @@ import firebase_core import firebase_crashlytics import firebase_messaging import flutter_inappwebview_macos +import flutter_local_notifications import flutter_platform_alert import flutter_secure_storage_macos import flutter_timezone @@ -54,6 +55,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterPlatformAlertPlugin.register(with: registry.registrar(forPlugin: "FlutterPlatformAlertPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index c90f7b89..f79c1ea8 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -100,6 +100,8 @@ PODS: - flutter_inappwebview_macos (0.0.1): - FlutterMacOS - OrderedSet (~> 6.0.3) + - flutter_local_notifications (0.0.1): + - FlutterMacOS - flutter_platform_alert (0.0.1): - FlutterMacOS - flutter_secure_storage_macos (6.1.3): @@ -260,6 +262,7 @@ DEPENDENCIES: - firebase_crashlytics (from `Flutter/ephemeral/.symlinks/plugins/firebase_crashlytics/macos`) - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`) - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) + - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - flutter_platform_alert (from `Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`) @@ -335,6 +338,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos flutter_inappwebview_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos + flutter_local_notifications: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos flutter_platform_alert: :path: Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos flutter_secure_storage_macos: @@ -411,6 +416,7 @@ SPEC CHECKSUMS: FirebaseRemoteConfigInterop: 0896fd52ab72586a355c8f389ff85aaa9e5375e1 FirebaseSessions: f4692789e770bec66ce17d772c0e9561c4f11737 flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d + flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0 flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284 flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 flutter_timezone: d59eea86178cbd7943cd2431cc2eaa9850f935d8 diff --git a/pubspec.lock b/pubspec.lock index a232240c..23f05fe6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -902,6 +902,38 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: a9966c850de5e445331b854fa42df96a8020066d67f125a5964cbc6556643f68 + url: "https://pub.dev" + source: hosted + version: "19.4.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: ed46d7ae4ec9d19e4c8fa2badac5fe27ba87a3fe387343ce726f927af074ec98 + url: "https://pub.dev" + source: hosted + version: "1.0.2" flutter_localizations: dependency: transitive description: flutter @@ -1505,10 +1537,10 @@ packages: dependency: "direct main" description: name: material_symbols_icons - sha256: b1342194e859b2774f920b484c46f54a37a845488e23d570385fbe3ede92ee9f + sha256: "2cfd19bf1c3016b0de7298eb3d3444fcb6ef093d934deb870ceb946af89cfa58" url: "https://pub.dev" source: hosted - version: "4.2867.0" + version: "4.2872.0" media_kit: dependency: "direct main" description: @@ -2310,10 +2342,10 @@ packages: dependency: transitive description: name: sqlparser - sha256: "7c859c803cf7e9a84d6db918bac824545045692bbe94a6386bd3a45132235d09" + sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67" url: "https://pub.dev" source: hosted - version: "0.41.1" + version: "0.41.2" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d553b01b..d8b1f951 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -83,7 +83,7 @@ dependencies: flutter_udid: ^4.0.0 firebase_core: ^4.1.0 web_socket_channel: ^3.0.3 - material_symbols_icons: ^4.2867.0 + material_symbols_icons: ^4.2872.0 drift: ^2.28.1 drift_flutter: ^0.2.5 path: ^1.9.1 @@ -140,6 +140,7 @@ dependencies: file_saver: ^0.3.1 tray_manager: ^0.5.1 flutter_webrtc: ^1.1.0 + flutter_local_notifications: ^19.4.1 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index f0c7452a..e43e01bd 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -32,6 +32,7 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST croppy + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES)