From 08c6d21399b7ca138e60cd032cd13d49b1ca1e11 Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Mon, 23 Oct 2023 12:07:26 +0200 Subject: [PATCH] feat(neon): redesign settings and implement cupertino style Signed-off-by: Nikolas Rimikis --- packages/app/pubspec.lock | 8 + .../neon/lib/src/pages/account_settings.dart | 105 ++-- .../neon/neon/lib/src/pages/settings.dart | 505 ++++++++++-------- .../widgets/option_settings_tile.dart | 212 +++++++- .../widgets/select_settings_tile.dart | 53 -- .../settings/widgets/settings_category.dart | 63 ++- .../src/settings/widgets/settings_list.dart | 21 +- .../widgets/toggle_settings_tile.dart | 23 - packages/neon/neon/lib/src/theme/theme.dart | 5 + packages/neon/neon/pubspec.yaml | 1 + 10 files changed, 605 insertions(+), 391 deletions(-) delete mode 100644 packages/neon/neon/lib/src/settings/widgets/select_settings_tile.dart delete mode 100644 packages/neon/neon/lib/src/settings/widgets/toggle_settings_tile.dart diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock index 9cc847a8..19896f14 100644 --- a/packages/app/pubspec.lock +++ b/packages/app/pubspec.lock @@ -185,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.17.3" + cupertino_icons: + dependency: transitive + description: + name: cupertino_icons + sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d + url: "https://pub.dev" + source: hosted + version: "1.0.6" dbus: dependency: transitive description: diff --git a/packages/neon/neon/lib/src/pages/account_settings.dart b/packages/neon/neon/lib/src/pages/account_settings.dart index 907cf879..364869ba 100644 --- a/packages/neon/neon/lib/src/pages/account_settings.dart +++ b/packages/neon/neon/lib/src/pages/account_settings.dart @@ -8,13 +8,13 @@ import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/models/account.dart'; import 'package:neon/src/router.dart'; import 'package:neon/src/settings/widgets/custom_settings_tile.dart'; -import 'package:neon/src/settings/widgets/select_settings_tile.dart'; +import 'package:neon/src/settings/widgets/option_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/adaptive.dart'; import 'package:neon/src/utils/confirmation_dialog.dart'; import 'package:neon/src/widgets/error.dart'; -import 'package:neon/src/widgets/linear_progress_indicator.dart'; import 'package:nextcloud/provisioning_api.dart' as provisioning_api; /// Account settings page. @@ -84,61 +84,62 @@ class AccountSettingsPage extends StatelessWidget { ], ); - final body = ResultBuilder.behaviorSubject( - subject: userDetailsBloc.userDetails, - builder: (final context, final userDetails) { - final quotaRelative = userDetails.data?.quota.relative?.$int ?? userDetails.data?.quota.relative?.$double ?? 0; - final quotaTotal = userDetails.data?.quota.total?.$int ?? userDetails.data?.quota.total?.$double ?? 0; - final quotaUsed = userDetails.data?.quota.used?.$int ?? userDetails.data?.quota.used?.$double ?? 0; + final body = SettingsList( + categories: [ + SettingsCategory( + title: Text(NeonLocalizations.of(context).accountOptionsCategoryStorageInfo), + tiles: [ + ResultBuilder.behaviorSubject( + subject: userDetailsBloc.userDetails, + builder: (final context, final userDetails) { + if (userDetails.hasError) { + return NeonError( + userDetails.error ?? 'Something went wrong', + type: NeonErrorType.listTile, + onRetry: userDetailsBloc.refresh, + ); + } - return SettingsList( - categories: [ - SettingsCategory( - title: Text(NeonLocalizations.of(context).accountOptionsCategoryStorageInfo), - tiles: [ - CustomSettingsTile( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (userDetails.hasData) ...[ - LinearProgressIndicator( - value: quotaRelative / 100, - backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.3), - ), - const SizedBox( - height: 10, - ), - Text( - NeonLocalizations.of(context).accountOptionsQuotaUsedOf( - filesize(quotaUsed, 1), - filesize(quotaTotal, 1), - quotaRelative.toString(), - ), - ), - ], - NeonError( - userDetails.error, - onRetry: userDetailsBloc.refresh, - ), - NeonLinearProgressIndicator( - visible: userDetails.isLoading, - ), - ], + double? value; + Widget? subtitle; + if (userDetails.hasData) { + final quotaRelative = + userDetails.data?.quota.relative?.$int ?? userDetails.data?.quota.relative?.$double ?? 0; + final quotaTotal = userDetails.data?.quota.total?.$int ?? userDetails.data?.quota.total?.$double ?? 0; + final quotaUsed = userDetails.data?.quota.used?.$int ?? userDetails.data?.quota.used?.$double ?? 0; + + value = quotaRelative / 100; + subtitle = Text( + NeonLocalizations.of(context).accountOptionsQuotaUsedOf( + filesize(quotaUsed, 1), + filesize(quotaTotal, 1), + quotaRelative.toString(), + ), + ); + } + + return CustomSettingsTile( + title: LinearProgressIndicator( + value: value, + minHeight: isCupertino(context) ? 15 : null, + borderRadius: BorderRadius.circular(isCupertino(context) ? 5 : 3), + backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.3), ), - ), - ], + subtitle: subtitle, + ); + }, ), - SettingsCategory( - title: Text(NeonLocalizations.of(context).optionsCategoryGeneral), - tiles: [ - SelectSettingsTile( - option: options.initialApp, - ), - ], + ], + ), + SettingsCategory( + title: Text(NeonLocalizations.of(context).optionsCategoryGeneral), + tiles: [ + SelectSettingsTile( + option: options.initialApp, ), ], - ); - }, + ), + ], ); return Scaffold( diff --git a/packages/neon/neon/lib/src/pages/settings.dart b/packages/neon/neon/lib/src/pages/settings.dart index 14e99757..c2eb3098 100644 --- a/packages/neon/neon/lib/src/pages/settings.dart +++ b/packages/neon/neon/lib/src/pages/settings.dart @@ -1,4 +1,5 @@ import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:meta/meta.dart'; @@ -10,14 +11,14 @@ import 'package:neon/src/router.dart'; import 'package:neon/src/settings/utils/settings_export_helper.dart'; import 'package:neon/src/settings/widgets/account_settings_tile.dart'; import 'package:neon/src/settings/widgets/custom_settings_tile.dart'; -import 'package:neon/src/settings/widgets/select_settings_tile.dart'; +import 'package:neon/src/settings/widgets/option_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/settings/widgets/settings_tile.dart'; import 'package:neon/src/settings/widgets/text_settings_tile.dart'; -import 'package:neon/src/settings/widgets/toggle_settings_tile.dart'; import 'package:neon/src/theme/branding.dart'; import 'package:neon/src/theme/dialog.dart'; +import 'package:neon/src/utils/adaptive.dart'; import 'package:neon/src/utils/confirmation_dialog.dart'; import 'package:neon/src/utils/global_options.dart'; import 'package:neon/src/utils/provider.dart'; @@ -112,259 +113,194 @@ class _SettingsPageState extends State { ), ], ); - final body = StreamBuilder( - stream: accountsBloc.accounts, - initialData: accountsBloc.accounts.valueOrNull, - builder: ( - final context, - final accountsSnapshot, - ) => - ValueListenableBuilder( - valueListenable: globalOptions.pushNotificationsEnabled, - builder: ( - final context, - final _, - final __, - ) => - SettingsList( - initialCategory: widget.initialCategory?.name, - categories: [ - SettingsCategory( - title: Text(NeonLocalizations.of(context).settingsApps), - key: ValueKey(SettingsCategories.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(NeonLocalizations.of(context).optionsCategoryTheme), - key: ValueKey(SettingsCategories.theme.name), - tiles: [ - SelectSettingsTile( - option: globalOptions.themeMode, - ), - ToggleSettingsTile( - option: globalOptions.themeOLEDAsDark, - ), - ToggleSettingsTile( - option: globalOptions.themeUseNextcloudTheme, + final body = SettingsList( + initialCategory: widget.initialCategory?.name, + categories: [ + SettingsCategory( + hasLeading: true, + title: Text(NeonLocalizations.of(context).settingsApps), + key: ValueKey(SettingsCategories.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(NeonLocalizations.of(context).optionsCategoryTheme), + key: ValueKey(SettingsCategories.theme.name), + tiles: [ + SelectSettingsTile( + option: globalOptions.themeMode, ), - SettingsCategory( - title: Text(NeonLocalizations.of(context).optionsCategoryNavigation), - key: ValueKey(SettingsCategories.navigation.name), - tiles: [ - SelectSettingsTile( - option: globalOptions.navigationMode, - ), - ], + ToggleSettingsTile( + option: globalOptions.themeOLEDAsDark, + ), + ToggleSettingsTile( + option: globalOptions.themeUseNextcloudTheme, + ), + ], + ), + SettingsCategory( + title: Text(NeonLocalizations.of(context).optionsCategoryNavigation), + key: ValueKey(SettingsCategories.navigation.name), + tiles: [ + SelectSettingsTile( + option: globalOptions.navigationMode, ), - if (NeonPlatform.instance.canUsePushNotifications) ...[ - SettingsCategory( - title: Text(NeonLocalizations.of(context).optionsCategoryPushNotifications), - key: ValueKey(SettingsCategories.pushNotifications.name), - tiles: [ - if (!globalOptions.pushNotificationsEnabled.enabled) ...[ - TextSettingsTile( - text: NeonLocalizations.of(context).globalOptionsPushNotificationsEnabledDisabledNotice, - style: TextStyle( - fontWeight: FontWeight.w600, - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.error, - ), - ), - ], - ToggleSettingsTile( - option: globalOptions.pushNotificationsEnabled, - ), - SelectSettingsTile( - option: globalOptions.pushNotificationsDistributor, - ), - ], + ], + ), + if (NeonPlatform.instance.canUsePushNotifications) buildNotificationsCategory(), + if (NeonPlatform.instance.canUseWindowManager) ...[ + SettingsCategory( + title: Text(NeonLocalizations.of(context).optionsCategoryStartup), + key: ValueKey(SettingsCategories.startup.name), + tiles: [ + ToggleSettingsTile( + option: globalOptions.startupMinimized, ), - ], - if (NeonPlatform.instance.canUseWindowManager) ...[ - SettingsCategory( - title: Text(NeonLocalizations.of(context).optionsCategoryStartup), - key: ValueKey(SettingsCategories.startup.name), - tiles: [ - ToggleSettingsTile( - option: globalOptions.startupMinimized, - ), - ToggleSettingsTile( - option: globalOptions.startupMinimizeInsteadOfExit, - ), - ], + ToggleSettingsTile( + option: globalOptions.startupMinimizeInsteadOfExit, ), ], - if (NeonPlatform.instance.canUseWindowManager && NeonPlatform.instance.canUseSystemTray) ...[ - SettingsCategory( - title: Text(NeonLocalizations.of(context).optionsCategorySystemTray), - key: ValueKey(SettingsCategories.systemTray.name), - tiles: [ - ToggleSettingsTile( - option: globalOptions.systemTrayEnabled, - ), - ToggleSettingsTile( - option: globalOptions.systemTrayHideToTrayWhenMinimized, - ), - ], + ), + ], + if (NeonPlatform.instance.canUseWindowManager && NeonPlatform.instance.canUseSystemTray) ...[ + SettingsCategory( + title: Text(NeonLocalizations.of(context).optionsCategorySystemTray), + key: ValueKey(SettingsCategories.systemTray.name), + tiles: [ + ToggleSettingsTile( + option: globalOptions.systemTrayEnabled, + ), + ToggleSettingsTile( + option: globalOptions.systemTrayHideToTrayWhenMinimized, ), ], - SettingsCategory( - title: Text(NeonLocalizations.of(context).optionsCategoryAccounts), - key: ValueKey(SettingsCategories.accounts.name), - tiles: [ - if (accountsSnapshot.requireData.length > 1) ...[ - ToggleSettingsTile( - option: globalOptions.rememberLastUsedAccount, - ), - SelectSettingsTile( - 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: const Icon(MdiIcons.accountPlus), - label: Text(NeonLocalizations.of(context).globalOptionsAccountsAdd), - ), + ), + ], + ...buildAccountCategory(), + SettingsCategory( + hasLeading: true, + title: Text(NeonLocalizations.of(context).optionsCategoryOther), + key: ValueKey(SettingsCategories.other.name), + tiles: [ + if (branding.sourceCodeURL != null) + CustomSettingsTile( + leading: Icon( + Icons.code, + color: Theme.of(context).colorScheme.primary, ), - ], - ), - SettingsCategory( - title: Text(NeonLocalizations.of(context).optionsCategoryOther), - key: ValueKey(SettingsCategories.other.name), - tiles: [ - if (branding.sourceCodeURL != null) - CustomSettingsTile( - leading: Icon( - Icons.code, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(NeonLocalizations.of(context).sourceCode), - onTap: () async { - await launchUrlString( - branding.sourceCodeURL!, - mode: LaunchMode.externalApplication, - ); - }, - ), - if (branding.issueTrackerURL != null) - CustomSettingsTile( - leading: Icon( - MdiIcons.textBoxEditOutline, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(NeonLocalizations.of(context).issueTracker), - onTap: () async { - await launchUrlString( - branding.issueTrackerURL!, - mode: LaunchMode.externalApplication, - ); - }, - ), - CustomSettingsTile( - leading: Icon( - MdiIcons.scriptText, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(NeonLocalizations.of(context).licenses), - onTap: () async { - showLicensePage( - context: context, - applicationName: branding.name, - applicationIcon: branding.logo, - applicationLegalese: branding.legalese, - applicationVersion: NeonProvider.of(context).version, - ); - }, + title: Text(NeonLocalizations.of(context).sourceCode), + onTap: () async { + await launchUrlString( + branding.sourceCodeURL!, + mode: LaunchMode.externalApplication, + ); + }, + ), + if (branding.issueTrackerURL != null) + CustomSettingsTile( + leading: Icon( + MdiIcons.textBoxEditOutline, + color: Theme.of(context).colorScheme.primary, ), - CustomSettingsTile( - leading: Icon( - MdiIcons.export, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(NeonLocalizations.of(context).settingsExport), - onTap: () async { - final settingsExportHelper = _buildSettingsExportHelper(context); - - try { - final fileName = 'nextcloud-neon-settings-${DateTime.now().millisecondsSinceEpoch ~/ 1000}.json'; + title: Text(NeonLocalizations.of(context).issueTracker), + onTap: () async { + await launchUrlString( + branding.issueTrackerURL!, + mode: LaunchMode.externalApplication, + ); + }, + ), + CustomSettingsTile( + leading: Icon( + MdiIcons.scriptText, + color: Theme.of(context).colorScheme.primary, + ), + title: Text(NeonLocalizations.of(context).licenses), + onTap: () async { + showLicensePage( + context: context, + applicationName: branding.name, + applicationIcon: branding.logo, + applicationLegalese: branding.legalese, + applicationVersion: NeonProvider.of(context).version, + ); + }, + ), + CustomSettingsTile( + leading: Icon( + MdiIcons.export, + color: Theme.of(context).colorScheme.primary, + ), + title: Text(NeonLocalizations.of(context).settingsExport), + onTap: () async { + final settingsExportHelper = _buildSettingsExportHelper(context); - final data = settingsExportHelper.exportToFile(); - await saveFileWithPickDialog(fileName, data); - } catch (e, s) { - debugPrint(e.toString()); - debugPrint(s.toString()); - if (mounted) { - NeonError.showSnackbar(context, e); - } - } - }, - ), - CustomSettingsTile( - leading: Icon( - MdiIcons.import, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(NeonLocalizations.of(context).settingsImport), - onTap: () async { - final settingsExportHelper = _buildSettingsExportHelper(context); + try { + final fileName = 'nextcloud-neon-settings-${DateTime.now().millisecondsSinceEpoch ~/ 1000}.json'; - try { - final result = await FilePicker.platform.pickFiles( - withReadStream: true, - ); + final data = settingsExportHelper.exportToFile(); + await saveFileWithPickDialog(fileName, data); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + if (mounted) { + NeonError.showSnackbar(context, e); + } + } + }, + ), + CustomSettingsTile( + leading: Icon( + MdiIcons.import, + color: Theme.of(context).colorScheme.primary, + ), + title: Text(NeonLocalizations.of(context).settingsImport), + onTap: () async { + final settingsExportHelper = _buildSettingsExportHelper(context); - if (result == null) { - return; - } + try { + final result = await FilePicker.platform.pickFiles( + withReadStream: true, + ); - if (!result.files.single.path!.endsWith('.json')) { - if (mounted) { - NeonError.showSnackbar( - context, - NeonLocalizations.of(context).settingsImportWrongFileExtension, - ); - } - return; - } + if (result == null) { + return; + } - await settingsExportHelper.applyFromFile(result.files.single.readStream); - } catch (e, s) { - debugPrint(e.toString()); - debugPrint(s.toString()); - if (mounted) { - NeonError.showSnackbar(context, e); - } + if (!result.files.single.path!.endsWith('.json')) { + if (mounted) { + NeonError.showSnackbar( + context, + NeonLocalizations.of(context).settingsImportWrongFileExtension, + ); } - }, - ), - ], + return; + } + + await settingsExportHelper.applyFromFile(result.files.single.readStream); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + if (mounted) { + NeonError.showSnackbar(context, e); + } + } + }, ), ], ), - ), + ], ); return Scaffold( @@ -381,6 +317,105 @@ class _SettingsPageState extends State { ); } + Widget buildNotificationsCategory() { + final globalOptions = NeonProvider.of(context); + + return ValueListenableBuilder( + valueListenable: globalOptions.pushNotificationsEnabled, + builder: (final context, final _, final __) => SettingsCategory( + title: Text(NeonLocalizations.of(context).optionsCategoryPushNotifications), + key: ValueKey(SettingsCategories.pushNotifications.name), + tiles: [ + if (!globalOptions.pushNotificationsEnabled.enabled) + TextSettingsTile( + text: NeonLocalizations.of(context).globalOptionsPushNotificationsEnabledDisabledNotice, + style: TextStyle( + fontWeight: FontWeight.w600, + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.error, + ), + ), + ToggleSettingsTile( + option: globalOptions.pushNotificationsEnabled, + ), + SelectSettingsTile( + option: globalOptions.pushNotificationsDistributor, + ), + ], + ), + ); + } + + Iterable buildAccountCategory() sync* { + final globalOptions = NeonProvider.of(context); + final accountsBloc = NeonProvider.of(context); + final accounts = accountsBloc.accounts.value; + final hasMultipleAccounts = accounts.length > 1; + + final title = Text(NeonLocalizations.of(context).optionsCategoryAccounts); + final key = ValueKey(SettingsCategories.accounts.name); + final accountTiles = accounts.map( + (final account) => AccountSettingsTile( + account: account, + onTap: () => AccountSettingsRoute(accountID: account.id).go(context), + ), + ); + final rememberLastUsedAccountTile = ToggleSettingsTile( + option: globalOptions.rememberLastUsedAccount, + ); + final initialAccountTile = SelectSettingsTile( + option: globalOptions.initialAccount, + ); + + if (isCupertino(context)) { + if (hasMultipleAccounts) { + yield SettingsCategory( + title: title, + key: key, + tiles: [ + rememberLastUsedAccountTile, + initialAccountTile, + ], + ); + } + final addAccountTile = CustomSettingsTile( + leading: const Icon( + CupertinoIcons.add, + ), + title: Text(NeonLocalizations.of(context).globalOptionsAccountsAdd), + onTap: () async => const LoginRoute().push(context), + ); + + yield CupertinoListSection.insetGrouped( + header: !hasMultipleAccounts ? title : null, + key: !hasMultipleAccounts ? key : null, + children: [ + ...accountTiles, + addAccountTile, + ], + ); + } else { + final addAccountTile = CustomSettingsTile( + title: ElevatedButton.icon( + onPressed: () async => const LoginRoute().push(context), + icon: const Icon(MdiIcons.accountPlus), + label: Text(NeonLocalizations.of(context).globalOptionsAccountsAdd), + ), + ); + + yield SettingsCategory( + title: title, + key: key, + tiles: [ + if (hasMultipleAccounts) rememberLastUsedAccountTile, + if (hasMultipleAccounts) initialAccountTile, + ...accountTiles, + addAccountTile, + ], + ); + } + } + SettingsExportHelper _buildSettingsExportHelper(final BuildContext context) { final globalOptions = NeonProvider.of(context); final accountsBloc = NeonProvider.of(context); diff --git a/packages/neon/neon/lib/src/settings/widgets/option_settings_tile.dart b/packages/neon/neon/lib/src/settings/widgets/option_settings_tile.dart index fc329a46..40dd9518 100644 --- a/packages/neon/neon/lib/src/settings/widgets/option_settings_tile.dart +++ b/packages/neon/neon/lib/src/settings/widgets/option_settings_tile.dart @@ -1,9 +1,13 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:neon/settings.dart'; -import 'package:neon/src/settings/widgets/select_settings_tile.dart'; +import 'package:neon/src/settings/models/option.dart'; import 'package:neon/src/settings/widgets/settings_tile.dart'; -import 'package:neon/src/settings/widgets/toggle_settings_tile.dart'; +import 'package:neon/src/theme/dialog.dart'; +import 'package:neon/src/utils/adaptive.dart'; +import 'package:neon/src/widgets/adaptive_widgets/list_tile.dart'; +import 'package:neon/utils.dart'; @internal class OptionSettingsTile extends InputSettingsTile { @@ -18,3 +22,205 @@ class OptionSettingsTile extends InputSettingsTile { SelectOption() => SelectSettingsTile(option: option as SelectOption), }; } + +@internal +class ToggleSettingsTile extends InputSettingsTile { + const ToggleSettingsTile({ + required super.option, + super.key, + }); + + @override + Widget build(final BuildContext context) => ValueListenableBuilder( + valueListenable: option, + builder: (final context, final value, final child) => SwitchListTile.adaptive( + title: child, + value: value, + onChanged: option.enabled ? (final value) => option.value = value : null, + ), + child: Text(option.label(context)), + ); +} + +@internal +class SelectSettingsTile extends InputSettingsTile> { + const SelectSettingsTile({ + required super.option, + this.immediateSelection = true, + super.key, + }); + + final bool immediateSelection; + + @override + Widget build(final BuildContext context) => ValueListenableBuilder( + valueListenable: option, + builder: (final context, final value, final child) { + final valueText = option.values[value]?.call(context); + return AdaptiveListTile.additionalInfo( + enabled: option.enabled, + title: child!, + additionalInfo: valueText != null ? Text(valueText) : null, + onTap: () async { + assert(option.enabled, 'Option must be enabled to handle taps'); + final showCupertino = isCupertino(context); + + if (showCupertino) { + await Navigator.push( + context, + CupertinoPageRoute( + builder: (final _) => SelectSettingsTileScreen(option: option), + ), + ); + } else { + final result = await showAdaptiveDialog( + context: context, + builder: (final context) => SelectSettingsTileDialog( + option: option, + immediateSelection: immediateSelection, + ), + ); + + if (result != null) { + option.value = result; + } + } + }, + ); + }, + child: Text( + option.label(context), + ), + ); +} + +@internal +class SelectSettingsTileDialog extends StatefulWidget { + const SelectSettingsTileDialog({ + required this.option, + this.immediateSelection = true, + super.key, + }); + + final SelectOption option; + + final bool immediateSelection; + + @override + State> createState() => _SelectSettingsTileDialogState(); +} + +class _SelectSettingsTileDialogState extends State> { + late T value; + late SelectOption option = widget.option; + + @override + void initState() { + value = option.value; + + super.initState(); + } + + void submit() => Navigator.pop(context, value); + void cancel() => Navigator.pop(context); + + @override + Widget build(final BuildContext context) { + final content = SingleChildScrollView( + child: Column( + children: [ + ...option.values.keys.map( + (final k) => RadioListTile( + title: Text( + option.values[k]!(context), + overflow: TextOverflow.ellipsis, + ), + value: k, + groupValue: value, + onChanged: (final value) { + setState(() { + this.value = value as T; + }); + + if (widget.immediateSelection) { + submit(); + } + }, + ), + ), + ], + ), + ); + + final actions = [ + TextButton( + onPressed: cancel, + child: Text(NeonLocalizations.of(context).actionClose), + ), + TextButton( + onPressed: submit, + child: Text(NeonLocalizations.of(context).actionContinue), + ), + ]; + + return AlertDialog( + title: Text( + option.label(context), + ), + content: content, + actions: widget.immediateSelection ? null : actions, + ); + } +} + +@internal +class SelectSettingsTileScreen extends StatelessWidget { + const SelectSettingsTileScreen({ + required this.option, + super.key, + }); + + final SelectOption option; + + @override + Widget build(final BuildContext context) { + final dialogTheme = NeonDialogTheme.of(context); + + final selector = ValueListenableBuilder( + valueListenable: option, + builder: (final context, final value, final child) => CupertinoListSection.insetGrouped( + hasLeading: false, + header: child, + children: [ + ...option.values.keys.map( + (final k) => RadioListTile.adaptive( + controlAffinity: ListTileControlAffinity.trailing, + title: Text( + option.values[k]!(context), + overflow: TextOverflow.ellipsis, + ), + value: k, + groupValue: value, + onChanged: (final value) { + option.value = value as T; + }, + ), + ), + ], + ), + child: Text( + option.label(context), + ), + ); + + return Scaffold( + appBar: AppBar(), + body: Center( + child: ConstrainedBox( + constraints: dialogTheme.constraints, + child: selector, + ), + ), + ); + } +} diff --git a/packages/neon/neon/lib/src/settings/widgets/select_settings_tile.dart b/packages/neon/neon/lib/src/settings/widgets/select_settings_tile.dart deleted file mode 100644 index 3b06eb19..00000000 --- a/packages/neon/neon/lib/src/settings/widgets/select_settings_tile.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:meta/meta.dart'; -import 'package:neon/src/settings/models/option.dart'; -import 'package:neon/src/settings/widgets/settings_tile.dart'; - -@internal -class SelectSettingsTile extends InputSettingsTile> { - const SelectSettingsTile({ - required super.option, - super.key, - }); - - @override - Widget build(final BuildContext context) => ValueListenableBuilder( - valueListenable: option, - builder: (final context, final value, final child) => LayoutBuilder( - builder: (final context, final constraints) => ListTile( - enabled: option.enabled, - title: child, - trailing: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constraints.maxWidth * 0.5, - ), - child: IntrinsicWidth( - child: DropdownButton( - isExpanded: true, - value: value, - items: option.values.keys - .map( - (final k) => DropdownMenuItem( - value: k, - child: Text( - option.values[k]!(context), - overflow: TextOverflow.ellipsis, - ), - ), - ) - .toList(), - onChanged: option.enabled - ? (final value) { - option.value = value as T; - } - : null, - ), - ), - ), - ), - ), - child: Text( - option.label(context), - ), - ); -} diff --git a/packages/neon/neon/lib/src/settings/widgets/settings_category.dart b/packages/neon/neon/lib/src/settings/widgets/settings_category.dart index fd2b1374..41c3d8eb 100644 --- a/packages/neon/neon/lib/src/settings/widgets/settings_category.dart +++ b/packages/neon/neon/lib/src/settings/widgets/settings_category.dart @@ -1,41 +1,72 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:intersperse/intersperse.dart'; import 'package:meta/meta.dart'; -import 'package:neon/src/settings/widgets/settings_tile.dart'; +import 'package:neon/src/utils/adaptive.dart'; @internal class SettingsCategory extends StatelessWidget { const SettingsCategory({ required this.tiles, this.title, + this.footer, + this.hasLeading = false, super.key, }); final Widget? title; - final List tiles; + final List tiles; + final Widget? footer; + final bool hasLeading; @override Widget build(final BuildContext context) { + if (isCupertino(context)) { + return CupertinoListSection.insetGrouped( + hasLeading: hasLeading, + header: title, + footer: footer, + children: tiles, + ); + } else { + return MaterialSettingsCategory( + header: title, + children: tiles, + ); + } + } +} + +class MaterialSettingsCategory extends StatelessWidget { + const MaterialSettingsCategory({ + required this.children, + this.header, + super.key, + }); + + final Widget? header; + final List children; + + @override + Widget build(final BuildContext context) { + final theme = Theme.of(context); final textTheme = Theme.of(context).textTheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (title != null) - DefaultTextStyle( - style: textTheme.titleMedium!.copyWith( - fontWeight: FontWeight.bold, + if (header != null) + Padding( + padding: const EdgeInsets.only(top: 25, bottom: 5), + child: DefaultTextStyle( + style: textTheme.titleMedium!.copyWith( + color: theme.colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + child: header!, ), - child: title!, ), - ...tiles, - ] - .intersperse( - const SizedBox( - height: 10, - ), - ) - .toList(), + ...children, + ], ); } } 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 55642062..5cc9632b 100644 --- a/packages/neon/neon/lib/src/settings/widgets/settings_list.dart +++ b/packages/neon/neon/lib/src/settings/widgets/settings_list.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:neon/src/settings/widgets/settings_category.dart'; +import 'package:neon/src/utils/adaptive.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; @visibleForTesting @@ -10,7 +10,7 @@ class SettingsList extends StatelessWidget { super.key, }); - final List categories; + final List categories; final String? initialCategory; int? _getIndex(final String? initialCategory) { @@ -25,11 +25,14 @@ class SettingsList extends StatelessWidget { } @override - 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(), - ); + Widget build(final BuildContext context) { + final hasPadding = !isCupertino(context); + + return ScrollablePositionedList.builder( + padding: hasPadding ? const EdgeInsets.symmetric(horizontal: 20) : null, + itemCount: categories.length, + initialScrollIndex: _getIndex(initialCategory) ?? 0, + itemBuilder: (final context, final index) => categories[index], + ); + } } diff --git a/packages/neon/neon/lib/src/settings/widgets/toggle_settings_tile.dart b/packages/neon/neon/lib/src/settings/widgets/toggle_settings_tile.dart deleted file mode 100644 index d334c599..00000000 --- a/packages/neon/neon/lib/src/settings/widgets/toggle_settings_tile.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:meta/meta.dart'; -import 'package:neon/src/settings/models/option.dart'; -import 'package:neon/src/settings/widgets/settings_tile.dart'; - -@internal -class ToggleSettingsTile extends InputSettingsTile { - const ToggleSettingsTile({ - required super.option, - super.key, - }); - - @override - Widget build(final BuildContext context) => ValueListenableBuilder( - valueListenable: option, - builder: (final context, final value, final child) => SwitchListTile.adaptive( - title: child, - value: value, - onChanged: option.enabled ? (final value) => option.value = value : null, - ), - child: Text(option.label(context)), - ); -} diff --git a/packages/neon/neon/lib/src/theme/theme.dart b/packages/neon/neon/lib/src/theme/theme.dart index 5c7cee8e..4bcf9f32 100644 --- a/packages/neon/neon/lib/src/theme/theme.dart +++ b/packages/neon/neon/lib/src/theme/theme.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:neon/src/theme/colors.dart'; @@ -90,6 +91,10 @@ class AppTheme { dividerTheme: _dividerTheme, scrollbarTheme: _scrollbarTheme, inputDecorationTheme: _inputDecorationTheme, + cupertinoOverrideTheme: CupertinoThemeData( + brightness: brightness, + textTheme: const CupertinoTextThemeData(), + ), extensions: [ neonTheme, ...?appThemes, diff --git a/packages/neon/neon/pubspec.yaml b/packages/neon/neon/pubspec.yaml index 784339b7..a8ace978 100644 --- a/packages/neon/neon/pubspec.yaml +++ b/packages/neon/neon/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: collection: ^1.0.0 crypto: ^3.0.0 + cupertino_icons: ^1.0.0 dynamic_color: ^1.0.0 file_picker: ^6.0.0 filesize: ^2.0.0