diff --git a/.cspell/dart_flutter.txt b/.cspell/dart_flutter.txt index f1f2d865..15af3041 100644 --- a/.cspell/dart_flutter.txt +++ b/.cspell/dart_flutter.txt @@ -1,4 +1,5 @@ autofocus +cupertino endtemplate expando gapless 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/account_settings_tile.dart b/packages/neon/neon/lib/src/settings/widgets/account_settings_tile.dart index b444ab16..1c8ce8f4 100644 --- a/packages/neon/neon/lib/src/settings/widgets/account_settings_tile.dart +++ b/packages/neon/neon/lib/src/settings/widgets/account_settings_tile.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:neon/src/models/account.dart'; @@ -18,11 +20,11 @@ class AccountSettingsTile extends SettingsTile { /// {@macro neon.AccountTile.account} final Account account; - /// {@macro neon.AccountTile.trailing} + /// {@macro neon.AdaptiveListTile.trailing} final Widget? trailing; - /// {@macro neon.AccountTile.onTap} - final GestureTapCallback? onTap; + /// {@macro neon.AdaptiveListTile.onTap} + final FutureOr Function()? onTap; @override Widget build(final BuildContext context) => NeonAccountTile( diff --git a/packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart b/packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart index 6e216943..6161651b 100644 --- a/packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart +++ b/packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart @@ -1,11 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:neon/src/settings/widgets/settings_tile.dart'; +import 'package:neon/src/widgets/adaptive_widgets/list_tile.dart'; @internal class CustomSettingsTile extends SettingsTile { const CustomSettingsTile({ - this.title, + required this.title, this.subtitle, this.leading, this.trailing, @@ -13,14 +16,14 @@ class CustomSettingsTile extends SettingsTile { super.key, }); - final Widget? title; + final Widget title; final Widget? subtitle; final Widget? leading; final Widget? trailing; - final GestureTapCallback? onTap; + final FutureOr Function()? onTap; @override - Widget build(final BuildContext context) => ListTile( + Widget build(final BuildContext context) => AdaptiveListTile( title: title, subtitle: subtitle, leading: leading, 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/lib/src/utils/adaptive.dart b/packages/neon/neon/lib/src/utils/adaptive.dart new file mode 100644 index 00000000..f2c3a4de --- /dev/null +++ b/packages/neon/neon/lib/src/utils/adaptive.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +/// Returns whether the current platform is a Cupertino one. +/// +/// This is true for both `TargetPlatform.iOS` and `TargetPlatform.macOS`. +bool isCupertino(final BuildContext context) { + final theme = Theme.of(context); + + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return false; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return true; + } +} diff --git a/packages/neon/neon/lib/src/widgets/account_switcher_button.dart b/packages/neon/neon/lib/src/widgets/account_switcher_button.dart index 65b23a5f..d021aab8 100644 --- a/packages/neon/neon/lib/src/widgets/account_switcher_button.dart +++ b/packages/neon/neon/lib/src/widgets/account_switcher_button.dart @@ -7,6 +7,7 @@ import 'package:neon/src/pages/settings.dart'; import 'package:neon/src/router.dart'; import 'package:neon/src/utils/provider.dart'; import 'package:neon/src/widgets/account_selection_dialog.dart'; +import 'package:neon/src/widgets/adaptive_widgets/list_tile.dart'; import 'package:neon/src/widgets/user_avatar.dart'; @internal @@ -23,7 +24,7 @@ class AccountSwitcherButton extends StatelessWidget { builder: (final context) => NeonAccountSelectionDialog( highlightActiveAccount: true, children: [ - ListTile( + AdaptiveListTile( leading: const Icon(Icons.settings), title: Text(NeonLocalizations.of(context).settingsAccountManage), onTap: () { diff --git a/packages/neon/neon/lib/src/widgets/account_tile.dart b/packages/neon/neon/lib/src/widgets/account_tile.dart index 421bfa9b..fec541f8 100644 --- a/packages/neon/neon/lib/src/widgets/account_tile.dart +++ b/packages/neon/neon/lib/src/widgets/account_tile.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:intersperse/intersperse.dart'; import 'package:meta/meta.dart'; @@ -5,6 +7,7 @@ import 'package:neon/src/bloc/result.dart'; import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/models/account.dart'; import 'package:neon/src/utils/provider.dart'; +import 'package:neon/src/widgets/adaptive_widgets/list_tile.dart'; import 'package:neon/src/widgets/error.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart'; import 'package:neon/src/widgets/user_avatar.dart'; @@ -27,23 +30,11 @@ class NeonAccountTile extends StatelessWidget { /// {@endtemplate} final Account account; - /// {@template neon.AccountTile.trailing} - /// A widget to display after the title. - /// - /// Typically an [Icon] widget. - /// - /// To show right-aligned metadata (assuming left-to-right reading order; - /// left-aligned for right-to-left reading order), consider using a [Row] with - /// [CrossAxisAlignment.baseline] alignment whose first item is [Expanded] and - /// whose second child is the metadata text, instead of using the [trailing] - /// property. - /// {@endtemplate} + /// {@macro neon.AdaptiveListTile.trailing} final Widget? trailing; - /// {@template neon.AccountTile.onTap} - /// Called when the user taps this list tile. - /// {@endtemplate} - final GestureTapCallback? onTap; + /// {@macro neon.AdaptiveListTile.onTap} + final FutureOr Function()? onTap; /// Whether to also show the status on the avatar. /// @@ -55,7 +46,7 @@ class NeonAccountTile extends StatelessWidget { Widget build(final BuildContext context) { final userDetailsBloc = NeonProvider.of(context).getUserDetailsBlocFor(account); - return ListTile( + return AdaptiveListTile( onTap: onTap, leading: NeonUserAvatar( account: account, diff --git a/packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart b/packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart new file mode 100644 index 00000000..e0392fe7 --- /dev/null +++ b/packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +/// A wrapper widget that adaptively displays a [ListTile] on Material platforms +/// and a [CupertinoListTile] on Cupertino ones. +class AdaptiveListTile extends StatelessWidget { + /// Creates a new adaptive list tile. + /// + /// If supplied the [subtitle] will be displayed below the title. + const AdaptiveListTile({ + required this.title, + this.enabled = true, + this.subtitle, + this.leading, + this.trailing, + this.onTap, + super.key, + }) : additionalInfo = null; + + /// Creates a new adaptive list tile. + /// + /// If supplied the [additionalInfo] will be displayed below the title on + /// Material platforms and as a trailing widget on Cupertino ones. + const AdaptiveListTile.additionalInfo({ + required this.title, + this.enabled = true, + this.additionalInfo, + this.leading, + this.trailing, + this.onTap, + super.key, + }) : subtitle = additionalInfo; + + /// {@template neon.AdaptiveListTile.title} + /// A [title] is used to convey the central information. Usually a [Text]. + /// {@endtemplate} + final Widget title; + + /// {@template neon.AdaptiveListTile.subtitle} + /// A [subtitle] is used to display additional information. It is located + /// below [title]. Usually a [Text] widget. + final Widget? subtitle; + + /// {@template neon.AdaptiveListTile.additionalInfo} + /// Similar to [subtitle], an [additionalInfo] is used to display additional + /// information. However, instead of being displayed below [title], it is + /// displayed on the right, before [trailing]. Usually a [Text] widget. + /// + /// This is only available on Cupertino platforms. + /// {@endtemplate} + final Widget? additionalInfo; + + /// {@template neon.AdaptiveListTile.leading} + /// A widget displayed at the start of the [AdaptiveListTile]. This is + /// typically an `Icon` or an `Image`. + /// {@endtemplate} + final Widget? leading; + + /// {@template neon.AdaptiveListTile.trailing} + /// A widget displayed at the end of the [AdaptiveListTile]. + /// {@endtemplate} + final Widget? trailing; + + /// {@template neon.AdaptiveListTile.onTap} + /// The [onTap] function is called when a user taps on the[AdaptiveListTile]. + /// If left `null`, the [AdaptiveListTile] will not react to taps. + /// + /// If the platform is a Cupertino one and this is a `Future Function()`, + /// then the [AdaptiveListTile] remains activated until the returned future is + /// awaited. This is according to iOS behavior. + /// However, if this function is a `void Function()`, then the tile is active + /// only for the duration of invocation. + /// {@endtemplate} + final FutureOr Function()? onTap; + + /// {@template neon.AdaptiveListTile.enabled} + /// Whether this list tile is interactive. + /// + /// If false, this list tile is styled with the disabled color from the + /// current [Theme] and the [onTap] callback is inoperative. + /// {@endtemplate} + final bool enabled; + + @override + Widget build(final BuildContext context) { + final theme = Theme.of(context); + + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return ListTile( + title: title, + subtitle: subtitle, + leading: leading, + trailing: trailing, + onTap: onTap, + enabled: enabled, + ); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + final tile = CupertinoListTile( + title: title, + subtitle: additionalInfo == null ? subtitle : null, + additionalInfo: additionalInfo, + leading: leading, + trailing: trailing, + onTap: enabled ? onTap : null, + ); + + if (!enabled) { + var data = CupertinoTheme.of(context); + data = data.copyWith( + textTheme: data.resolveFrom(context).textTheme.copyWith( + textStyle: data.textTheme.textStyle.merge( + TextStyle( + color: theme.disabledColor, + ), + ), + ), + ); + + return CupertinoTheme( + data: data, + child: tile, + ); + } + + return tile; + } + } +} diff --git a/packages/neon/neon/lib/src/widgets/error.dart b/packages/neon/neon/lib/src/widgets/error.dart index 18c87fd4..75d04e99 100644 --- a/packages/neon/neon/lib/src/widgets/error.dart +++ b/packages/neon/neon/lib/src/widgets/error.dart @@ -8,6 +8,7 @@ import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/router.dart'; import 'package:neon/src/utils/exceptions.dart'; import 'package:neon/src/utils/provider.dart'; +import 'package:neon/src/widgets/adaptive_widgets/list_tile.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:universal_io/io.dart'; @@ -22,7 +23,7 @@ enum NeonErrorType { /// Shows a column with the error message and a retry button. column, - /// Shows a [ListTile] with the error. + /// Shows a [AdaptiveListTile] with the error. listTile, } @@ -147,10 +148,9 @@ class NeonError extends StatelessWidget { ), ); case NeonErrorType.listTile: - return ListTile( + return AdaptiveListTile( leading: errorIcon, - title: Text(message), - titleTextStyle: textStyle, + title: Text(message, style: textStyle), onTap: onPressed, ); } diff --git a/packages/neon/neon/lib/src/widgets/unified_search_results.dart b/packages/neon/neon/lib/src/widgets/unified_search_results.dart index 473d8553..26949454 100644 --- a/packages/neon/neon/lib/src/widgets/unified_search_results.dart +++ b/packages/neon/neon/lib/src/widgets/unified_search_results.dart @@ -8,6 +8,7 @@ import 'package:neon/src/blocs/unified_search.dart'; import 'package:neon/src/models/account.dart'; import 'package:neon/src/theme/sizes.dart'; import 'package:neon/src/utils/provider.dart'; +import 'package:neon/src/widgets/adaptive_widgets/list_tile.dart'; import 'package:neon/src/widgets/error.dart'; import 'package:neon/src/widgets/image.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart'; @@ -81,7 +82,7 @@ class NeonUnifiedSearchResults extends StatelessWidget { visible: result.isLoading, ), if (entries.isEmpty) ...[ - ListTile( + AdaptiveListTile( leading: const Icon( Icons.close, size: largeIconSize, @@ -90,7 +91,7 @@ class NeonUnifiedSearchResults extends StatelessWidget { ), ], for (final entry in entries) ...[ - ListTile( + AdaptiveListTile( leading: NeonImageWrapper( size: const Size.square(largeIconSize), child: _buildThumbnail(context, accountsBloc.activeAccount.value!, entry), diff --git a/packages/neon/neon/lib/src/widgets/validation_tile.dart b/packages/neon/neon/lib/src/widgets/validation_tile.dart index 543688fe..4f2aaf22 100644 --- a/packages/neon/neon/lib/src/widgets/validation_tile.dart +++ b/packages/neon/neon/lib/src/widgets/validation_tile.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:neon/src/widgets/adaptive_widgets/list_tile.dart'; /// Validation list tile. /// @@ -48,7 +49,7 @@ class NeonValidationTile extends StatelessWidget { size: size, ), }; - return ListTile( + return AdaptiveListTile( leading: leading, title: Text( title, 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