Browse Source

Merge pull request #1012 from nextcloud/refactor/neon/settings

Refactor/neon/settings
pull/1078/head
Nikolas Rimikis 1 year ago committed by GitHub
parent
commit
a15fbb7d27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .cspell/dart_flutter.txt
  2. 8
      packages/app/pubspec.lock
  3. 105
      packages/neon/neon/lib/src/pages/account_settings.dart
  4. 505
      packages/neon/neon/lib/src/pages/settings.dart
  5. 8
      packages/neon/neon/lib/src/settings/widgets/account_settings_tile.dart
  6. 11
      packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart
  7. 212
      packages/neon/neon/lib/src/settings/widgets/option_settings_tile.dart
  8. 53
      packages/neon/neon/lib/src/settings/widgets/select_settings_tile.dart
  9. 63
      packages/neon/neon/lib/src/settings/widgets/settings_category.dart
  10. 21
      packages/neon/neon/lib/src/settings/widgets/settings_list.dart
  11. 23
      packages/neon/neon/lib/src/settings/widgets/toggle_settings_tile.dart
  12. 5
      packages/neon/neon/lib/src/theme/theme.dart
  13. 19
      packages/neon/neon/lib/src/utils/adaptive.dart
  14. 3
      packages/neon/neon/lib/src/widgets/account_switcher_button.dart
  15. 23
      packages/neon/neon/lib/src/widgets/account_tile.dart
  16. 135
      packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart
  17. 8
      packages/neon/neon/lib/src/widgets/error.dart
  18. 5
      packages/neon/neon/lib/src/widgets/unified_search_results.dart
  19. 3
      packages/neon/neon/lib/src/widgets/validation_tile.dart
  20. 1
      packages/neon/neon/pubspec.yaml

1
.cspell/dart_flutter.txt

@ -1,4 +1,5 @@
autofocus
cupertino
endtemplate
expando
gapless

8
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:

105
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<provisioning_api.UserDetails>.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<provisioning_api.UserDetails>.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(

505
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<SettingsPage> {
),
],
);
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: <SettingsTile>[
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: <SettingsTile>[
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: <SettingsTile>[
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<PackageInfo>(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<PackageInfo>(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<SettingsPage> {
);
}
Widget buildNotificationsCategory() {
final globalOptions = NeonProvider.of<GlobalOptions>(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<Widget> buildAccountCategory() sync* {
final globalOptions = NeonProvider.of<GlobalOptions>(context);
final accountsBloc = NeonProvider.of<AccountsBloc>(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<GlobalOptions>(context);
final accountsBloc = NeonProvider.of<AccountsBloc>(context);

8
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<void> Function()? onTap;
@override
Widget build(final BuildContext context) => NeonAccountTile(

11
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<void> Function()? onTap;
@override
Widget build(final BuildContext context) => ListTile(
Widget build(final BuildContext context) => AdaptiveListTile(
title: title,
subtitle: subtitle,
leading: leading,

212
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<ToggleOption> {
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<T> extends InputSettingsTile<SelectOption<T>> {
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<void>(
builder: (final _) => SelectSettingsTileScreen(option: option),
),
);
} else {
final result = await showAdaptiveDialog<T>(
context: context,
builder: (final context) => SelectSettingsTileDialog(
option: option,
immediateSelection: immediateSelection,
),
);
if (result != null) {
option.value = result;
}
}
},
);
},
child: Text(
option.label(context),
),
);
}
@internal
class SelectSettingsTileDialog<T> extends StatefulWidget {
const SelectSettingsTileDialog({
required this.option,
this.immediateSelection = true,
super.key,
});
final SelectOption<T> option;
final bool immediateSelection;
@override
State<SelectSettingsTileDialog<T>> createState() => _SelectSettingsTileDialogState<T>();
}
class _SelectSettingsTileDialogState<T> extends State<SelectSettingsTileDialog<T>> {
late T value;
late SelectOption<T> 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<T> extends StatelessWidget {
const SelectSettingsTileScreen({
required this.option,
super.key,
});
final SelectOption<T> 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,
),
),
);
}
}

53
packages/neon/neon/lib/src/settings/widgets/select_settings_tile.dart

@ -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<T> extends InputSettingsTile<SelectOption<T>> {
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<T>(
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),
),
);
}

63
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<SettingsTile> tiles;
final List<Widget> 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<Widget> 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,
],
);
}
}

21
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<SettingsCategory> categories;
final List<Widget> 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],
);
}
}

23
packages/neon/neon/lib/src/settings/widgets/toggle_settings_tile.dart

@ -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<ToggleOption> {
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)),
);
}

5
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,

19
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;
}
}

3
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: () {

23
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<void> 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<AccountsBloc>(context).getUserDetailsBlocFor(account);
return ListTile(
return AdaptiveListTile(
onTap: onTap,
leading: NeonUserAvatar(
account: account,

135
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<void> 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<void> 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;
}
}
}

8
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,
);
}

5
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),

3
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,

1
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

Loading…
Cancel
Save