Browse Source

Merge pull request #100 from jld3103/refactor/tray-quickactions-pushnotifications

Refactor tray, quick actions and push notifications
pull/101/head
jld3103 2 years ago committed by GitHub
parent
commit
f4e9078aa5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 221
      packages/neon/lib/src/app.dart
  2. 2
      packages/neon/lib/src/apps/notifications/app.dart
  3. 7
      packages/neon/lib/src/apps/notifications/pages/main.dart
  4. 55
      packages/neon/lib/src/blocs/accounts.dart
  5. 48
      packages/neon/lib/src/blocs/apps.dart
  6. 12
      packages/neon/lib/src/models/account.dart
  7. 20
      packages/neon/lib/src/models/push_notification_with_account.dart
  8. 18
      packages/neon/lib/src/models/push_notification_with_account.g.dart
  9. 3
      packages/neon/lib/src/neon.dart
  10. 201
      packages/neon/lib/src/pages/home/home.dart
  11. 5
      packages/neon/lib/src/utils/global.dart
  12. 7
      packages/neon/lib/src/utils/localizations.dart
  13. 21
      packages/neon/lib/src/utils/push_utils.dart
  14. 20
      packages/nextcloud/lib/src/nextcloud.openapi.dart
  15. 10
      packages/nextcloud/lib/src/nextcloud.openapi.g.dart
  16. 7
      packages/nextcloud/lib/src/nextcloud.openapi.json
  17. 7
      specs/notifications.json
  18. 2
      tool/send-push-notification-test.sh

221
packages/neon/lib/src/app.dart

@ -21,12 +21,16 @@ class NeonApp extends StatefulWidget {
} }
// ignore: prefer_mixin // ignore: prefer_mixin
class _NeonAppState extends State<NeonApp> with WidgetsBindingObserver { class _NeonAppState extends State<NeonApp> with WidgetsBindingObserver, tray.TrayListener, WindowListener {
final _appRegex = RegExp(r'^app_([a-z]+)$', multiLine: true);
final _navigatorKey = GlobalKey<NavigatorState>(); final _navigatorKey = GlobalKey<NavigatorState>();
CoreServerCapabilities_Ocs_Data_Capabilities_Theming? _userTheme; late NeonPlatform _platform;
final _platformBrightness = BehaviorSubject<Brightness>.seeded( late GlobalOptions _globalOptions;
WidgetsBinding.instance.window.platformBrightness, late AccountsBloc _accountsBloc;
);
CoreServerCapabilities_Ocs_Data_Capabilities_Theming? _nextcloudTheme;
final _platformBrightness = BehaviorSubject<Brightness>.seeded(WidgetsBinding.instance.window.platformBrightness);
Rect? _lastBounds;
@override @override
void didChangePlatformBrightness() { void didChangePlatformBrightness() {
@ -39,9 +43,19 @@ class _NeonAppState extends State<NeonApp> with WidgetsBindingObserver {
void initState() { void initState() {
super.initState(); super.initState();
_platform = Provider.of<NeonPlatform>(context, listen: false);
_globalOptions = Provider.of<GlobalOptions>(context, listen: false);
_accountsBloc = RxBlocProvider.of<AccountsBloc>(context);
WidgetsBinding.instance.addObserver(this); 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 { widget.accountsBloc.activeAccount.listen((final activeAccount) async {
FlutterNativeSplash.remove(); FlutterNativeSplash.remove();
@ -58,9 +72,9 @@ class _NeonAppState extends State<NeonApp> with WidgetsBindingObserver {
); );
Widget builder(final context) => HomePage( Widget builder(final context) => HomePage(
account: activeAccount, account: activeAccount,
onThemeChanged: (final theme) { onThemeChanged: (final nextcloudTheme) {
setState(() { setState(() {
_userTheme = theme; _nextcloudTheme = nextcloudTheme;
}); });
}, },
); );
@ -78,12 +92,201 @@ class _NeonAppState extends State<NeonApp> with WidgetsBindingObserver {
); );
} }
}); });
final localizations = await appLocalizationsFromSystem();
if (!mounted) {
return;
}
final appImplementations = Provider.of<List<AppImplementation>>(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<AccountsBloc>(context).accounts.value.find(accountID);
if (account == null) {
return;
}
final appImplementation = Provider.of<List<AppImplementation>>(context, listen: false)
.singleWhere((final a) => a.id == 'notifications');
_accountsBloc.getAppsBloc(account).getAppBloc<NotificationsBloc>(appImplementation).refresh();
};
Global.onPushNotificationClicked = (final pushNotificationWithAccountID) async {
final allAppImplementations = Provider.of<List<AppImplementation>>(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<AccountsBloc>(context).accounts.value.find(pushNotificationWithAccountID.accountID);
if (account == null) {
return;
}
_accountsBloc.setActiveAccount(account);
if (appImplementation.id != 'notifications') {
_accountsBloc
.getAppsBloc(account)
.getAppBloc<NotificationsBloc>(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<String, dynamic>,
),
);
}
}
}); });
} }
@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 @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
if (_platform.canUseSystemTray) {
tray.trayManager.removeListener(this);
}
if (_platform.canUseWindowManager) {
windowManager.removeListener(this);
}
unawaited(_platformBrightness.close()); unawaited(_platformBrightness.close());
super.dispose(); super.dispose();
@ -110,7 +313,7 @@ class _NeonAppState extends State<NeonApp> with WidgetsBindingObserver {
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
navigatorKey: _navigatorKey, navigatorKey: _navigatorKey,
theme: getThemeFromNextcloudTheme( theme: getThemeFromNextcloudTheme(
_userTheme, _nextcloudTheme,
themeMode, themeMode,
platformBrightnessSnapshot.data!, platformBrightnessSnapshot.data!,
oledAsDark: themeOLEDAsDark, oledAsDark: themeOLEDAsDark,

2
packages/neon/lib/src/apps/notifications/app.dart

@ -1,9 +1,11 @@
library notifications; library notifications;
import 'package:flutter/material.dart'; 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:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:neon/l10n/localizations.dart'; import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/apps/notifications/blocs/notifications.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/blocs/apps.dart';
import 'package:neon/src/neon.dart'; import 'package:neon/src/neon.dart';
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';

7
packages/neon/lib/src/apps/notifications/pages/main.dart

@ -103,7 +103,12 @@ class _NotificationsMainPageState extends State<NotificationsMainPage> {
), ),
), ),
onTap: () async { onTap: () async {
if (!(await Global.handleNotificationOpening!(notification))) { final allAppImplementations = Provider.of<List<AppImplementation>>(context, listen: false);
final matchingAppImplementations = allAppImplementations.where((final a) => a.id == notification.app);
if (matchingAppImplementations.isNotEmpty) {
final accountsBloc = RxBlocProvider.of<AccountsBloc>(context);
accountsBloc.getAppsBloc(accountsBloc.activeAccount.value!).setActiveApp(notification.app);
} else {
await showDialog( await showDialog(
context: context, context: context,
builder: (final context) => AlertDialog( builder: (final context) => AlertDialog(

55
packages/neon/lib/src/blocs/accounts.dart

@ -129,7 +129,8 @@ class AccountsBloc extends $AccountsBloc {
if (_accountsAppsBlocs[account.id] != null) { if (_accountsAppsBlocs[account.id] != null) {
return _accountsAppsBlocs[account.id]!; return _accountsAppsBlocs[account.id]!;
} }
return AppsBloc(
return _accountsAppsBlocs[account.id] = AppsBloc(
_requestManager, _requestManager,
this, this,
account, account,
@ -137,33 +138,15 @@ class AccountsBloc extends $AccountsBloc {
); );
} }
final RequestManager _requestManager;
final Storage _storage;
final SharedPreferences _sharedPreferences;
final GlobalOptions _globalOptions;
final List<AppImplementation> _allAppImplementations;
final PackageInfo _packageInfo;
final _keyAccounts = 'accounts';
final _keyLastUsedAccount = 'last-used-account';
final _accountsOptions = <String, AccountSpecificOptions>{};
final _accountsAppsBlocs = <String, AppsBloc>{};
late final _activeAccountSubject = BehaviorSubject<Account?>.seeded(null);
late final _accountsSubject = BehaviorSubject<List<Account>>.seeded([]);
String? pushNotificationApp;
final Map<Account, UserDetailsBloc> _userDetailsBlocs = {};
final Map<Account, UserStatusBloc> _userStatusBlocs = {};
UserDetailsBloc getUserDetailsBloc(final Account account) { UserDetailsBloc getUserDetailsBloc(final Account account) {
if (_userDetailsBlocs[account] != null) { if (_userDetailsBlocs[account] != null) {
return _userDetailsBlocs[account]!; return _userDetailsBlocs[account]!;
} }
final bloc = UserDetailsBloc(_requestManager, account.client); return _userDetailsBlocs[account.id] = UserDetailsBloc(
_userDetailsBlocs[account] = bloc; _requestManager,
account.client,
return bloc; );
} }
UserStatusBloc getUserStatusBloc(final Account account) { UserStatusBloc getUserStatusBloc(final Account account) {
@ -171,12 +154,30 @@ class AccountsBloc extends $AccountsBloc {
return _userStatusBlocs[account]!; return _userStatusBlocs[account]!;
} }
final bloc = UserStatusBloc(_requestManager, account, _activeAccountSubject); return _userStatusBlocs[account.id] = UserStatusBloc(
_userStatusBlocs[account] = bloc; _requestManager,
account,
return bloc; _activeAccountSubject,
);
} }
final RequestManager _requestManager;
final Storage _storage;
final SharedPreferences _sharedPreferences;
final GlobalOptions _globalOptions;
final List<AppImplementation> _allAppImplementations;
final PackageInfo _packageInfo;
final _keyAccounts = 'accounts';
final _keyLastUsedAccount = 'last-used-account';
final _accountsOptions = <String, AccountSpecificOptions>{};
late final _activeAccountSubject = BehaviorSubject<Account?>.seeded(null);
late final _accountsSubject = BehaviorSubject<List<Account>>.seeded([]);
final _accountsAppsBlocs = <String, AppsBloc>{};
final _userDetailsBlocs = <String, UserDetailsBloc>{};
final _userStatusBlocs = <String, UserStatusBloc>{};
@override @override
void dispose() { void dispose() {
unawaited(_activeAccountSubject.close()); unawaited(_activeAccountSubject.close());

48
packages/neon/lib/src/blocs/apps.dart

@ -64,28 +64,23 @@ class AppsBloc extends $AppsBloc {
result.data != null ? _filteredAppImplementations(result.data!) : <AppImplementation>[]; result.data != null ? _filteredAppImplementations(result.data!) : <AppImplementation>[];
if (result.data != null) { if (result.data != null) {
if (_accountsBloc.pushNotificationApp != null) { final options = _accountsBloc.getOptions(_account);
setActiveApp(_accountsBloc.pushNotificationApp); unawaited(
_accountsBloc.pushNotificationApp = null; options.initialApp.stream.first.then((var initialApp) async {
} else { if (initialApp == null) {
final options = _accountsBloc.getOptions(_account); if (appImplementations.where((final a) => a.id == 'files').isNotEmpty) {
unawaited( initialApp = 'files';
options.initialApp.stream.first.then((var initialApp) { } else if (appImplementations.isNotEmpty) {
if (initialApp == null) { // This should never happen, because the files app is always installed and can not be removed, but just in
if (appImplementations.where((final a) => a.id == 'files').isNotEmpty) { // case this changes at a later point.
initialApp = 'files'; initialApp = appImplementations[0].id;
} else if (appImplementations.isNotEmpty) {
// This should never happen, because the files app is always installed and can not be removed, but just in
// case this changes at a later point.
initialApp = appImplementations[0].id;
}
} }
if (!_activeAppSubject.hasValue) { }
setActiveApp(initialApp); if (!_activeAppSubject.hasValue) {
} setActiveApp(initialApp);
}), }
); }),
} );
} }
}); });
@ -120,17 +115,14 @@ class AppsBloc extends $AppsBloc {
final _appImplementationsSubject = BehaviorSubject<Result<List<AppImplementation>>>(); final _appImplementationsSubject = BehaviorSubject<Result<List<AppImplementation>>>();
late final _activeAppSubject = BehaviorSubject<String?>(); late final _activeAppSubject = BehaviorSubject<String?>();
final Map<AppImplementation, RxBlocBase> _blocs = {}; final Map<String, RxBlocBase> _blocs = {};
T getAppBloc<T extends RxBlocBase>(final AppImplementation appImplementation) { T getAppBloc<T extends RxBlocBase>(final AppImplementation appImplementation) {
if (_blocs[appImplementation] != null) { if (_blocs[appImplementation.id] != null) {
return _blocs[appImplementation]! as T; return _blocs[appImplementation.id]! as T;
} }
final bloc = appImplementation.buildBloc(_account.client); return _blocs[appImplementation.id] = appImplementation.buildBloc(_account.client) as T;
_blocs[appImplementation] = bloc;
return bloc as T;
} }
@override @override

12
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}'; return '${username!}@${uri.port != 443 ? '${uri.host}:${uri.port}' : uri.host}';
} }
} }
extension AccountFind on List<Account> {
Account? find(final String accountID) {
for (final account in this) {
if (account.id == accountID) {
return account;
}
}
return null;
}
}

20
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<String, dynamic> json) =>
_$PushNotificationWithAccountIDFromJson(json);
Map<String, dynamic> toJson() => _$PushNotificationWithAccountIDToJson(this);
final NotificationsPushNotification notification;
final String accountID;
}

18
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<String, dynamic> json) =>
PushNotificationWithAccountID(
notification: NotificationsPushNotification.fromJson(json['notification'] as Map<String, dynamic>),
accountID: json['accountID'] as String,
);
Map<String, dynamic> _$PushNotificationWithAccountIDToJson(PushNotificationWithAccountID instance) => <String, dynamic>{
'notification': instance.notification,
'accountID': instance.accountID,
};

3
packages/neon/lib/src/neon.dart

@ -9,6 +9,7 @@ import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:filesize/filesize.dart'; import 'package:filesize/filesize.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.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_details.dart';
import 'package:neon/src/blocs/user_status.dart'; import 'package:neon/src/blocs/user_status.dart';
import 'package:neon/src/models/account.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:nextcloud/nextcloud.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -73,6 +75,7 @@ part 'utils/env.dart';
part 'utils/global.dart'; part 'utils/global.dart';
part 'utils/global_options.dart'; part 'utils/global_options.dart';
part 'utils/hex_color.dart'; part 'utils/hex_color.dart';
part 'utils/localizations.dart';
part 'utils/missing_permission_exception.dart'; part 'utils/missing_permission_exception.dart';
part 'utils/nextcloud_app_specific_options.dart'; part 'utils/nextcloud_app_specific_options.dart';
part 'utils/push_utils.dart'; part 'utils/push_utils.dart';

201
packages/neon/lib/src/pages/home/home.dart

@ -17,33 +17,19 @@ class HomePage extends StatefulWidget {
} }
// ignore: prefer_mixin // ignore: prefer_mixin
class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListener { class _HomePageState extends State<HomePage> {
final _appRegex = RegExp(r'^app_([a-z]+)$', multiLine: true);
final _scaffoldKey = GlobalKey<ScaffoldState>(); final _scaffoldKey = GlobalKey<ScaffoldState>();
late NeonPlatform _platform;
late GlobalOptions _globalOptions; late GlobalOptions _globalOptions;
late CapabilitiesBloc _capabilitiesBloc; late CapabilitiesBloc _capabilitiesBloc;
late AppsBloc _appsBloc; late AppsBloc _appsBloc;
Rect? _lastBounds;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_platform = Provider.of<NeonPlatform>(context, listen: false);
_globalOptions = Provider.of<GlobalOptions>(context, listen: false); _globalOptions = Provider.of<GlobalOptions>(context, listen: false);
final accountsBloc = RxBlocProvider.of<AccountsBloc>(context); _appsBloc = RxBlocProvider.of<AccountsBloc>(context).getAppsBloc(widget.account);
_appsBloc = accountsBloc.getAppsBloc(widget.account);
if (_platform.canUseSystemTray) {
tray.trayManager.addListener(this);
}
if (_platform.canUseWindowManager) {
windowManager.addListener(this);
}
_capabilitiesBloc = CapabilitiesBloc( _capabilitiesBloc = CapabilitiesBloc(
Provider.of<RequestManager>(context, listen: false), Provider.of<RequestManager>(context, listen: false),
@ -95,180 +81,6 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
} }
} }
}); });
WidgetsBinding.instance.addPostFrameCallback((final _) async {
final appImplementations = Provider.of<List<AppImplementation>>(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<List<AppImplementation>>(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<List<AppImplementation>>(context, listen: false)
.singleWhere((final a) => a.id == 'notifications');
_appsBloc.getAppBloc<NotificationsBloc>(appImplementation).refresh();
};
Global.onPushNotificationClicked = (final payload) async {
if (payload != null) {
final notification = NotificationsPushNotification.fromJson(json.decode(payload) as Map<String, dynamic>);
debugPrint('onNotificationClicked: ${notification.subject}');
final allAppImplementations = Provider.of<List<AppImplementation>>(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<NotificationsBloc>(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 { Future _showUnsupportedVersion(final String appName) async {
@ -303,15 +115,6 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
@override @override
void dispose() { void dispose() {
_capabilitiesBloc.dispose(); _capabilitiesBloc.dispose();
_appsBloc.dispose();
if (_platform.canUseSystemTray) {
tray.trayManager.removeListener(this);
}
if (_platform.canUseWindowManager) {
windowManager.removeListener(this);
}
super.dispose(); super.dispose();
} }

5
packages/neon/lib/src/utils/global.dart

@ -1,7 +1,6 @@
part of '../neon.dart'; part of '../neon.dart';
class Global { class Global {
static Function()? onPushNotificationReceived; static Function(String accountID)? onPushNotificationReceived;
static Function(String? payload)? onPushNotificationClicked; static Function(PushNotificationWithAccountID notification)? onPushNotificationClicked;
static Future<bool> Function(NotificationsNotification notification)? handleNotificationOpening;
} }

7
packages/neon/lib/src/utils/localizations.dart

@ -0,0 +1,7 @@
part of '../neon.dart';
Future<AppLocalizations> 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));
}

21
packages/neon/lib/src/utils/push_utils.dart

@ -40,8 +40,12 @@ class PushUtils {
final localNotificationsPlugin = await initLocalNotifications( final localNotificationsPlugin = await initLocalNotifications(
onDidReceiveNotificationResponse: (final notification) async { onDidReceiveNotificationResponse: (final notification) async {
if (Global.onPushNotificationClicked != null) { if (Global.onPushNotificationClicked != null && notification.payload != null) {
await Global.onPushNotificationClicked!(notification.payload); await Global.onPushNotificationClicked!(
PushNotificationWithAccountID.fromJson(
json.decode(notification.payload!) as Map<String, dynamic>,
),
);
} }
}, },
); );
@ -69,9 +73,7 @@ class PushUtils {
return; return;
} }
final parts = final localizations = await appLocalizationsFromSystem();
(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 platform = await getNeonPlatform(); final platform = await getNeonPlatform();
final cache = Cache(platform); final cache = Cache(platform);
@ -112,10 +114,15 @@ class PushUtils {
urgency: notification.type == 'voip' ? LinuxNotificationUrgency.critical : LinuxNotificationUrgency.normal, 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( static int _getNotificationID(

20
packages/nextcloud/lib/src/nextcloud.openapi.dart

@ -3840,11 +3840,11 @@ class UserStatusClient {
@JsonSerializable() @JsonSerializable()
class NotificationsPushNotificationDecryptedSubject { class NotificationsPushNotificationDecryptedSubject {
NotificationsPushNotificationDecryptedSubject({ NotificationsPushNotificationDecryptedSubject({
required this.nid, this.nid,
required this.app, this.app,
required this.subject, this.subject,
required this.type, this.type,
required this.id, this.id,
this.delete, this.delete,
this.deleteAll, this.deleteAll,
}); });
@ -3852,15 +3852,15 @@ class NotificationsPushNotificationDecryptedSubject {
factory NotificationsPushNotificationDecryptedSubject.fromJson(Map<String, dynamic> json) => factory NotificationsPushNotificationDecryptedSubject.fromJson(Map<String, dynamic> json) =>
_$NotificationsPushNotificationDecryptedSubjectFromJson(json); _$NotificationsPushNotificationDecryptedSubjectFromJson(json);
final int nid; final int? nid;
final String app; final String? app;
final String subject; final String? subject;
final String type; final String? type;
final String id; final String? id;
final bool? delete; final bool? delete;

10
packages/nextcloud/lib/src/nextcloud.openapi.g.dart

@ -1627,11 +1627,11 @@ Map<String, dynamic> _$UserStatusPredefinedStatusesToJson(UserStatusPredefinedSt
NotificationsPushNotificationDecryptedSubject _$NotificationsPushNotificationDecryptedSubjectFromJson( NotificationsPushNotificationDecryptedSubject _$NotificationsPushNotificationDecryptedSubjectFromJson(
Map<String, dynamic> json) => Map<String, dynamic> json) =>
NotificationsPushNotificationDecryptedSubject( NotificationsPushNotificationDecryptedSubject(
nid: json['nid'] as int, nid: json['nid'] as int?,
app: json['app'] as String, app: json['app'] as String?,
subject: json['subject'] as String, subject: json['subject'] as String?,
type: json['type'] as String, type: json['type'] as String?,
id: json['id'] as String, id: json['id'] as String?,
delete: json['delete'] as bool?, delete: json['delete'] as bool?,
deleteAll: json['delete-all'] as bool?, deleteAll: json['delete-all'] as bool?,
); );

7
packages/nextcloud/lib/src/nextcloud.openapi.json

@ -1599,13 +1599,6 @@
}, },
"NotificationsPushNotificationDecryptedSubject": { "NotificationsPushNotificationDecryptedSubject": {
"type": "object", "type": "object",
"required": [
"nid",
"app",
"subject",
"type",
"id"
],
"properties": { "properties": {
"nid": { "nid": {
"type": "integer" "type": "integer"

7
specs/notifications.json

@ -307,13 +307,6 @@
}, },
"NotificationsPushNotificationDecryptedSubject": { "NotificationsPushNotificationDecryptedSubject": {
"type": "object", "type": "object",
"required": [
"nid",
"app",
"subject",
"type",
"id"
],
"properties": { "properties": {
"nid": { "nid": {
"type": "integer" "type": "integer"

2
tool/send-push-notification-test.sh

@ -1,4 +1,4 @@
#!/bin/bash #!/bin/bash
set -euxo pipefail set -euxo pipefail
docker exec -it "$(docker ps | grep nextcloud-neon-dev | cut -d " " -f 1)" /bin/bash -c "php -f occ notification:test-push test $*" docker exec -it "$(docker ps | grep nextcloud-neon-dev | cut -d " " -f 1)" /bin/bash -c "php -f occ notification:test-push $*"

Loading…
Cancel
Save