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) {
final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient);
initializeLocalNotifications();
final wsNotifier = ref.read(websocketStateProvider.notifier);
wsNotifier.connect();
}

View File

@@ -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<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(
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<void> subscribePushNotification(
Dio apiClient, {
bool detailedErrors = false,
}) async {
if (Platform.isLinux) {
if (!kIsWeb && Platform.isLinux) {
return;
}
await FirebaseMessaging.instance.requestPermission(

View File

@@ -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,

View File

@@ -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<ScaffoldState>();
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, Intent>{
LogicalKeySet(LogicalKeyboardKey.escape): const PopIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{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<PopIntent> {
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<Shadow>? shadows;