diff --git a/packages/neon/lib/src/app.dart b/packages/neon/lib/src/app.dart index 87c6af5a..6c452510 100644 --- a/packages/neon/lib/src/app.dart +++ b/packages/neon/lib/src/app.dart @@ -21,12 +21,16 @@ class NeonApp extends StatefulWidget { } // ignore: prefer_mixin -class _NeonAppState extends State with WidgetsBindingObserver { +class _NeonAppState extends State with WidgetsBindingObserver, tray.TrayListener, WindowListener { + final _appRegex = RegExp(r'^app_([a-z]+)$', multiLine: true); final _navigatorKey = GlobalKey(); - CoreServerCapabilities_Ocs_Data_Capabilities_Theming? _userTheme; - final _platformBrightness = BehaviorSubject.seeded( - WidgetsBinding.instance.window.platformBrightness, - ); + late NeonPlatform _platform; + late GlobalOptions _globalOptions; + late AccountsBloc _accountsBloc; + + CoreServerCapabilities_Ocs_Data_Capabilities_Theming? _nextcloudTheme; + final _platformBrightness = BehaviorSubject.seeded(WidgetsBinding.instance.window.platformBrightness); + Rect? _lastBounds; @override void didChangePlatformBrightness() { @@ -39,9 +43,19 @@ class _NeonAppState extends State with WidgetsBindingObserver { void initState() { super.initState(); + _platform = Provider.of(context, listen: false); + _globalOptions = Provider.of(context, listen: false); + _accountsBloc = RxBlocProvider.of(context); + WidgetsBinding.instance.addObserver(this); + if (_platform.canUseSystemTray) { + tray.trayManager.addListener(this); + } + if (_platform.canUseWindowManager) { + windowManager.addListener(this); + } - WidgetsBinding.instance.addPostFrameCallback((final _) { + WidgetsBinding.instance.addPostFrameCallback((final _) async { widget.accountsBloc.activeAccount.listen((final activeAccount) async { FlutterNativeSplash.remove(); @@ -58,9 +72,9 @@ class _NeonAppState extends State with WidgetsBindingObserver { ); Widget builder(final context) => HomePage( account: activeAccount, - onThemeChanged: (final theme) { + onThemeChanged: (final nextcloudTheme) { setState(() { - _userTheme = theme; + _nextcloudTheme = nextcloudTheme; }); }, ); @@ -78,12 +92,201 @@ class _NeonAppState extends State with WidgetsBindingObserver { ); } }); + final localizations = await appLocalizationsFromSystem(); + + if (!mounted) { + return; + } + final appImplementations = Provider.of>(context, listen: false); + if (_platform.canUseQuickActions) { + const quickActions = QuickActions(); + await quickActions.setShortcutItems( + appImplementations + .map( + (final app) => ShortcutItem( + type: 'app_${app.id}', + localizedTitle: app.nameFromLocalization(localizations), + icon: 'app_${app.id}', + ), + ) + .toList(), + ); + await quickActions.initialize(_handleShortcut); + } + + if (_platform.canUseWindowManager) { + await windowManager.setPreventClose(true); + + if (_globalOptions.startupMinimized.value) { + await _saveAndMinimizeWindow(); + } + } + + if (_platform.canUseSystemTray) { + _globalOptions.systemTrayEnabled.stream.listen((final enabled) async { + if (enabled) { + // TODO: This works on Linux, but maybe not on macOS or Windows + await tray.trayManager.setIcon('assets/logo_neon.svg'); + if (mounted) { + await tray.trayManager.setContextMenu( + tray.Menu( + items: [ + for (final app in appImplementations) ...[ + tray.MenuItem( + key: 'app_${app.id}', + label: app.nameFromLocalization(localizations), + // TODO: Add icons which should work on macOS and Windows + ), + ], + tray.MenuItem.separator(), + tray.MenuItem( + key: 'show_hide', + label: localizations.showSlashHide, + ), + tray.MenuItem( + key: 'exit', + label: localizations.exit, + ), + ], + ), + ); + } + } else { + await tray.trayManager.destroy(); + } + }); + } + + if (_platform.canUsePushNotifications) { + final localNotificationsPlugin = await PushUtils.initLocalNotifications(); + Global.onPushNotificationReceived = (final accountID) async { + final account = RxBlocProvider.of(context).accounts.value.find(accountID); + if (account == null) { + return; + } + final appImplementation = Provider.of>(context, listen: false) + .singleWhere((final a) => a.id == 'notifications'); + _accountsBloc.getAppsBloc(account).getAppBloc(appImplementation).refresh(); + }; + Global.onPushNotificationClicked = (final pushNotificationWithAccountID) async { + final allAppImplementations = Provider.of>(context, listen: false); + + final matchingAppImplementations = + allAppImplementations.where((final a) => a.id == pushNotificationWithAccountID.notification.subject.app); + + late AppImplementation appImplementation; + if (matchingAppImplementations.isNotEmpty) { + appImplementation = matchingAppImplementations.single; + } else { + appImplementation = allAppImplementations.singleWhere((final a) => a.id == 'notifications'); + } + + final account = + RxBlocProvider.of(context).accounts.value.find(pushNotificationWithAccountID.accountID); + if (account == null) { + return; + } + + _accountsBloc.setActiveAccount(account); + if (appImplementation.id != 'notifications') { + _accountsBloc + .getAppsBloc(account) + .getAppBloc(appImplementation) + .deleteNotification(pushNotificationWithAccountID.notification.subject.nid!); + } + await _openAppFromExternal(account, appImplementation.id); + }; + + final details = await localNotificationsPlugin.getNotificationAppLaunchDetails(); + if (details != null && details.didNotificationLaunchApp && details.notificationResponse?.payload != null) { + await Global.onPushNotificationClicked!( + PushNotificationWithAccountID.fromJson( + json.decode(details.notificationResponse!.payload!) as Map, + ), + ); + } + } }); } + @override + void onTrayMenuItemClick(final tray.MenuItem menuItem) { + if (menuItem.key != null) { + unawaited(_handleShortcut(menuItem.key!)); + } + } + + @override + Future onWindowClose() async { + if (_globalOptions.startupMinimizeInsteadOfExit.value) { + await _saveAndMinimizeWindow(); + } else { + await windowManager.destroy(); + } + } + + Future _handleShortcut(final String shortcutType) async { + if (shortcutType == 'show_hide') { + if (_platform.canUseWindowManager) { + if (await windowManager.isVisible()) { + await _saveAndMinimizeWindow(); + } else { + await _showAndRestoreWindow(); + } + } + return; + } + if (shortcutType == 'exit') { + exit(0); + } + + final matches = _appRegex.allMatches(shortcutType).toList(); + if (matches.isNotEmpty) { + final activeAccount = _accountsBloc.activeAccount.valueOrNull; + if (activeAccount == null) { + return; + } + await _openAppFromExternal(activeAccount, matches[0].group(1)!); + } + } + + Future _openAppFromExternal(final Account account, final String id) async { + _accountsBloc.getAppsBloc(account).setActiveApp(id); + _navigatorKey.currentState!.popUntil((final route) => route.settings.name == 'home'); + await _showAndRestoreWindow(); + } + + Future _saveAndMinimizeWindow() async { + _lastBounds = await windowManager.getBounds(); + if (_globalOptions.systemTrayEnabled.value && _globalOptions.systemTrayHideToTrayWhenMinimized.value) { + await windowManager.hide(); + } else { + await windowManager.minimize(); + } + } + + Future _showAndRestoreWindow() async { + if (!_platform.canUseWindowManager) { + return; + } + + final wasVisible = await windowManager.isVisible(); + await windowManager.show(); + await windowManager.focus(); + if (_lastBounds != null && !wasVisible) { + await windowManager.setBounds(_lastBounds); + } + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); + if (_platform.canUseSystemTray) { + tray.trayManager.removeListener(this); + } + if (_platform.canUseWindowManager) { + windowManager.removeListener(this); + } unawaited(_platformBrightness.close()); super.dispose(); @@ -110,7 +313,7 @@ class _NeonAppState extends State with WidgetsBindingObserver { supportedLocales: AppLocalizations.supportedLocales, navigatorKey: _navigatorKey, theme: getThemeFromNextcloudTheme( - _userTheme, + _nextcloudTheme, themeMode, platformBrightnessSnapshot.data!, oledAsDark: themeOLEDAsDark, diff --git a/packages/neon/lib/src/apps/notifications/app.dart b/packages/neon/lib/src/apps/notifications/app.dart index 6de988d1..8e2197cd 100644 --- a/packages/neon/lib/src/apps/notifications/app.dart +++ b/packages/neon/lib/src/apps/notifications/app.dart @@ -1,9 +1,11 @@ library notifications; import 'package:flutter/material.dart'; +import 'package:flutter_rx_bloc/flutter_rx_bloc.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:neon/l10n/localizations.dart'; import 'package:neon/src/apps/notifications/blocs/notifications.dart'; +import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/blocs/apps.dart'; import 'package:neon/src/neon.dart'; import 'package:nextcloud/nextcloud.dart'; diff --git a/packages/neon/lib/src/apps/notifications/pages/main.dart b/packages/neon/lib/src/apps/notifications/pages/main.dart index 2a82c727..86219bca 100644 --- a/packages/neon/lib/src/apps/notifications/pages/main.dart +++ b/packages/neon/lib/src/apps/notifications/pages/main.dart @@ -103,7 +103,12 @@ class _NotificationsMainPageState extends State { ), ), onTap: () async { - if (!(await Global.handleNotificationOpening!(notification))) { + final allAppImplementations = Provider.of>(context, listen: false); + final matchingAppImplementations = allAppImplementations.where((final a) => a.id == notification.app); + if (matchingAppImplementations.isNotEmpty) { + final accountsBloc = RxBlocProvider.of(context); + accountsBloc.getAppsBloc(accountsBloc.activeAccount.value!).setActiveApp(notification.app); + } else { await showDialog( context: context, builder: (final context) => AlertDialog( diff --git a/packages/neon/lib/src/models/account.dart b/packages/neon/lib/src/models/account.dart index 09c9cdf0..d58cacd9 100644 --- a/packages/neon/lib/src/models/account.dart +++ b/packages/neon/lib/src/models/account.dart @@ -81,3 +81,15 @@ extension NextcloudClientHelpers on NextcloudClient { return '${username!}@${uri.port != 443 ? '${uri.host}:${uri.port}' : uri.host}'; } } + +extension AccountFind on List { + Account? find(final String accountID) { + for (final account in this) { + if (account.id == accountID) { + return account; + } + } + + return null; + } +} diff --git a/packages/neon/lib/src/models/push_notification_with_account.dart b/packages/neon/lib/src/models/push_notification_with_account.dart new file mode 100644 index 00000000..02c2eadf --- /dev/null +++ b/packages/neon/lib/src/models/push_notification_with_account.dart @@ -0,0 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:nextcloud/nextcloud.dart'; + +part 'push_notification_with_account.g.dart'; + +@JsonSerializable() +class PushNotificationWithAccountID { + PushNotificationWithAccountID({ + required this.notification, + required this.accountID, + }); + + factory PushNotificationWithAccountID.fromJson(final Map json) => + _$PushNotificationWithAccountIDFromJson(json); + Map toJson() => _$PushNotificationWithAccountIDToJson(this); + + final NotificationsPushNotification notification; + + final String accountID; +} diff --git a/packages/neon/lib/src/models/push_notification_with_account.g.dart b/packages/neon/lib/src/models/push_notification_with_account.g.dart new file mode 100644 index 00000000..30a05069 --- /dev/null +++ b/packages/neon/lib/src/models/push_notification_with_account.g.dart @@ -0,0 +1,18 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'push_notification_with_account.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PushNotificationWithAccountID _$PushNotificationWithAccountIDFromJson(Map json) => + PushNotificationWithAccountID( + notification: NotificationsPushNotification.fromJson(json['notification'] as Map), + accountID: json['accountID'] as String, + ); + +Map _$PushNotificationWithAccountIDToJson(PushNotificationWithAccountID instance) => { + 'notification': instance.notification, + 'accountID': instance.accountID, + }; diff --git a/packages/neon/lib/src/neon.dart b/packages/neon/lib/src/neon.dart index fd61ee30..cf003cb1 100644 --- a/packages/neon/lib/src/neon.dart +++ b/packages/neon/lib/src/neon.dart @@ -9,6 +9,7 @@ import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:file_picker/file_picker.dart'; import 'package:filesize/filesize.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; @@ -33,6 +34,7 @@ import 'package:neon/src/blocs/login.dart'; import 'package:neon/src/blocs/user_details.dart'; import 'package:neon/src/blocs/user_status.dart'; import 'package:neon/src/models/account.dart'; +import 'package:neon/src/models/push_notification_with_account.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; @@ -73,6 +75,7 @@ part 'utils/env.dart'; part 'utils/global.dart'; part 'utils/global_options.dart'; part 'utils/hex_color.dart'; +part 'utils/localizations.dart'; part 'utils/missing_permission_exception.dart'; part 'utils/nextcloud_app_specific_options.dart'; part 'utils/push_utils.dart'; diff --git a/packages/neon/lib/src/pages/home/home.dart b/packages/neon/lib/src/pages/home/home.dart index 04d7dc14..f655b1ad 100644 --- a/packages/neon/lib/src/pages/home/home.dart +++ b/packages/neon/lib/src/pages/home/home.dart @@ -17,33 +17,19 @@ class HomePage extends StatefulWidget { } // ignore: prefer_mixin -class _HomePageState extends State with tray.TrayListener, WindowListener { - final _appRegex = RegExp(r'^app_([a-z]+)$', multiLine: true); - +class _HomePageState extends State { final _scaffoldKey = GlobalKey(); - late NeonPlatform _platform; late GlobalOptions _globalOptions; late CapabilitiesBloc _capabilitiesBloc; late AppsBloc _appsBloc; - Rect? _lastBounds; - @override void initState() { super.initState(); - _platform = Provider.of(context, listen: false); _globalOptions = Provider.of(context, listen: false); - final accountsBloc = RxBlocProvider.of(context); - _appsBloc = accountsBloc.getAppsBloc(widget.account); - - if (_platform.canUseSystemTray) { - tray.trayManager.addListener(this); - } - if (_platform.canUseWindowManager) { - windowManager.addListener(this); - } + _appsBloc = RxBlocProvider.of(context).getAppsBloc(widget.account); _capabilitiesBloc = CapabilitiesBloc( Provider.of(context, listen: false), @@ -95,180 +81,6 @@ class _HomePageState extends State with tray.TrayListener, WindowListe } } }); - - WidgetsBinding.instance.addPostFrameCallback((final _) async { - final appImplementations = Provider.of>(context, listen: false); - if (_platform.canUseQuickActions) { - const quickActions = QuickActions(); - await quickActions.setShortcutItems( - appImplementations - .map( - (final app) => ShortcutItem( - type: 'app_${app.id}', - localizedTitle: app.name(context), - icon: 'app_${app.id}', - ), - ) - .toList(), - ); - await quickActions.initialize(_handleShortcut); - } - - if (_platform.canUseWindowManager) { - await windowManager.setPreventClose(true); - - if (_globalOptions.startupMinimized.value) { - await _saveAndMinimizeWindow(); - } - } - - if (_platform.canUseSystemTray) { - _globalOptions.systemTrayEnabled.stream.listen((final enabled) async { - if (enabled) { - // TODO: This works on Linux, but maybe not on macOS or Windows - await tray.trayManager.setIcon('assets/logo_neon.svg'); - if (mounted) { - await tray.trayManager.setContextMenu( - tray.Menu( - items: [ - for (final app in appImplementations) ...[ - tray.MenuItem( - key: 'app_${app.id}', - label: app.name(context), - // TODO: Add icons which should work on macOS and Windows - ), - ], - tray.MenuItem.separator(), - tray.MenuItem( - key: 'show_hide', - label: AppLocalizations.of(context).showSlashHide, - ), - tray.MenuItem( - key: 'exit', - label: AppLocalizations.of(context).exit, - ), - ], - ), - ); - } - } else { - await tray.trayManager.destroy(); - } - }); - } - - Global.handleNotificationOpening = (final notification) async { - final allAppImplementations = Provider.of>(context, listen: false); - final matchingAppImplementations = allAppImplementations.where((final a) => a.id == notification.app); - if (matchingAppImplementations.isNotEmpty) { - _appsBloc.setActiveApp(notification.app); - return true; - } - - return false; - }; - - if (_platform.canUsePushNotifications) { - final localNotificationsPlugin = await PushUtils.initLocalNotifications(); - Global.onPushNotificationReceived = () async { - final appImplementation = Provider.of>(context, listen: false) - .singleWhere((final a) => a.id == 'notifications'); - _appsBloc.getAppBloc(appImplementation).refresh(); - }; - Global.onPushNotificationClicked = (final payload) async { - if (payload != null) { - final notification = NotificationsPushNotification.fromJson(json.decode(payload) as Map); - debugPrint('onNotificationClicked: ${notification.subject}'); - - final allAppImplementations = Provider.of>(context, listen: false); - - final matchingAppImplementations = - allAppImplementations.where((final a) => a.id == notification.subject.app); - - late AppImplementation appImplementation; - if (matchingAppImplementations.isNotEmpty) { - appImplementation = matchingAppImplementations.single; - } else { - appImplementation = allAppImplementations.singleWhere((final a) => a.id == 'notifications'); - } - - if (appImplementation.id != 'notifications') { - _appsBloc.getAppBloc(appImplementation).deleteNotification(notification.subject.nid); - } - await _openAppFromExternal(appImplementation.id); - } - }; - - final details = await localNotificationsPlugin.getNotificationAppLaunchDetails(); - if (details != null && details.didNotificationLaunchApp) { - await Global.onPushNotificationClicked!(details.notificationResponse?.payload); - } - } - }); - } - - @override - void onTrayMenuItemClick(final tray.MenuItem menuItem) { - if (menuItem.key != null) { - unawaited(_handleShortcut(menuItem.key!)); - } - } - - @override - Future onWindowClose() async { - if (_globalOptions.startupMinimizeInsteadOfExit.value) { - await _saveAndMinimizeWindow(); - } else { - await windowManager.destroy(); - } - } - - Future _handleShortcut(final String shortcutType) async { - if (shortcutType == 'show_hide') { - if (_platform.canUseWindowManager) { - if (await windowManager.isVisible()) { - await _saveAndMinimizeWindow(); - } else { - await _showAndRestoreWindow(); - } - } - return; - } - if (shortcutType == 'exit') { - exit(0); - } - - final matches = _appRegex.allMatches(shortcutType).toList(); - if (matches.isNotEmpty) { - await _openAppFromExternal(matches[0].group(1)!); - return; - } - } - - Future _openAppFromExternal(final String id) async { - _appsBloc.setActiveApp(id); - Navigator.of(context).popUntil((final route) => route.settings.name == 'home'); - if (_platform.canUseWindowManager) { - await _showAndRestoreWindow(); - } - } - - Future _saveAndMinimizeWindow() async { - _lastBounds = await windowManager.getBounds(); - if (_globalOptions.systemTrayEnabled.value && _globalOptions.systemTrayHideToTrayWhenMinimized.value) { - await windowManager.hide(); - } else { - await windowManager.minimize(); - } - } - - Future _showAndRestoreWindow() async { - final wasVisible = await windowManager.isVisible(); - await windowManager.show(); - await windowManager.focus(); - if (_lastBounds != null && !wasVisible) { - await windowManager.setBounds(_lastBounds); - } } Future _showUnsupportedVersion(final String appName) async { @@ -303,15 +115,6 @@ class _HomePageState extends State with tray.TrayListener, WindowListe @override void dispose() { _capabilitiesBloc.dispose(); - _appsBloc.dispose(); - - if (_platform.canUseSystemTray) { - tray.trayManager.removeListener(this); - } - if (_platform.canUseWindowManager) { - windowManager.removeListener(this); - } - super.dispose(); } diff --git a/packages/neon/lib/src/utils/global.dart b/packages/neon/lib/src/utils/global.dart index b38951dd..32e5e174 100644 --- a/packages/neon/lib/src/utils/global.dart +++ b/packages/neon/lib/src/utils/global.dart @@ -1,7 +1,6 @@ part of '../neon.dart'; class Global { - static Function()? onPushNotificationReceived; - static Function(String? payload)? onPushNotificationClicked; - static Future Function(NotificationsNotification notification)? handleNotificationOpening; + static Function(String accountID)? onPushNotificationReceived; + static Function(PushNotificationWithAccountID notification)? onPushNotificationClicked; } diff --git a/packages/neon/lib/src/utils/localizations.dart b/packages/neon/lib/src/utils/localizations.dart new file mode 100644 index 00000000..43641d6d --- /dev/null +++ b/packages/neon/lib/src/utils/localizations.dart @@ -0,0 +1,7 @@ +part of '../neon.dart'; + +Future appLocalizationsFromSystem() async { + final parts = + (await findSystemLocale()).split('_').map((final a) => a.split('.')).reduce((final a, final b) => [...a, ...b]); + return AppLocalizations.delegate.load(Locale(parts[0], parts.length > 1 ? parts[1] : null)); +} diff --git a/packages/neon/lib/src/utils/push_utils.dart b/packages/neon/lib/src/utils/push_utils.dart index 9165b3ae..d9827c3d 100644 --- a/packages/neon/lib/src/utils/push_utils.dart +++ b/packages/neon/lib/src/utils/push_utils.dart @@ -40,8 +40,12 @@ class PushUtils { final localNotificationsPlugin = await initLocalNotifications( onDidReceiveNotificationResponse: (final notification) async { - if (Global.onPushNotificationClicked != null) { - await Global.onPushNotificationClicked!(notification.payload); + if (Global.onPushNotificationClicked != null && notification.payload != null) { + await Global.onPushNotificationClicked!( + PushNotificationWithAccountID.fromJson( + json.decode(notification.payload!) as Map, + ), + ); } }, ); @@ -69,9 +73,7 @@ class PushUtils { return; } - final parts = - (await findSystemLocale()).split('_').map((final a) => a.split('.')).reduce((final a, final b) => [...a, ...b]); - final localizations = await AppLocalizations.delegate.load(Locale(parts[0], parts.length > 1 ? parts[1] : null)); + final localizations = await appLocalizationsFromSystem(); final platform = await getNeonPlatform(); final cache = Cache(platform); @@ -112,10 +114,15 @@ class PushUtils { urgency: notification.type == 'voip' ? LinuxNotificationUrgency.critical : LinuxNotificationUrgency.normal, ), ), - payload: json.encode(notification.toJson()), + payload: json.encode( + PushNotificationWithAccountID( + notification: notification, + accountID: instance, + ).toJson(), + ), ); - Global.onPushNotificationReceived?.call(); + Global.onPushNotificationReceived?.call(instance); } static int _getNotificationID(