A framework for building convergent cross-platform Nextcloud clients using Flutter.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

317 lines
11 KiB

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:meta/meta.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/bloc/result_builder.dart';
import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/models/notifications_interface.dart';
import 'package:neon/src/models/push_notification.dart';
import 'package:neon/src/platform/platform.dart';
import 'package:neon/src/router.dart';
import 'package:neon/src/theme/neon.dart';
import 'package:neon/src/theme/theme.dart';
import 'package:neon/src/utils/global.dart';
import 'package:neon/src/utils/global_options.dart';
import 'package:neon/src/utils/localizations.dart';
import 'package:neon/src/utils/provider.dart';
import 'package:neon/src/utils/push_utils.dart';
import 'package:neon/src/widgets/options_collection_builder.dart';
import 'package:nextcloud/core.dart' as core;
import 'package:nextcloud/nextcloud.dart';
import 'package:quick_actions/quick_actions.dart';
import 'package:tray_manager/tray_manager.dart' as tray;
import 'package:window_manager/window_manager.dart';
@internal
class NeonApp extends StatefulWidget {
const NeonApp({
required this.neonTheme,
super.key,
});
final NeonTheme neonTheme;
@override
State<NeonApp> createState() => _NeonAppState();
}
// ignore: prefer_mixin
class _NeonAppState extends State<NeonApp> with WidgetsBindingObserver, tray.TrayListener, WindowListener {
final _appRegex = RegExp(r'^app_([a-z]+)$', multiLine: true);
final _navigatorKey = GlobalKey<NavigatorState>();
late final Iterable<AppImplementation> _appImplementations;
late final GlobalOptions _globalOptions;
late final AccountsBloc _accountsBloc;
late final _routerDelegate = buildAppRouter(
navigatorKey: _navigatorKey,
accountsBloc: _accountsBloc,
);
Rect? _lastBounds;
@override
void initState() {
super.initState();
_appImplementations = NeonProvider.of<Iterable<AppImplementation>>(context);
_globalOptions = NeonProvider.of<GlobalOptions>(context);
_accountsBloc = NeonProvider.of<AccountsBloc>(context);
WidgetsBinding.instance.addObserver(this);
if (NeonPlatform.instance.canUseSystemTray) {
tray.trayManager.addListener(this);
}
if (NeonPlatform.instance.canUseWindowManager) {
windowManager.addListener(this);
}
WidgetsBinding.instance.addPostFrameCallback((final _) async {
final localizations = await appLocalizationsFromSystem();
if (!mounted) {
return;
}
if (NeonPlatform.instance.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 (NeonPlatform.instance.canUseWindowManager) {
await windowManager.setPreventClose(true);
if (_globalOptions.startupMinimized.value) {
await _saveAndMinimizeWindow();
}
}
if (NeonPlatform.instance.canUseSystemTray) {
_globalOptions.systemTrayEnabled.addListener(() async {
if (_globalOptions.systemTrayEnabled.value) {
// TODO: This works on Linux, but maybe not on macOS or Windows
await tray.trayManager.setIcon('assets/logo.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.actionShowSlashHide,
),
tray.MenuItem(
key: 'exit',
label: localizations.actionExit,
),
],
),
);
}
} else {
await tray.trayManager.destroy();
}
});
}
if (NeonPlatform.instance.canUsePushNotifications) {
final localNotificationsPlugin = await PushUtils.initLocalNotifications();
Global.onPushNotificationReceived = (final accountID) async {
final account = _accountsBloc.accounts.value.tryFind(accountID);
if (account == null) {
return;
}
final allAppImplementations = NeonProvider.of<Iterable<AppImplementation>>(context);
final app = allAppImplementations.tryFind(AppIDs.notifications) as NotificationsAppInterface?;
if (app == null) {
return;
}
await _accountsBloc.getAppsBlocFor(account).getAppBloc<NotificationsBlocInterface>(app).refresh();
};
Global.onPushNotificationClicked = (final pushNotificationWithAccountID) async {
final account = _accountsBloc.accounts.value.tryFind(pushNotificationWithAccountID.accountID);
if (account == null) {
return;
}
_accountsBloc.setActiveAccount(account);
final allAppImplementations = NeonProvider.of<Iterable<AppImplementation>>(context);
final notificationsApp = allAppImplementations.tryFind(AppIDs.notifications) as NotificationsAppInterface?;
if (notificationsApp != null) {
_accountsBloc
.getAppsBlocFor(account)
.getAppBloc<NotificationsBlocInterface>(notificationsApp)
.deleteNotification(pushNotificationWithAccountID.subject.nid!);
}
final app = allAppImplementations.tryFind(pushNotificationWithAccountID.subject.app) ?? notificationsApp;
if (app == null) {
return;
}
await _openAppFromExternal(account, app.id);
};
final details = await localNotificationsPlugin.getNotificationAppLaunchDetails();
if (details != null && details.didNotificationLaunchApp && details.notificationResponse?.payload != null) {
await Global.onPushNotificationClicked!(
PushNotification.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<void> onWindowClose() async {
if (_globalOptions.startupMinimizeInsteadOfExit.value) {
await _saveAndMinimizeWindow();
} else {
await windowManager.destroy();
}
}
@override
Future<void> onWindowMinimize() async {
await _saveAndMinimizeWindow();
}
Future<void> _handleShortcut(final String shortcutType) async {
if (shortcutType == 'show_hide') {
if (NeonPlatform.instance.canUseWindowManager) {
if (await windowManager.isVisible()) {
await _saveAndMinimizeWindow();
} else {
await _showAndRestoreWindow();
}
}
return;
}
if (shortcutType == 'exit') {
exit(0);
}
final matches = _appRegex.allMatches(shortcutType);
final activeAccount = await _accountsBloc.activeAccount.first;
if (matches.isNotEmpty && activeAccount != null) {
await _openAppFromExternal(activeAccount, matches.first.group(1)!);
}
}
Future<void> _openAppFromExternal(final Account account, final String id) async {
await _accountsBloc.getAppsBlocFor(account).setActiveApp(id);
_navigatorKey.currentState!.popUntil((final route) => route.settings.name == 'home');
await _showAndRestoreWindow();
}
Future<void> _saveAndMinimizeWindow() async {
_lastBounds = await windowManager.getBounds();
if (_globalOptions.systemTrayEnabled.value && _globalOptions.systemTrayHideToTrayWhenMinimized.value) {
await windowManager.hide();
} else {
await windowManager.minimize();
}
}
Future<void> _showAndRestoreWindow() async {
if (!NeonPlatform.instance.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 (NeonPlatform.instance.canUseSystemTray) {
tray.trayManager.removeListener(this);
}
if (NeonPlatform.instance.canUseWindowManager) {
windowManager.removeListener(this);
}
super.dispose();
}
@override
Widget build(final BuildContext context) => OptionsCollectionBuilder(
valueListenable: _globalOptions,
builder: (final context, final options, final _) => StreamBuilder<Account?>(
stream: _accountsBloc.activeAccount,
builder: (final context, final activeAccountSnapshot) {
FlutterNativeSplash.remove();
return ResultBuilder<core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data?>.behaviorSubject(
stream: activeAccountSnapshot.hasData
? _accountsBloc.getCapabilitiesBlocFor(activeAccountSnapshot.data!).capabilities
: null,
builder: (final context, final capabilitiesSnapshot) {
final appTheme = AppTheme(
capabilitiesSnapshot.data?.capabilities.themingPublicCapabilities?.theming,
keepOriginalAccentColor: options.themeKeepOriginalAccentColor.value,
oledAsDark: options.themeOLEDAsDark.value,
appThemes: _appImplementations.map((final a) => a.theme).whereNotNull(),
neonTheme: widget.neonTheme,
);
return MaterialApp.router(
localizationsDelegates: [
..._appImplementations.map((final app) => app.localizationsDelegate),
...NeonLocalizations.localizationsDelegates,
],
supportedLocales: {
..._appImplementations.map((final app) => app.supportedLocales).expand((final element) => element),
...NeonLocalizations.supportedLocales,
},
themeMode: options.themeMode.value,
theme: appTheme.lightTheme,
darkTheme: appTheme.darkTheme,
routerConfig: _routerDelegate,
);
},
);
},
),
);
}