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. 69
      packages/neon/neon/lib/src/pages/account_settings.dart
  4. 181
      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. 57
      packages/neon/neon/lib/src/settings/widgets/settings_category.dart
  10. 13
      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 autofocus
cupertino
endtemplate endtemplate
expando expando
gapless gapless

8
packages/app/pubspec.lock

@ -185,6 +185,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.17.3" 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: dbus:
dependency: transitive dependency: transitive
description: description:

69
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/models/account.dart';
import 'package:neon/src/router.dart'; import 'package:neon/src/router.dart';
import 'package:neon/src/settings/widgets/custom_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_category.dart';
import 'package:neon/src/settings/widgets/settings_list.dart'; import 'package:neon/src/settings/widgets/settings_list.dart';
import 'package:neon/src/theme/dialog.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/confirmation_dialog.dart';
import 'package:neon/src/widgets/error.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; import 'package:nextcloud/provisioning_api.dart' as provisioning_api;
/// Account settings page. /// Account settings page.
@ -84,47 +84,50 @@ class AccountSettingsPage extends StatelessWidget {
], ],
); );
final body = ResultBuilder<provisioning_api.UserDetails>.behaviorSubject( final body = SettingsList(
categories: [
SettingsCategory(
title: Text(NeonLocalizations.of(context).accountOptionsCategoryStorageInfo),
tiles: [
ResultBuilder<provisioning_api.UserDetails>.behaviorSubject(
subject: userDetailsBloc.userDetails, subject: userDetailsBloc.userDetails,
builder: (final context, final userDetails) { builder: (final context, final userDetails) {
final quotaRelative = userDetails.data?.quota.relative?.$int ?? userDetails.data?.quota.relative?.$double ?? 0; if (userDetails.hasError) {
return NeonError(
userDetails.error ?? 'Something went wrong',
type: NeonErrorType.listTile,
onRetry: userDetailsBloc.refresh,
);
}
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 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 quotaUsed = userDetails.data?.quota.used?.$int ?? userDetails.data?.quota.used?.$double ?? 0;
return SettingsList( value = quotaRelative / 100;
categories: [ subtitle = Text(
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( NeonLocalizations.of(context).accountOptionsQuotaUsedOf(
filesize(quotaUsed, 1), filesize(quotaUsed, 1),
filesize(quotaTotal, 1), filesize(quotaTotal, 1),
quotaRelative.toString(), 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,
NeonError( );
userDetails.error, },
onRetry: userDetailsBloc.refresh,
),
NeonLinearProgressIndicator(
visible: userDetails.isLoading,
),
],
),
), ),
], ],
), ),
@ -138,8 +141,6 @@ class AccountSettingsPage extends StatelessWidget {
), ),
], ],
); );
},
);
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,

181
packages/neon/neon/lib/src/pages/settings.dart

@ -1,4 +1,5 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:meta/meta.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/utils/settings_export_helper.dart';
import 'package:neon/src/settings/widgets/account_settings_tile.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/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_category.dart';
import 'package:neon/src/settings/widgets/settings_list.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/settings_tile.dart';
import 'package:neon/src/settings/widgets/text_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/branding.dart';
import 'package:neon/src/theme/dialog.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/confirmation_dialog.dart';
import 'package:neon/src/utils/global_options.dart'; import 'package:neon/src/utils/global_options.dart';
import 'package:neon/src/utils/provider.dart'; import 'package:neon/src/utils/provider.dart';
@ -112,24 +113,11 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
], ],
); );
final body = StreamBuilder( final body = SettingsList(
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, initialCategory: widget.initialCategory?.name,
categories: [ categories: [
SettingsCategory( SettingsCategory(
hasLeading: true,
title: Text(NeonLocalizations.of(context).settingsApps), title: Text(NeonLocalizations.of(context).settingsApps),
key: ValueKey(SettingsCategories.apps.name), key: ValueKey(SettingsCategories.apps.name),
tiles: <SettingsTile>[ tiles: <SettingsTile>[
@ -170,30 +158,7 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
], ],
), ),
if (NeonPlatform.instance.canUsePushNotifications) ...[ if (NeonPlatform.instance.canUsePushNotifications) buildNotificationsCategory(),
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.canUseWindowManager) ...[ if (NeonPlatform.instance.canUseWindowManager) ...[
SettingsCategory( SettingsCategory(
title: Text(NeonLocalizations.of(context).optionsCategoryStartup), title: Text(NeonLocalizations.of(context).optionsCategoryStartup),
@ -222,39 +187,12 @@ class _SettingsPageState extends State<SettingsPage> {
], ],
), ),
], ],
...buildAccountCategory(),
SettingsCategory( SettingsCategory(
title: Text(NeonLocalizations.of(context).optionsCategoryAccounts), hasLeading: true,
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),
),
),
],
),
SettingsCategory(
title: Text(NeonLocalizations.of(context).optionsCategoryOther), title: Text(NeonLocalizations.of(context).optionsCategoryOther),
key: ValueKey(SettingsCategories.other.name), key: ValueKey(SettingsCategories.other.name),
tiles: <SettingsTile>[ tiles: [
if (branding.sourceCodeURL != null) if (branding.sourceCodeURL != null)
CustomSettingsTile( CustomSettingsTile(
leading: Icon( leading: Icon(
@ -363,8 +301,6 @@ class _SettingsPageState extends State<SettingsPage> {
], ],
), ),
], ],
),
),
); );
return Scaffold( 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) { SettingsExportHelper _buildSettingsExportHelper(final BuildContext context) {
final globalOptions = NeonProvider.of<GlobalOptions>(context); final globalOptions = NeonProvider.of<GlobalOptions>(context);
final accountsBloc = NeonProvider.of<AccountsBloc>(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:flutter/material.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:neon/src/models/account.dart'; import 'package:neon/src/models/account.dart';
@ -18,11 +20,11 @@ class AccountSettingsTile extends SettingsTile {
/// {@macro neon.AccountTile.account} /// {@macro neon.AccountTile.account}
final Account account; final Account account;
/// {@macro neon.AccountTile.trailing} /// {@macro neon.AdaptiveListTile.trailing}
final Widget? trailing; final Widget? trailing;
/// {@macro neon.AccountTile.onTap} /// {@macro neon.AdaptiveListTile.onTap}
final GestureTapCallback? onTap; final FutureOr<void> Function()? onTap;
@override @override
Widget build(final BuildContext context) => NeonAccountTile( 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:flutter/material.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:neon/src/settings/widgets/settings_tile.dart'; import 'package:neon/src/settings/widgets/settings_tile.dart';
import 'package:neon/src/widgets/adaptive_widgets/list_tile.dart';
@internal @internal
class CustomSettingsTile extends SettingsTile { class CustomSettingsTile extends SettingsTile {
const CustomSettingsTile({ const CustomSettingsTile({
this.title, required this.title,
this.subtitle, this.subtitle,
this.leading, this.leading,
this.trailing, this.trailing,
@ -13,14 +16,14 @@ class CustomSettingsTile extends SettingsTile {
super.key, super.key,
}); });
final Widget? title; final Widget title;
final Widget? subtitle; final Widget? subtitle;
final Widget? leading; final Widget? leading;
final Widget? trailing; final Widget? trailing;
final GestureTapCallback? onTap; final FutureOr<void> Function()? onTap;
@override @override
Widget build(final BuildContext context) => ListTile( Widget build(final BuildContext context) => AdaptiveListTile(
title: title, title: title,
subtitle: subtitle, subtitle: subtitle,
leading: leading, 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:meta/meta.dart';
import 'package:neon/settings.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/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 @internal
class OptionSettingsTile extends InputSettingsTile { class OptionSettingsTile extends InputSettingsTile {
@ -18,3 +22,205 @@ class OptionSettingsTile extends InputSettingsTile {
SelectOption() => SelectSettingsTile(option: option as SelectOption), 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),
),
);
}

57
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:flutter/material.dart';
import 'package:intersperse/intersperse.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:neon/src/settings/widgets/settings_tile.dart'; import 'package:neon/src/utils/adaptive.dart';
@internal @internal
class SettingsCategory extends StatelessWidget { class SettingsCategory extends StatelessWidget {
const SettingsCategory({ const SettingsCategory({
required this.tiles, required this.tiles,
this.title, this.title,
this.footer,
this.hasLeading = false,
super.key, super.key,
}); });
final Widget? title; final Widget? title;
final List<SettingsTile> tiles; final List<Widget> tiles;
final Widget? footer;
final bool hasLeading;
@override @override
Widget build(final BuildContext context) { 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; final textTheme = Theme.of(context).textTheme;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (title != null) if (header != null)
DefaultTextStyle( Padding(
padding: const EdgeInsets.only(top: 25, bottom: 5),
child: DefaultTextStyle(
style: textTheme.titleMedium!.copyWith( style: textTheme.titleMedium!.copyWith(
color: theme.colorScheme.secondary,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
child: title!, child: header!,
), ),
...tiles,
]
.intersperse(
const SizedBox(
height: 10,
), ),
) ...children,
.toList(), ],
); );
} }
} }

13
packages/neon/neon/lib/src/settings/widgets/settings_list.dart

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; 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'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
@visibleForTesting @visibleForTesting
@ -10,7 +10,7 @@ class SettingsList extends StatelessWidget {
super.key, super.key,
}); });
final List<SettingsCategory> categories; final List<Widget> categories;
final String? initialCategory; final String? initialCategory;
int? _getIndex(final String? initialCategory) { int? _getIndex(final String? initialCategory) {
@ -25,11 +25,14 @@ class SettingsList extends StatelessWidget {
} }
@override @override
Widget build(final BuildContext context) => ScrollablePositionedList.separated( Widget build(final BuildContext context) {
padding: const EdgeInsets.all(20), final hasPadding = !isCupertino(context);
return ScrollablePositionedList.builder(
padding: hasPadding ? const EdgeInsets.symmetric(horizontal: 20) : null,
itemCount: categories.length, itemCount: categories.length,
initialScrollIndex: _getIndex(initialCategory) ?? 0, initialScrollIndex: _getIndex(initialCategory) ?? 0,
itemBuilder: (final context, final index) => categories[index], itemBuilder: (final context, final index) => categories[index],
separatorBuilder: (final context, final index) => const Divider(),
); );
} }
}

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:flutter/material.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:neon/src/theme/colors.dart'; import 'package:neon/src/theme/colors.dart';
@ -90,6 +91,10 @@ class AppTheme {
dividerTheme: _dividerTheme, dividerTheme: _dividerTheme,
scrollbarTheme: _scrollbarTheme, scrollbarTheme: _scrollbarTheme,
inputDecorationTheme: _inputDecorationTheme, inputDecorationTheme: _inputDecorationTheme,
cupertinoOverrideTheme: CupertinoThemeData(
brightness: brightness,
textTheme: const CupertinoTextThemeData(),
),
extensions: [ extensions: [
neonTheme, neonTheme,
...?appThemes, ...?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/router.dart';
import 'package:neon/src/utils/provider.dart'; import 'package:neon/src/utils/provider.dart';
import 'package:neon/src/widgets/account_selection_dialog.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'; import 'package:neon/src/widgets/user_avatar.dart';
@internal @internal
@ -23,7 +24,7 @@ class AccountSwitcherButton extends StatelessWidget {
builder: (final context) => NeonAccountSelectionDialog( builder: (final context) => NeonAccountSelectionDialog(
highlightActiveAccount: true, highlightActiveAccount: true,
children: [ children: [
ListTile( AdaptiveListTile(
leading: const Icon(Icons.settings), leading: const Icon(Icons.settings),
title: Text(NeonLocalizations.of(context).settingsAccountManage), title: Text(NeonLocalizations.of(context).settingsAccountManage),
onTap: () { 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:flutter/material.dart';
import 'package:intersperse/intersperse.dart'; import 'package:intersperse/intersperse.dart';
import 'package:meta/meta.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/blocs/accounts.dart';
import 'package:neon/src/models/account.dart'; import 'package:neon/src/models/account.dart';
import 'package:neon/src/utils/provider.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/error.dart';
import 'package:neon/src/widgets/linear_progress_indicator.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart';
import 'package:neon/src/widgets/user_avatar.dart'; import 'package:neon/src/widgets/user_avatar.dart';
@ -27,23 +30,11 @@ class NeonAccountTile extends StatelessWidget {
/// {@endtemplate} /// {@endtemplate}
final Account account; final Account account;
/// {@template neon.AccountTile.trailing} /// {@macro neon.AdaptiveListTile.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}
final Widget? trailing; final Widget? trailing;
/// {@template neon.AccountTile.onTap} /// {@macro neon.AdaptiveListTile.onTap}
/// Called when the user taps this list tile. final FutureOr<void> Function()? onTap;
/// {@endtemplate}
final GestureTapCallback? onTap;
/// Whether to also show the status on the avatar. /// Whether to also show the status on the avatar.
/// ///
@ -55,7 +46,7 @@ class NeonAccountTile extends StatelessWidget {
Widget build(final BuildContext context) { Widget build(final BuildContext context) {
final userDetailsBloc = NeonProvider.of<AccountsBloc>(context).getUserDetailsBlocFor(account); final userDetailsBloc = NeonProvider.of<AccountsBloc>(context).getUserDetailsBlocFor(account);
return ListTile( return AdaptiveListTile(
onTap: onTap, onTap: onTap,
leading: NeonUserAvatar( leading: NeonUserAvatar(
account: account, 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/router.dart';
import 'package:neon/src/utils/exceptions.dart'; import 'package:neon/src/utils/exceptions.dart';
import 'package:neon/src/utils/provider.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:nextcloud/nextcloud.dart';
import 'package:universal_io/io.dart'; import 'package:universal_io/io.dart';
@ -22,7 +23,7 @@ enum NeonErrorType {
/// Shows a column with the error message and a retry button. /// Shows a column with the error message and a retry button.
column, column,
/// Shows a [ListTile] with the error. /// Shows a [AdaptiveListTile] with the error.
listTile, listTile,
} }
@ -147,10 +148,9 @@ class NeonError extends StatelessWidget {
), ),
); );
case NeonErrorType.listTile: case NeonErrorType.listTile:
return ListTile( return AdaptiveListTile(
leading: errorIcon, leading: errorIcon,
title: Text(message), title: Text(message, style: textStyle),
titleTextStyle: textStyle,
onTap: onPressed, 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/models/account.dart';
import 'package:neon/src/theme/sizes.dart'; import 'package:neon/src/theme/sizes.dart';
import 'package:neon/src/utils/provider.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/error.dart';
import 'package:neon/src/widgets/image.dart'; import 'package:neon/src/widgets/image.dart';
import 'package:neon/src/widgets/linear_progress_indicator.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart';
@ -81,7 +82,7 @@ class NeonUnifiedSearchResults extends StatelessWidget {
visible: result.isLoading, visible: result.isLoading,
), ),
if (entries.isEmpty) ...[ if (entries.isEmpty) ...[
ListTile( AdaptiveListTile(
leading: const Icon( leading: const Icon(
Icons.close, Icons.close,
size: largeIconSize, size: largeIconSize,
@ -90,7 +91,7 @@ class NeonUnifiedSearchResults extends StatelessWidget {
), ),
], ],
for (final entry in entries) ...[ for (final entry in entries) ...[
ListTile( AdaptiveListTile(
leading: NeonImageWrapper( leading: NeonImageWrapper(
size: const Size.square(largeIconSize), size: const Size.square(largeIconSize),
child: _buildThumbnail(context, accountsBloc.activeAccount.value!, entry), 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:flutter/material.dart';
import 'package:neon/src/widgets/adaptive_widgets/list_tile.dart';
/// Validation list tile. /// Validation list tile.
/// ///
@ -48,7 +49,7 @@ class NeonValidationTile extends StatelessWidget {
size: size, size: size,
), ),
}; };
return ListTile( return AdaptiveListTile(
leading: leading, leading: leading,
title: Text( title: Text(
title, title,

1
packages/neon/neon/pubspec.yaml

@ -9,6 +9,7 @@ environment:
dependencies: dependencies:
collection: ^1.0.0 collection: ^1.0.0
crypto: ^3.0.0 crypto: ^3.0.0
cupertino_icons: ^1.0.0
dynamic_color: ^1.0.0 dynamic_color: ^1.0.0
file_picker: ^6.0.0 file_picker: ^6.0.0
filesize: ^2.0.0 filesize: ^2.0.0

Loading…
Cancel
Save