System notification for desktop and android

This commit is contained in:
2025-09-06 12:59:23 +08:00
parent 580d9fd979
commit 9f2f1c0848
9 changed files with 255 additions and 68 deletions

View File

@@ -223,6 +223,7 @@ class IslandApp extends HookConsumerWidget {
if (user.value != null) { if (user.value != null) {
final apiClient = ref.read(apiClientProvider); final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient); subscribePushNotification(apiClient);
initializeLocalNotifications();
final wsNotifier = ref.read(websocketStateProvider.notifier); final wsNotifier = ref.read(websocketStateProvider.notifier);
wsNotifier.connect(); wsNotifier.connect();
} }

View File

@@ -6,6 +6,7 @@ import 'package:dio/dio.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:island/main.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:top_snackbar_flutter/top_snack_bar.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
AppLifecycleState _appLifecycleState = AppLifecycleState.resumed;
void _onAppLifecycleChanged(AppLifecycleState state) {
_appLifecycleState = state;
}
Future<void> 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<WebSocketPacket> setupNotificationListener( StreamSubscription<WebSocketPacket> setupNotificationListener(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
) { ) {
final ws = ref.watch(websocketProvider); final ws = ref.watch(websocketProvider);
return ws.dataStream.listen((pkt) { return ws.dataStream.listen((pkt) async {
if (pkt.type == "notifications.new") { if (pkt.type == "notifications.new") {
final notification = SnNotification.fromJson(pkt.data!); final notification = SnNotification.fromJson(pkt.data!);
showTopSnackBar( if (_appLifecycleState == AppLifecycleState.resumed) {
globalOverlay.currentState!, // App is focused, show in-app notification
Center( log(
child: ConstrainedBox( '[Notification] Showing in-app notification: ${notification.title}',
constraints: const BoxConstraints(maxWidth: 480), );
child: NotificationCard(notification: notification), showTopSnackBar(
globalOverlay.currentState!,
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: NotificationCard(notification: notification),
),
), ),
), onTap: () {
onTap: () { if (notification.meta['action_uri'] != null) {
if (notification.meta['action_uri'] != null) { var uri = notification.meta['action_uri'] as String;
var uri = notification.meta['action_uri'] as String; if (uri.startsWith('/')) {
if (uri.startsWith('/')) { // In-app routes
// In-app routes rootNavigatorKey.currentContext?.push(
rootNavigatorKey.currentContext?.push( notification.meta['action_uri'],
notification.meta['action_uri'], );
); } else {
} else { // External URLs
// External URLs launchUrlString(uri);
launchUrlString(uri); }
} }
} },
}, onDismissed: () {},
onDismissed: () {}, dismissType: DismissType.onSwipe,
dismissType: DismissType.onSwipe, displayDuration: const Duration(seconds: 5),
displayDuration: const Duration(seconds: 5), snackBarPosition: SnackBarPosition.top,
snackBarPosition: SnackBarPosition.top, padding: EdgeInsets.only(
padding: EdgeInsets.only( left: 16,
left: 16, right: 16,
right: 16, top:
top: (!kIsWeb &&
(!kIsWeb && (Platform.isMacOS ||
(Platform.isMacOS || Platform.isWindows ||
Platform.isWindows || Platform.isLinux))
Platform.isLinux)) ? 28
? 28 // ignore: use_build_context_synchronously
// ignore: use_build_context_synchronously : MediaQuery.of(context).padding.top + 16,
: MediaQuery.of(context).padding.top + 16, bottom: 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<void> subscribePushNotification(
Dio apiClient, { Dio apiClient, {
bool detailedErrors = false, bool detailedErrors = false,
}) async { }) async {
if (Platform.isLinux) { if (!kIsWeb && Platform.isLinux) {
return; return;
} }
await FirebaseMessaging.instance.requestPermission( await FirebaseMessaging.instance.requestPermission(

View File

@@ -17,8 +17,8 @@ class NotificationCard extends HookConsumerWidget {
return Card( return Card(
elevation: 4, elevation: 4,
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)), borderRadius: BorderRadius.all(Radius.circular(8)),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -3,6 +3,7 @@ import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -153,7 +154,7 @@ class _WindowSizeObserver extends WidgetsBindingObserver {
final rootScaffoldKey = GlobalKey<ScaffoldState>(); final rootScaffoldKey = GlobalKey<ScaffoldState>();
class AppScaffold extends StatelessWidget { class AppScaffold extends HookConsumerWidget {
final Widget? body; final Widget? body;
final PreferredSizeWidget? bottomNavigationBar; final PreferredSizeWidget? bottomNavigationBar;
final PreferredSizeWidget? bottomSheet; final PreferredSizeWidget? bottomSheet;
@@ -186,7 +187,14 @@ class AppScaffold extends StatelessWidget {
}); });
@override @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 appBarHeight = appBar?.preferredSize.height ?? 0;
final safeTop = MediaQuery.of(context).padding.top; final safeTop = MediaQuery.of(context).padding.top;
@@ -201,29 +209,59 @@ class AppScaffold extends StatelessWidget {
], ],
); );
return Scaffold( return Shortcuts(
extendBody: extendBody ?? true, shortcuts: <LogicalKeySet, Intent>{
extendBodyBehindAppBar: true, LogicalKeySet(LogicalKeyboardKey.escape): const PopIntent(),
backgroundColor: },
noBackground child: Actions(
? Colors.transparent actions: <Type, Action<Intent>>{PopIntent: PopAction(context)},
: Theme.of(context).scaffoldBackgroundColor, child: Focus(
body: focusNode: focusNode,
noBackground ? content : AppBackground(isRoot: true, child: content), child: Scaffold(
appBar: appBar, extendBody: extendBody ?? true,
bottomNavigationBar: bottomNavigationBar, extendBodyBehindAppBar: true,
bottomSheet: bottomSheet, backgroundColor:
drawer: drawer, noBackground
endDrawer: endDrawer, ? Colors.transparent
floatingActionButton: floatingActionButton, : Theme.of(context).scaffoldBackgroundColor,
floatingActionButtonAnimator: floatingActionButtonAnimator, body:
floatingActionButtonLocation: floatingActionButtonLocation, noBackground
onDrawerChanged: onDrawerChanged, ? content
onEndDrawerChanged: onEndDrawerChanged, : 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<PopIntent> {
final BuildContext context;
PopAction(this.context);
@override
void invoke(PopIntent intent) {
if (context.canPop()) {
context.pop();
}
}
}
class PageBackButton extends StatelessWidget { class PageBackButton extends StatelessWidget {
final Color? color; final Color? color;
final List<Shadow>? shadows; final List<Shadow>? shadows;

View File

@@ -16,6 +16,7 @@ import firebase_core
import firebase_crashlytics import firebase_crashlytics
import firebase_messaging import firebase_messaging
import flutter_inappwebview_macos import flutter_inappwebview_macos
import flutter_local_notifications
import flutter_platform_alert import flutter_platform_alert
import flutter_secure_storage_macos import flutter_secure_storage_macos
import flutter_timezone import flutter_timezone
@@ -54,6 +55,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterPlatformAlertPlugin.register(with: registry.registrar(forPlugin: "FlutterPlatformAlertPlugin")) FlutterPlatformAlertPlugin.register(with: registry.registrar(forPlugin: "FlutterPlatformAlertPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))

View File

@@ -100,6 +100,8 @@ PODS:
- flutter_inappwebview_macos (0.0.1): - flutter_inappwebview_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
- OrderedSet (~> 6.0.3) - OrderedSet (~> 6.0.3)
- flutter_local_notifications (0.0.1):
- FlutterMacOS
- flutter_platform_alert (0.0.1): - flutter_platform_alert (0.0.1):
- FlutterMacOS - FlutterMacOS
- flutter_secure_storage_macos (6.1.3): - flutter_secure_storage_macos (6.1.3):
@@ -260,6 +262,7 @@ DEPENDENCIES:
- firebase_crashlytics (from `Flutter/ephemeral/.symlinks/plugins/firebase_crashlytics/macos`) - firebase_crashlytics (from `Flutter/ephemeral/.symlinks/plugins/firebase_crashlytics/macos`)
- firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`) - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`)
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/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_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_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
- flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/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 :path: Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos
flutter_inappwebview_macos: flutter_inappwebview_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/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: flutter_platform_alert:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos :path: Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos
flutter_secure_storage_macos: flutter_secure_storage_macos:
@@ -411,6 +416,7 @@ SPEC CHECKSUMS:
FirebaseRemoteConfigInterop: 0896fd52ab72586a355c8f389ff85aaa9e5375e1 FirebaseRemoteConfigInterop: 0896fd52ab72586a355c8f389ff85aaa9e5375e1
FirebaseSessions: f4692789e770bec66ce17d772c0e9561c4f11737 FirebaseSessions: f4692789e770bec66ce17d772c0e9561c4f11737
flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284 flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284
flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54
flutter_timezone: d59eea86178cbd7943cd2431cc2eaa9850f935d8 flutter_timezone: d59eea86178cbd7943cd2431cc2eaa9850f935d8

View File

@@ -902,6 +902,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_localizations:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -1505,10 +1537,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: material_symbols_icons name: material_symbols_icons
sha256: b1342194e859b2774f920b484c46f54a37a845488e23d570385fbe3ede92ee9f sha256: "2cfd19bf1c3016b0de7298eb3d3444fcb6ef093d934deb870ceb946af89cfa58"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2867.0" version: "4.2872.0"
media_kit: media_kit:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -2310,10 +2342,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqlparser name: sqlparser
sha256: "7c859c803cf7e9a84d6db918bac824545045692bbe94a6386bd3a45132235d09" sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.41.1" version: "0.41.2"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:

View File

@@ -83,7 +83,7 @@ dependencies:
flutter_udid: ^4.0.0 flutter_udid: ^4.0.0
firebase_core: ^4.1.0 firebase_core: ^4.1.0
web_socket_channel: ^3.0.3 web_socket_channel: ^3.0.3
material_symbols_icons: ^4.2867.0 material_symbols_icons: ^4.2872.0
drift: ^2.28.1 drift: ^2.28.1
drift_flutter: ^0.2.5 drift_flutter: ^0.2.5
path: ^1.9.1 path: ^1.9.1
@@ -140,6 +140,7 @@ dependencies:
file_saver: ^0.3.1 file_saver: ^0.3.1
tray_manager: ^0.5.1 tray_manager: ^0.5.1
flutter_webrtc: ^1.1.0 flutter_webrtc: ^1.1.0
flutter_local_notifications: ^19.4.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -32,6 +32,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
croppy croppy
flutter_local_notifications_windows
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)