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/l10n/en.arb b/packages/neon/neon/lib/l10n/en.arb index 2372a31e..f302dfde 100644 --- a/packages/neon/neon/lib/l10n/en.arb +++ b/packages/neon/neon/lib/l10n/en.arb @@ -82,6 +82,7 @@ "settings": "Settings", "settingsApps": "Apps", "settingsAccount": "Account", + "settingsAccountManage": "Manage accounts", "settingsExport": "Export settings", "settingsImport": "Import settings", "settingsImportWrongFileExtension": "Settings import has wrong file extension (has to be .json.base64)", diff --git a/packages/neon/neon/lib/l10n/localizations.dart b/packages/neon/neon/lib/l10n/localizations.dart index 647b4cfa..67ba04cd 100644 --- a/packages/neon/neon/lib/l10n/localizations.dart +++ b/packages/neon/neon/lib/l10n/localizations.dart @@ -341,6 +341,12 @@ abstract class AppLocalizations { /// **'Account'** String get settingsAccount; + /// No description provided for @settingsAccountManage. + /// + /// In en, this message translates to: + /// **'Manage accounts'** + String get settingsAccountManage; + /// No description provided for @settingsExport. /// /// In en, this message translates to: diff --git a/packages/neon/neon/lib/l10n/localizations_en.dart b/packages/neon/neon/lib/l10n/localizations_en.dart index 90ba1a2d..45eb240b 100644 --- a/packages/neon/neon/lib/l10n/localizations_en.dart +++ b/packages/neon/neon/lib/l10n/localizations_en.dart @@ -160,6 +160,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsAccount => 'Account'; + @override + String get settingsAccountManage => 'Manage accounts'; + @override String get settingsExport => 'Export settings'; diff --git a/packages/neon/neon/lib/src/pages/account_settings.dart b/packages/neon/neon/lib/src/pages/account_settings.dart index daebb0fc..f99d90b8 100644 --- a/packages/neon/neon/lib/src/pages/account_settings.dart +++ b/packages/neon/neon/lib/src/pages/account_settings.dart @@ -10,13 +10,14 @@ import 'package:neon/src/settings/widgets/custom_settings_tile.dart'; import 'package:neon/src/settings/widgets/dropdown_button_settings_tile.dart'; import 'package:neon/src/settings/widgets/settings_category.dart'; import 'package:neon/src/settings/widgets/settings_list.dart'; +import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/utils/confirmation_dialog.dart'; import 'package:neon/src/widgets/exception.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart'; import 'package:nextcloud/nextcloud.dart'; class AccountSettingsPage extends StatelessWidget { - AccountSettingsPage({ + const AccountSettingsPage({ required this.bloc, required this.account, super.key, @@ -25,104 +26,115 @@ class AccountSettingsPage extends StatelessWidget { final AccountsBloc bloc; final Account account; - late final _options = bloc.getOptionsFor(account); - late final _userDetailsBloc = bloc.getUserDetailsBlocFor(account); - late final _name = account.client.humanReadableID; - @override - Widget build(final BuildContext context) => Scaffold( - resizeToAvoidBottomInset: false, - appBar: AppBar( - title: Text(_name), - actions: [ - IconButton( - onPressed: () async { - if (await showConfirmationDialog( - context, - AppLocalizations.of(context).accountOptionsRemoveConfirm(account.client.humanReadableID), - )) { - final isActive = bloc.activeAccount.value == account; + Widget build(final BuildContext context) { + final options = bloc.getOptionsFor(account); + final userDetailsBloc = bloc.getUserDetailsBlocFor(account); + final name = account.client.humanReadableID; + + final appBar = AppBar( + title: Text(name), + actions: [ + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context, + AppLocalizations.of(context).accountOptionsRemoveConfirm(account.client.humanReadableID), + )) { + final isActive = bloc.activeAccount.value == account; - bloc.removeAccount(account); + bloc.removeAccount(account); - // ignore: use_build_context_synchronously - if (!context.mounted) { - return; - } + // ignore: use_build_context_synchronously + if (!context.mounted) { + return; + } - if (isActive) { - const HomeRoute().go(context); - } else { - Navigator.of(context).pop(); - } - } - }, - tooltip: AppLocalizations.of(context).accountOptionsRemove, - icon: Icon(MdiIcons.delete), - ), - IconButton( - onPressed: () async { - if (await showConfirmationDialog( - context, - AppLocalizations.of(context).settingsResetForConfirmation(_name), - )) { - await _options.reset(); - } - }, - tooltip: AppLocalizations.of(context).settingsResetFor(_name), - icon: Icon(MdiIcons.cogRefresh), - ), - ], + if (isActive) { + const HomeRoute().go(context); + } else { + Navigator.of(context).pop(); + } + } + }, + tooltip: AppLocalizations.of(context).accountOptionsRemove, + icon: Icon(MdiIcons.delete), ), - body: ResultBuilder.behaviorSubject( - stream: _userDetailsBloc.userDetails, - builder: (final context, final userDetails) => SettingsList( - categories: [ - SettingsCategory( - title: Text(AppLocalizations.of(context).accountOptionsCategoryStorageInfo), - tiles: [ - CustomSettingsTile( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (userDetails.hasData) ...[ - LinearProgressIndicator( - value: userDetails.requireData.quota.relative / 100, - backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.3), - ), - const SizedBox( - height: 10, - ), - Text( - AppLocalizations.of(context).accountOptionsQuotaUsedOf( - filesize(userDetails.requireData.quota.used, 1), - filesize(userDetails.requireData.quota.total, 1), - userDetails.requireData.quota.relative.toString(), - ), - ), - ], - NeonException( - userDetails.error, - onRetry: _userDetailsBloc.refresh, - ), - NeonLinearProgressIndicator( - visible: userDetails.isLoading, + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context, + AppLocalizations.of(context).settingsResetForConfirmation(name), + )) { + await options.reset(); + } + }, + tooltip: AppLocalizations.of(context).settingsResetFor(name), + icon: Icon(MdiIcons.cogRefresh), + ), + ], + ); + + final body = ResultBuilder.behaviorSubject( + stream: userDetailsBloc.userDetails, + builder: (final context, final userDetails) => SettingsList( + categories: [ + SettingsCategory( + title: Text(AppLocalizations.of(context).accountOptionsCategoryStorageInfo), + tiles: [ + CustomSettingsTile( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (userDetails.hasData) ...[ + LinearProgressIndicator( + value: userDetails.requireData.quota.relative / 100, + backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.3), + ), + const SizedBox( + height: 10, + ), + Text( + AppLocalizations.of(context).accountOptionsQuotaUsedOf( + filesize(userDetails.requireData.quota.used, 1), + filesize(userDetails.requireData.quota.total, 1), + userDetails.requireData.quota.relative.toString(), ), - ], + ), + ], + NeonException( + userDetails.error, + onRetry: userDetailsBloc.refresh, ), - ), - ], + NeonLinearProgressIndicator( + visible: userDetails.isLoading, + ), + ], + ), ), - SettingsCategory( - title: Text(AppLocalizations.of(context).optionsCategoryGeneral), - tiles: [ - DropdownButtonSettingsTile( - option: _options.initialApp, - ), - ], + ], + ), + SettingsCategory( + title: Text(AppLocalizations.of(context).optionsCategoryGeneral), + tiles: [ + DropdownButtonSettingsTile( + option: options.initialApp, ), ], ), + ], + ), + ); + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: appBar, + body: Center( + child: ConstrainedBox( + constraints: NeonDialogTheme.of(context).constraints, + child: body, ), - ); + ), + ); + } } diff --git a/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart b/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart index bea73d1b..6d236458 100644 --- a/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart +++ b/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart @@ -8,6 +8,7 @@ import 'package:neon/src/settings/widgets/checkbox_settings_tile.dart'; import 'package:neon/src/settings/widgets/dropdown_button_settings_tile.dart'; import 'package:neon/src/settings/widgets/settings_category.dart'; import 'package:neon/src/settings/widgets/settings_list.dart'; +import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/utils/confirmation_dialog.dart'; class NextcloudAppSettingsPage extends StatelessWidget { @@ -19,53 +20,62 @@ class NextcloudAppSettingsPage extends StatelessWidget { final AppImplementation appImplementation; @override - Widget build(final BuildContext context) => Scaffold( - resizeToAvoidBottomInset: false, - appBar: AppBar( - title: Text(appImplementation.name(context)), - actions: [ - IconButton( - onPressed: () async { - if (await showConfirmationDialog( - context, - AppLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)), - )) { - await appImplementation.options.reset(); - } - }, - tooltip: AppLocalizations.of(context).settingsResetFor(appImplementation.name(context)), - icon: Icon(MdiIcons.cogRefresh), - ), - ], + Widget build(final BuildContext context) { + final appBar = AppBar( + title: Text(appImplementation.name(context)), + actions: [ + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context, + AppLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)), + )) { + await appImplementation.options.reset(); + } + }, + tooltip: AppLocalizations.of(context).settingsResetFor(appImplementation.name(context)), + icon: Icon(MdiIcons.cogRefresh), ), - body: SettingsList( - categories: [ - for (final category in [...appImplementation.options.categories, null]) ...[ - if (appImplementation.options.options - .where((final option) => option.category == category) - .isNotEmpty) ...[ - SettingsCategory( - title: Text( - category != null ? category.name(context) : AppLocalizations.of(context).optionsCategoryOther, - ), - tiles: [ - for (final option - in appImplementation.options.options.where((final option) => option.category == category)) ...[ - if (option is ToggleOption) ...[ - CheckBoxSettingsTile( - option: option, - ), - ] else if (option is SelectOption) ...[ - DropdownButtonSettingsTile( - option: option, - ), - ], - ], + ], + ); + + final body = SettingsList( + categories: [ + for (final category in [...appImplementation.options.categories, null]) ...[ + if (appImplementation.options.options.where((final option) => option.category == category).isNotEmpty) ...[ + SettingsCategory( + title: Text( + category != null ? category.name(context) : AppLocalizations.of(context).optionsCategoryOther, + ), + tiles: [ + for (final option + in appImplementation.options.options.where((final option) => option.category == category)) ...[ + if (option is ToggleOption) ...[ + CheckBoxSettingsTile( + option: option, + ), + ] else if (option is SelectOption) ...[ + DropdownButtonSettingsTile( + option: option, + ), ], - ), + ], ], - ], + ), ], + ], + ], + ); + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: appBar, + body: Center( + child: ConstrainedBox( + constraints: NeonDialogTheme.of(context).constraints, + child: body, ), - ); + ), + ); + } } diff --git a/packages/neon/neon/lib/src/pages/settings.dart b/packages/neon/neon/lib/src/pages/settings.dart index 69ace82c..61d4930e 100644 --- a/packages/neon/neon/lib/src/pages/settings.dart +++ b/packages/neon/neon/lib/src/pages/settings.dart @@ -19,6 +19,7 @@ import 'package:neon/src/settings/widgets/settings_list.dart'; import 'package:neon/src/settings/widgets/settings_tile.dart'; import 'package:neon/src/settings/widgets/text_settings_tile.dart'; import 'package:neon/src/theme/branding.dart'; +import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/utils/confirmation_dialog.dart'; import 'package:neon/src/utils/global_options.dart'; import 'package:neon/src/utils/save_file.dart'; @@ -27,11 +28,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,271 +57,289 @@ 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( - title: Text(AppLocalizations.of(context).settings), - actions: [ - IconButton( - onPressed: () async { - if (await showConfirmationDialog(context, AppLocalizations.of(context).settingsResetAllConfirmation)) { - await globalOptions.reset(); - for (final appImplementation in appImplementations) { - await appImplementation.options.reset(); - } + final appBar = AppBar( + title: Text(AppLocalizations.of(context).settings), + actions: [ + IconButton( + onPressed: () async { + if (await showConfirmationDialog(context, AppLocalizations.of(context).settingsResetAllConfirmation)) { + await globalOptions.reset(); - for (final account in accountsBloc.accounts.value) { - await accountsBloc.getOptionsFor(account).reset(); - } + for (final appImplementation in appImplementations) { + await appImplementation.options.reset(); } - }, - tooltip: AppLocalizations.of(context).settingsResetAll, - icon: Icon(MdiIcons.cogRefresh), - ), - ], - ), - body: StreamBuilder>( - stream: accountsBloc.accounts, - 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, - builder: ( - final context, - final activeAccountSnapshot, - ) => - StreamBuilder( - stream: globalOptions.pushNotificationsEnabled.enabled, - builder: ( - final context, - final pushNotificationsEnabledEnabledSnapshot, - ) => - SettingsList( - categories: [ - 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); - }, - ), - ], - ], - ], - ), - SettingsCategory( - title: Text(AppLocalizations.of(context).optionsCategoryTheme), - tiles: [ - DropdownButtonSettingsTile( - option: globalOptions.themeMode, - ), - CheckBoxSettingsTile( - option: globalOptions.themeOLEDAsDark, - ), - CheckBoxSettingsTile( - option: globalOptions.themeKeepOriginalAccentColor, + + for (final account in accountsBloc.accounts.value) { + await accountsBloc.getOptionsFor(account).reset(); + } + } + }, + tooltip: AppLocalizations.of(context).settingsResetAll, + icon: Icon(MdiIcons.cogRefresh), + ), + ], + ); + final body = StreamBuilder>( + stream: accountsBloc.accounts, + initialData: accountsBloc.accounts.valueOrNull, + builder: ( + final context, + final accountsSnapshot, + ) { + final platform = Provider.of(context, listen: false); + return StreamBuilder( + stream: globalOptions.pushNotificationsEnabled.enabled, + initialData: globalOptions.pushNotificationsEnabled.enabled.valueOrNull, + builder: ( + final context, + final pushNotificationsEnabledEnabledSnapshot, + ) => + 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, ), - SettingsCategory( - title: Text(AppLocalizations.of(context).optionsCategoryNavigation), - tiles: [ - DropdownButtonSettingsTile( - option: globalOptions.navigationMode, - ), - ], + CheckBoxSettingsTile( + option: globalOptions.themeOLEDAsDark, ), - 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, + 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).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).optionsCategoryStartup), - tiles: [ - CheckBoxSettingsTile( - option: globalOptions.startupMinimized, - ), - CheckBoxSettingsTile( - option: globalOptions.startupMinimizeInsteadOfExit, - ), - ], + ), + ], + if (platform.canUseWindowManager) ...[ + SettingsCategory( + title: Text(AppLocalizations.of(context).optionsCategoryStartup), + key: ValueKey(SettingsCageories.startup.name), + 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, - ), - ], + ), + ], + if (platform.canUseWindowManager && platform.canUseSystemTray) ...[ + SettingsCategory( + title: Text(AppLocalizations.of(context).optionsCategorySystemTray), + key: ValueKey(SettingsCageories.systemTray.name), + tiles: [ + CheckBoxSettingsTile( + option: globalOptions.systemTrayEnabled, + ), + CheckBoxSettingsTile( + option: globalOptions.systemTrayHideToTrayWhenMinimized, ), ], - 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).optionsCategoryAccounts), + key: ValueKey(SettingsCageories.accounts.name), + tiles: [ + if (accountsSnapshot.requireData.length > 1) ...[ + CheckBoxSettingsTile( + option: globalOptions.rememberLastUsedAccount, + ), + DropdownButtonSettingsTile( + option: globalOptions.initialAccount, ), ], - 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, - ), - 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(), - ), - ), - ); - 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, - ), - title: Text(AppLocalizations.of(context).settingsImport), - onTap: () async { - try { - final result = await FilePicker.platform.pickFiles( - withData: true, - ); + 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, + ); + }, + ), + CustomSettingsTile( + leading: Icon( + MdiIcons.export, + color: Theme.of(context).colorScheme.primary, + ), + title: Text(AppLocalizations.of(context).settingsExport), + onTap: () async { + final settingsExportHelper = _buildSettingsExportHelper(context); - if (result == null) { - return; - } + 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); + } + }, + ), + CustomSettingsTile( + leading: Icon( + MdiIcons.import, + color: Theme.of(context).colorScheme.primary, + ), + title: Text(AppLocalizations.of(context).settingsImport), + onTap: () async { + final settingsExportHelper = _buildSettingsExportHelper(context); + + 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); + } + }, ), ], ), - ), - ); - }, + ], + ), + ); + }, + ); + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: appBar, + body: Center( + child: ConstrainedBox( + constraints: NeonDialogTheme.of(context).constraints, + child: body, + ), ), ); } + + 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/lib/src/utils/global_popups.dart b/packages/neon/neon/lib/src/utils/global_popups.dart index 05a732a7..28a69bd7 100644 --- a/packages/neon/neon/lib/src/utils/global_popups.dart +++ b/packages/neon/neon/lib/src/utils/global_popups.dart @@ -3,6 +3,7 @@ import 'package:meta/meta.dart'; import 'package:neon/l10n/localizations.dart'; import 'package:neon/src/blocs/first_launch.dart'; import 'package:neon/src/blocs/next_push.dart'; +import 'package:neon/src/pages/settings.dart'; import 'package:neon/src/router.dart'; import 'package:neon/src/utils/global_options.dart'; import 'package:provider/provider.dart'; @@ -43,7 +44,7 @@ class GlobalPopups { action: SnackBarAction( label: AppLocalizations.of(context).settings, onPressed: () { - const SettingsRoute().go(context); + const SettingsRoute(initialCategory: SettingsCageories.pushNotifications).go(context); }, ), ), diff --git a/packages/neon/neon/lib/src/widgets/account_switcher.dart b/packages/neon/neon/lib/src/widgets/account_switcher.dart new file mode 100644 index 00000000..869c60de --- /dev/null +++ b/packages/neon/neon/lib/src/widgets/account_switcher.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/blocs/accounts.dart'; +import 'package:neon/src/pages/settings.dart'; +import 'package:neon/src/router.dart'; +import 'package:neon/src/theme/dialog.dart'; +import 'package:neon/src/widgets/account_tile.dart'; +import 'package:neon/src/widgets/user_avatar.dart'; +import 'package:provider/provider.dart'; + +@internal +class AccountSwitcherButton extends StatelessWidget { + const AccountSwitcherButton({ + super.key, + }); + + Future _onPressed(final BuildContext context) async { + final accountsBloc = Provider.of(context, listen: false); + final accounts = accountsBloc.accounts.value; + final aa = accountsBloc.activeAccount.value!; + + await showDialog( + context: context, + builder: (final context) { + final body = Column( + children: [ + NeonAccountTile( + account: aa, + trailing: const Icon(Icons.check_circle), + onTap: Navigator.of(context).pop, + ), + const Divider(), + if (accounts.length > 1) + Builder( + builder: (final context) { + final inactiveAccounts = List.of(accounts)..removeWhere((final account) => (account.id == aa.id)); + final tiles = inactiveAccounts.map( + (final account) => NeonAccountTile( + account: account, + onTap: () { + accountsBloc.setActiveAccount(account); + Navigator.of(context).pop(); + }, + ), + ); + + return SingleChildScrollView( + child: ListBody( + children: tiles.toList(), + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.settings), + title: Text(AppLocalizations.of(context).settingsAccountManage), + onTap: () { + Navigator.of(context).pop(); + const SettingsRoute(initialCategory: SettingsCageories.accounts).push(context); + }, + ) + ], + ); + + return Dialog( + child: IntrinsicHeight( + child: Container( + padding: const EdgeInsets.all(24), + constraints: NeonDialogTheme.of(context).constraints, + child: body, + ), + ), + ); + }, + ); + } + + @override + Widget build(final BuildContext context) { + final accountsBloc = Provider.of(context, listen: false); + final account = accountsBloc.activeAccount.value!; + + return IconButton( + onPressed: () async => _onPressed(context), + tooltip: AppLocalizations.of(context).settingsAccount, + icon: NeonUserAvatar( + account: account, + ), + ); + } +} diff --git a/packages/neon/neon/lib/src/widgets/account_tile.dart b/packages/neon/neon/lib/src/widgets/account_tile.dart index 22aaad0a..390865c1 100644 --- a/packages/neon/neon/lib/src/widgets/account_tile.dart +++ b/packages/neon/neon/lib/src/widgets/account_tile.dart @@ -49,6 +49,7 @@ class NeonAccountTile extends StatelessWidget { account: account, showStatus: showStatus, ), + trailing: trailing, title: ResultBuilder.behaviorSubject( stream: userDetailsBloc.userDetails, builder: (final context, final userDetails) => Row( diff --git a/packages/neon/neon/lib/src/widgets/app_bar.dart b/packages/neon/neon/lib/src/widgets/app_bar.dart index 383c85dc..8933c57e 100644 --- a/packages/neon/neon/lib/src/widgets/app_bar.dart +++ b/packages/neon/neon/lib/src/widgets/app_bar.dart @@ -9,7 +9,7 @@ import 'package:neon/src/blocs/apps.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/router.dart'; +import 'package:neon/src/widgets/account_switcher.dart'; import 'package:neon/src/widgets/app_implementation_icon.dart'; import 'package:neon/src/widgets/exception.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart'; @@ -77,17 +77,9 @@ class NeonAppBar extends StatelessWidget implements PreferredSizeWidget { ], ], ), - actions: [ - const NotificationIconButton(), - IconButton( - onPressed: () { - AccountSettingsRoute(accountid: account.id).go(context); - }, - tooltip: AppLocalizations.of(context).settingsAccount, - icon: NeonUserAvatar( - account: account, - ), - ), + actions: const [ + NotificationIconButton(), + AccountSwitcherButton(), ], ), ), diff --git a/packages/neon/neon/lib/src/widgets/drawer.dart b/packages/neon/neon/lib/src/widgets/drawer.dart index 488cf416..47779a76 100644 --- a/packages/neon/neon/lib/src/widgets/drawer.dart +++ b/packages/neon/neon/lib/src/widgets/drawer.dart @@ -7,10 +7,8 @@ import 'package:neon/src/bloc/result_builder.dart'; import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/blocs/apps.dart'; import 'package:neon/src/blocs/capabilities.dart'; -import 'package:neon/src/models/account.dart'; import 'package:neon/src/models/app_implementation.dart'; import 'package:neon/src/router.dart'; -import 'package:neon/src/widgets/account_tile.dart'; import 'package:neon/src/widgets/cached_image.dart'; import 'package:neon/src/widgets/drawer_destination.dart'; import 'package:neon/src/widgets/exception.dart'; @@ -54,13 +52,12 @@ class _NeonDrawer extends StatefulWidget { State<_NeonDrawer> createState() => __NeonDrawerState(); } -class __NeonDrawerState extends State<_NeonDrawer> with SingleTickerProviderStateMixin { - late TabController _tabController; +class __NeonDrawerState extends State<_NeonDrawer> { late AccountsBloc _accountsBloc; late AppsBloc _appsBloc; late List _apps; - int _activeApp = 0; + late int _activeApp; @override void initState() { @@ -71,17 +68,6 @@ class __NeonDrawerState extends State<_NeonDrawer> with SingleTickerProviderStat _apps = widget.apps.toList(); _activeApp = _apps.indexWhere((final app) => app.id == _appsBloc.activeApp.valueOrNull?.id); - - _tabController = TabController( - vsync: this, - length: widget.apps.length, - ); - } - - @override - void dispose() { - _tabController.dispose(); - super.dispose(); } void onAppChange(final int index) { @@ -135,85 +121,57 @@ class NeonDrawerHeader extends StatelessWidget { final accountsBloc = Provider.of(context, listen: false); final capabilitiesBloc = accountsBloc.activeCapabilitiesBloc; - final accountSelecor = StreamBuilder>( - stream: accountsBloc.accounts, - builder: (final context, final accountsSnapshot) { - final accounts = accountsSnapshot.data; - if (accounts == null || accounts.length <= 1) { - return const SizedBox.shrink(); + final branding = ResultBuilder.behaviorSubject( + stream: capabilitiesBloc.capabilities, + builder: (final context, final capabilities) { + if (!capabilities.hasData) { + return NeonLinearProgressIndicator( + visible: capabilities.isLoading, + ); } - final items = accounts.map((final account) { - final child = NeonAccountTile( - account: account, - dense: true, - textColor: Theme.of(context).appBarTheme.foregroundColor, + if (capabilities.hasError) { + return NeonException( + capabilities.error, + onRetry: capabilitiesBloc.refresh, ); + } - return DropdownMenuItem( - value: account, - child: child, - ); - }).toList(); - - return DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - dropdownColor: Theme.of(context).colorScheme.primary, - iconEnabledColor: Theme.of(context).colorScheme.onBackground, - value: accountsBloc.activeAccount.value, - items: items, - onChanged: (final account) { - if (account == null) { - return; - } - - accountsBloc.setActiveAccount(account); - }, - ), - ); - }, - ); + final theme = capabilities.requireData.capabilities.theming; - return ResultBuilder.behaviorSubject( - stream: capabilitiesBloc.capabilities, - builder: (final context, final capabilities) => DrawerHeader( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - ), - child: Column( + if (theme == null) { + return const SizedBox(); + } + + return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (capabilities.hasData) ...[ - if (capabilities.requireData.capabilities.theming?.name != null) ...[ - Text( - capabilities.requireData.capabilities.theming!.name!, - style: DefaultTextStyle.of(context).style.copyWith( - color: Theme.of(context).appBarTheme.foregroundColor, - ), - ), - ], - if (capabilities.requireData.capabilities.theming?.logo != null) ...[ - Flexible( - child: NeonCachedImage.url( - url: capabilities.requireData.capabilities.theming!.logo!, - ), - ), - ], - ] else ...[ - NeonException( - capabilities.error, - onRetry: capabilitiesBloc.refresh, + if (theme.name != null) ...[ + Text( + theme.name!, + style: DefaultTextStyle.of(context).style.copyWith( + color: Theme.of(context).appBarTheme.foregroundColor, + ), ), - NeonLinearProgressIndicator( - visible: capabilities.isLoading, + ], + if (theme.logo != null) ...[ + Flexible( + child: NeonCachedImage.url( + url: theme.logo!, + ), ), ], - accountSelecor, ], - ), + ); + }, + ); + + return DrawerHeader( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, ), + child: branding, ); } } 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: