diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock index 37d5efde..6eca7a55 100644 --- a/packages/app/pubspec.lock +++ b/packages/app/pubspec.lock @@ -1037,6 +1037,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.6" + scrollable_positioned_list: + dependency: transitive + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" share_plus: dependency: transitive description: diff --git a/packages/neon/neon/lib/src/pages/settings.dart b/packages/neon/neon/lib/src/pages/settings.dart index 69ace82c..06134363 100644 --- a/packages/neon/neon/lib/src/pages/settings.dart +++ b/packages/neon/neon/lib/src/pages/settings.dart @@ -27,11 +27,25 @@ import 'package:neon/src/widgets/exception.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; +enum SettingsCageories { + apps, + theme, + navigation, + pushNotifications, + startup, + systemTray, + accounts, + other, +} + class SettingsPage extends StatefulWidget { const SettingsPage({ + this.initialCategory, super.key, }); + final SettingsCageories? initialCategory; + @override State createState() => _SettingsPageState(); } @@ -42,6 +56,7 @@ class _SettingsPageState extends State { final globalOptions = Provider.of(context); final accountsBloc = Provider.of(context, listen: false); final appImplementations = Provider.of>(context); + return Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( @@ -68,245 +83,254 @@ class _SettingsPageState extends State { ), body: StreamBuilder>( stream: accountsBloc.accounts, + initialData: accountsBloc.accounts.valueOrNull, builder: ( final context, final accountsSnapshot, ) { - final settingsExportHelper = SettingsExportHelper( - globalOptions: globalOptions, - appImplementations: appImplementations, - accountSpecificOptions: { - if (accountsSnapshot.hasData) ...{ - for (final account in accountsSnapshot.requireData) ...{ - account: accountsBloc.getOptionsFor(account).options, - }, - }, - }, - ); final platform = Provider.of(context, listen: false); - return StreamBuilder( - stream: accountsBloc.activeAccount, + return StreamBuilder( + stream: globalOptions.pushNotificationsEnabled.enabled, + initialData: globalOptions.pushNotificationsEnabled.enabled.valueOrNull, builder: ( final context, - final activeAccountSnapshot, + final pushNotificationsEnabledEnabledSnapshot, ) => - StreamBuilder( - stream: globalOptions.pushNotificationsEnabled.enabled, - builder: ( - final context, - final pushNotificationsEnabledEnabledSnapshot, - ) => - SettingsList( - categories: [ + SettingsList( + initialCategory: widget.initialCategory?.name, + categories: [ + SettingsCategory( + title: Text(AppLocalizations.of(context).settingsApps), + key: ValueKey(SettingsCageories.apps.name), + tiles: [ + for (final appImplementation in appImplementations) ...[ + if (appImplementation.options.options.isNotEmpty) ...[ + CustomSettingsTile( + leading: appImplementation.buildIcon(), + title: Text(appImplementation.name(context)), + onTap: () { + NextcloudAppSettingsRoute(appid: appImplementation.id).go(context); + }, + ), + ], + ], + ], + ), + SettingsCategory( + title: Text(AppLocalizations.of(context).optionsCategoryTheme), + key: ValueKey(SettingsCageories.theme.name), + tiles: [ + DropdownButtonSettingsTile( + option: globalOptions.themeMode, + ), + CheckBoxSettingsTile( + option: globalOptions.themeOLEDAsDark, + ), + CheckBoxSettingsTile( + option: globalOptions.themeKeepOriginalAccentColor, + ), + ], + ), + SettingsCategory( + title: Text(AppLocalizations.of(context).optionsCategoryNavigation), + key: ValueKey(SettingsCageories.navigation.name), + tiles: [ + DropdownButtonSettingsTile( + option: globalOptions.navigationMode, + ), + ], + ), + if (platform.canUsePushNotifications) ...[ SettingsCategory( - title: Text(AppLocalizations.of(context).settingsApps), - tiles: [ - for (final appImplementation in appImplementations) ...[ - if (appImplementation.options.options.isNotEmpty) ...[ - CustomSettingsTile( - leading: appImplementation.buildIcon(), - title: Text(appImplementation.name(context)), - onTap: () { - NextcloudAppSettingsRoute(appid: appImplementation.id).go(context); - }, + title: Text(AppLocalizations.of(context).optionsCategoryPushNotifications), + key: ValueKey(SettingsCageories.pushNotifications.name), + tiles: [ + if (pushNotificationsEnabledEnabledSnapshot.hasData && + !pushNotificationsEnabledEnabledSnapshot.requireData) ...[ + TextSettingsTile( + text: AppLocalizations.of(context).globalOptionsPushNotificationsEnabledDisabledNotice, + style: TextStyle( + fontWeight: FontWeight.w600, + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.error, ), - ], + ), ], + CheckBoxSettingsTile( + option: globalOptions.pushNotificationsEnabled, + ), + DropdownButtonSettingsTile( + option: globalOptions.pushNotificationsDistributor, + ), ], ), + ], + if (platform.canUseWindowManager) ...[ SettingsCategory( - title: Text(AppLocalizations.of(context).optionsCategoryTheme), + title: Text(AppLocalizations.of(context).optionsCategoryStartup), + key: ValueKey(SettingsCageories.startup.name), tiles: [ - DropdownButtonSettingsTile( - option: globalOptions.themeMode, - ), CheckBoxSettingsTile( - option: globalOptions.themeOLEDAsDark, + option: globalOptions.startupMinimized, ), CheckBoxSettingsTile( - option: globalOptions.themeKeepOriginalAccentColor, + option: globalOptions.startupMinimizeInsteadOfExit, ), ], ), + ], + if (platform.canUseWindowManager && platform.canUseSystemTray) ...[ SettingsCategory( - title: Text(AppLocalizations.of(context).optionsCategoryNavigation), + title: Text(AppLocalizations.of(context).optionsCategorySystemTray), + key: ValueKey(SettingsCageories.systemTray.name), tiles: [ - DropdownButtonSettingsTile( - option: globalOptions.navigationMode, + CheckBoxSettingsTile( + option: globalOptions.systemTrayEnabled, + ), + CheckBoxSettingsTile( + option: globalOptions.systemTrayHideToTrayWhenMinimized, ), ], ), - if (platform.canUsePushNotifications) ...[ - SettingsCategory( - title: Text(AppLocalizations.of(context).optionsCategoryPushNotifications), - tiles: [ - if (pushNotificationsEnabledEnabledSnapshot.hasData && - !pushNotificationsEnabledEnabledSnapshot.requireData) ...[ - TextSettingsTile( - text: AppLocalizations.of(context).globalOptionsPushNotificationsEnabledDisabledNotice, - style: TextStyle( - fontWeight: FontWeight.w600, - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.error, - ), - ), - ], - CheckBoxSettingsTile( - option: globalOptions.pushNotificationsEnabled, - ), - DropdownButtonSettingsTile( - option: globalOptions.pushNotificationsDistributor, - ), - ], - ), - ], - if (platform.canUseWindowManager) ...[ - SettingsCategory( - title: Text(AppLocalizations.of(context).optionsCategoryStartup), - tiles: [ - CheckBoxSettingsTile( - option: globalOptions.startupMinimized, - ), - CheckBoxSettingsTile( - option: globalOptions.startupMinimizeInsteadOfExit, - ), - ], - ), - ], - if (platform.canUseWindowManager && platform.canUseSystemTray) ...[ - SettingsCategory( - title: Text(AppLocalizations.of(context).optionsCategorySystemTray), - tiles: [ - CheckBoxSettingsTile( - option: globalOptions.systemTrayEnabled, - ), - CheckBoxSettingsTile( - option: globalOptions.systemTrayHideToTrayWhenMinimized, - ), - ], - ), + ], + SettingsCategory( + title: Text(AppLocalizations.of(context).optionsCategoryAccounts), + key: ValueKey(SettingsCageories.accounts.name), + tiles: [ + if (accountsSnapshot.requireData.length > 1) ...[ + CheckBoxSettingsTile( + option: globalOptions.rememberLastUsedAccount, + ), + DropdownButtonSettingsTile( + option: globalOptions.initialAccount, + ), + ], + for (final account in accountsSnapshot.requireData) ...[ + AccountSettingsTile( + account: account, + onTap: () { + AccountSettingsRoute(accountid: account.id).go(context); + }, + ), + ], + CustomSettingsTile( + title: ElevatedButton.icon( + onPressed: () async => const LoginRoute().push(context), + icon: Icon(MdiIcons.accountPlus), + label: Text(AppLocalizations.of(context).globalOptionsAccountsAdd), + ), + ) ], - if (accountsSnapshot.hasData) ...[ - SettingsCategory( - title: Text(AppLocalizations.of(context).optionsCategoryAccounts), - tiles: [ - if (accountsSnapshot.requireData.length > 1) ...[ - CheckBoxSettingsTile( - option: globalOptions.rememberLastUsedAccount, - ), - DropdownButtonSettingsTile( - option: globalOptions.initialAccount, - ), - ], - for (final account in accountsSnapshot.requireData) ...[ - AccountSettingsTile( - account: account, - onTap: () { - AccountSettingsRoute(accountid: account.id).go(context); - }, - ), - ], - CustomSettingsTile( - title: ElevatedButton.icon( - onPressed: () async => const LoginRoute().push(context), - icon: Icon(MdiIcons.accountPlus), - label: Text(AppLocalizations.of(context).globalOptionsAccountsAdd), - ), - ) - ], + ), + SettingsCategory( + title: Text(AppLocalizations.of(context).optionsCategoryOther), + key: ValueKey(SettingsCageories.other.name), + tiles: [ + CustomSettingsTile( + leading: Icon( + MdiIcons.scriptText, + color: Theme.of(context).colorScheme.primary, + ), + title: Text(AppLocalizations.of(context).licenses), + onTap: () async { + final branding = Branding.of(context); + showLicensePage( + context: context, + applicationName: branding.name, + applicationIcon: branding.logo, + applicationLegalese: branding.legalese, + applicationVersion: Provider.of(context, listen: false).version, + ); + }, ), - ], - SettingsCategory( - title: Text(AppLocalizations.of(context).optionsCategoryOther), - tiles: [ - CustomSettingsTile( - leading: Icon( - MdiIcons.scriptText, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(AppLocalizations.of(context).licenses), - onTap: () async { - final branding = Branding.of(context); - showLicensePage( - context: context, - applicationName: branding.name, - applicationIcon: branding.logo, - applicationLegalese: branding.legalese, - applicationVersion: Provider.of(context, listen: false).version, - ); - }, + CustomSettingsTile( + leading: Icon( + MdiIcons.export, + color: Theme.of(context).colorScheme.primary, ), - CustomSettingsTile( - leading: Icon( - MdiIcons.export, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(AppLocalizations.of(context).settingsExport), - onTap: () async { - try { - final fileName = - 'nextcloud-neon-settings-${DateTime.now().millisecondsSinceEpoch ~/ 1000}.json.base64'; - final data = base64.encode( - utf8.encode( - json.encode( - settingsExportHelper.toJsonExport(), - ), + title: Text(AppLocalizations.of(context).settingsExport), + onTap: () async { + final settingsExportHelper = _buildSettingsExportHelper(context); + + try { + final fileName = + 'nextcloud-neon-settings-${DateTime.now().millisecondsSinceEpoch ~/ 1000}.json.base64'; + final data = base64.encode( + utf8.encode( + json.encode( + settingsExportHelper.toJsonExport(), ), - ); - await saveFileWithPickDialog(fileName, Uint8List.fromList(utf8.encode(data))); - } catch (e, s) { - debugPrint(e.toString()); - debugPrint(s.toString()); - NeonException.showSnackbar(context, e); - } - }, + ), + ); + await saveFileWithPickDialog(fileName, Uint8List.fromList(utf8.encode(data))); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + NeonException.showSnackbar(context, e); + } + }, + ), + CustomSettingsTile( + leading: Icon( + MdiIcons.import, + color: Theme.of(context).colorScheme.primary, ), - CustomSettingsTile( - leading: Icon( - MdiIcons.import, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(AppLocalizations.of(context).settingsImport), - onTap: () async { - try { - final result = await FilePicker.platform.pickFiles( - withData: true, - ); + title: Text(AppLocalizations.of(context).settingsImport), + onTap: () async { + final settingsExportHelper = _buildSettingsExportHelper(context); - if (result == null) { - return; - } + try { + final result = await FilePicker.platform.pickFiles( + withData: true, + ); - if (!result.files.single.path!.endsWith('.json.base64')) { - if (mounted) { - NeonException.showSnackbar( - context, - AppLocalizations.of(context).settingsImportWrongFileExtension, - ); - } - return; - } + if (result == null) { + return; + } - final data = - json.decode(utf8.decode(base64.decode(utf8.decode(result.files.single.bytes!)))); - await settingsExportHelper.applyFromJson(data as Map); - } catch (e, s) { - debugPrint(e.toString()); - debugPrint(s.toString()); - NeonException.showSnackbar(context, e); + if (!result.files.single.path!.endsWith('.json.base64')) { + if (mounted) { + NeonException.showSnackbar( + context, + AppLocalizations.of(context).settingsImportWrongFileExtension, + ); + } + return; } - }, - ), - ], - ), - ], - ), + + final data = json.decode(utf8.decode(base64.decode(utf8.decode(result.files.single.bytes!)))); + + await settingsExportHelper.applyFromJson(data as Map); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + NeonException.showSnackbar(context, e); + } + }, + ), + ], + ), + ], ), ); }, ), ); } + + SettingsExportHelper _buildSettingsExportHelper(final BuildContext context) { + final globalOptions = Provider.of(context); + final accountsBloc = Provider.of(context, listen: false); + final appImplementations = Provider.of>(context); + + return SettingsExportHelper( + globalOptions: globalOptions, + appImplementations: appImplementations, + accountSpecificOptions: accountsBloc.accounts.value.asMap().map( + (final _, final account) => MapEntry(account, accountsBloc.getOptionsFor(account).options), + ), + ); + } } enum SettingsAccountAction { diff --git a/packages/neon/neon/lib/src/router.dart b/packages/neon/neon/lib/src/router.dart index c19d118b..ceb05c0c 100644 --- a/packages/neon/neon/lib/src/router.dart +++ b/packages/neon/neon/lib/src/router.dart @@ -367,8 +367,11 @@ class NextcloudAppSettingsRoute extends GoRouteData { @immutable class SettingsRoute extends GoRouteData { - const SettingsRoute(); + const SettingsRoute({this.initialCategory}); + + /// The initial category to show. + final SettingsCageories? initialCategory; @override - Widget build(final BuildContext context, final GoRouterState state) => const SettingsPage(); + Widget build(final BuildContext context, final GoRouterState state) => SettingsPage(initialCategory: initialCategory); } diff --git a/packages/neon/neon/lib/src/router.g.dart b/packages/neon/neon/lib/src/router.g.dart index be315b41..33f3e75d 100644 --- a/packages/neon/neon/lib/src/router.g.dart +++ b/packages/neon/neon/lib/src/router.g.dart @@ -71,13 +71,21 @@ extension $HomeRouteExtension on HomeRoute { Future push(BuildContext context) => context.push(location); void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); } extension $SettingsRouteExtension on SettingsRoute { - static SettingsRoute _fromState(GoRouterState state) => const SettingsRoute(); + static SettingsRoute _fromState(GoRouterState state) => SettingsRoute( + initialCategory: + _$convertMapValue('initial-category', state.queryParameters, _$SettingsCageoriesEnumMap._$fromName), + ); String get location => GoRouteData.$location( '/settings', + queryParams: { + if (initialCategory != null) 'initial-category': _$SettingsCageoriesEnumMap[initialCategory!], + }, ); void go(BuildContext context) => context.go(location); @@ -85,8 +93,21 @@ extension $SettingsRouteExtension on SettingsRoute { Future push(BuildContext context) => context.push(location); void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); } +const _$SettingsCageoriesEnumMap = { + SettingsCageories.apps: 'apps', + SettingsCageories.theme: 'theme', + SettingsCageories.navigation: 'navigation', + SettingsCageories.pushNotifications: 'push-notifications', + SettingsCageories.startup: 'startup', + SettingsCageories.systemTray: 'system-tray', + SettingsCageories.accounts: 'accounts', + SettingsCageories.other: 'other', +}; + extension $NextcloudAppSettingsRouteExtension on NextcloudAppSettingsRoute { static NextcloudAppSettingsRoute _fromState(GoRouterState state) => NextcloudAppSettingsRoute( appid: state.pathParameters['appid']!, @@ -101,6 +122,8 @@ extension $NextcloudAppSettingsRouteExtension on NextcloudAppSettingsRoute { Future push(BuildContext context) => context.push(location); void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); } extension $_AddAccountRouteExtension on _AddAccountRoute { @@ -115,6 +138,8 @@ extension $_AddAccountRouteExtension on _AddAccountRoute { Future push(BuildContext context) => context.push(location); void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); } extension $_AddAccountFlowRouteExtension on _AddAccountFlowRoute { @@ -206,6 +231,21 @@ extension $AccountSettingsRouteExtension on AccountSettingsRoute { Future push(BuildContext context) => context.push(location); void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +T? _$convertMapValue( + String key, + Map map, + T Function(String) converter, +) { + final value = map[key]; + return value == null ? null : converter(value); +} + +extension on Map { + T _$fromName(String value) => entries.singleWhere((element) => element.value == value).key; } RouteBase get $loginRoute => GoRouteData.$route( @@ -244,6 +284,8 @@ extension $LoginRouteExtension on LoginRoute { Future push(BuildContext context) => context.push(location); void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); } extension $LoginFlowRouteExtension on LoginFlowRoute { @@ -277,6 +319,8 @@ extension $LoginQrcodeRouteExtension on LoginQrcodeRoute { Future push(BuildContext context) => context.push(location); void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); } extension $LoginCheckServerStatusRouteExtension on LoginCheckServerStatusRoute { @@ -296,6 +340,8 @@ extension $LoginCheckServerStatusRouteExtension on LoginCheckServerStatusRoute { Future push(BuildContext context) => context.push(location); void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); } extension $LoginCheckAccountRouteExtension on LoginCheckAccountRoute { @@ -319,4 +365,6 @@ extension $LoginCheckAccountRouteExtension on LoginCheckAccountRoute { Future push(BuildContext context) => context.push(location); void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); } diff --git a/packages/neon/neon/lib/src/settings/widgets/settings_list.dart b/packages/neon/neon/lib/src/settings/widgets/settings_list.dart index cc69d2ff..fb609dd7 100644 --- a/packages/neon/neon/lib/src/settings/widgets/settings_list.dart +++ b/packages/neon/neon/lib/src/settings/widgets/settings_list.dart @@ -1,23 +1,36 @@ import 'package:flutter/material.dart'; -import 'package:intersperse/intersperse.dart'; import 'package:meta/meta.dart'; import 'package:neon/src/settings/widgets/settings_category.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; @internal class SettingsList extends StatelessWidget { const SettingsList({ required this.categories, + this.initialCategory, super.key, }); final List categories; + final String? initialCategory; + + int? _getIndex(final String? initialCategory) { + if (initialCategory == null) { + return null; + } + + final key = Key(initialCategory); + final index = categories.indexWhere((final category) => category.key == key); + + return index != -1 ? index : null; + } @override - Widget build(final BuildContext context) => Scrollbar( - child: ListView( - primary: true, - padding: const EdgeInsets.all(20), - children: categories.cast().intersperse(const Divider()).toList(), - ), + Widget build(final BuildContext context) => ScrollablePositionedList.separated( + padding: const EdgeInsets.all(20), + itemCount: categories.length, + initialScrollIndex: _getIndex(initialCategory) ?? 0, + itemBuilder: (final context, final index) => categories[index], + separatorBuilder: (final context, final index) => const Divider(), ); } diff --git a/packages/neon/neon/pubspec.yaml b/packages/neon/neon/pubspec.yaml index b4616eef..c04a6310 100644 --- a/packages/neon/neon/pubspec.yaml +++ b/packages/neon/neon/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: provider: ^6.0.5 quick_actions: ^1.0.3 rxdart: ^0.27.7 + scrollable_positioned_list: ^0.3.8 shared_preferences: ^2.1.1 sort_box: git: