Browse Source

feat(neon): redesign settings and implement cupertino style

Signed-off-by: Nikolas Rimikis <leptopoda@users.noreply.github.com>
pull/1012/head
Nikolas Rimikis 1 year ago
parent
commit
08c6d21399
No known key found for this signature in database
GPG Key ID: 85ED1DE9786A4FF2
  1. 8
      packages/app/pubspec.lock
  2. 69
      packages/neon/neon/lib/src/pages/account_settings.dart
  3. 181
      packages/neon/neon/lib/src/pages/settings.dart
  4. 212
      packages/neon/neon/lib/src/settings/widgets/option_settings_tile.dart
  5. 53
      packages/neon/neon/lib/src/settings/widgets/select_settings_tile.dart
  6. 57
      packages/neon/neon/lib/src/settings/widgets/settings_category.dart
  7. 13
      packages/neon/neon/lib/src/settings/widgets/settings_list.dart
  8. 23
      packages/neon/neon/lib/src/settings/widgets/toggle_settings_tile.dart
  9. 5
      packages/neon/neon/lib/src/theme/theme.dart
  10. 1
      packages/neon/neon/pubspec.yaml

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:

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/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,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,
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 quotaUsed = userDetails.data?.quota.used?.$int ?? userDetails.data?.quota.used?.$double ?? 0;
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(
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),
),
],
NeonError(
userDetails.error,
onRetry: userDetailsBloc.refresh,
),
NeonLinearProgressIndicator(
visible: userDetails.isLoading,
),
],
),
subtitle: subtitle,
);
},
),
],
),
@ -138,8 +141,6 @@ class AccountSettingsPage extends StatelessWidget {
),
],
);
},
);
return Scaffold(
resizeToAvoidBottomInset: false,

181
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,24 +113,11 @@ 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(
final body = SettingsList(
initialCategory: widget.initialCategory?.name,
categories: [
SettingsCategory(
hasLeading: true,
title: Text(NeonLocalizations.of(context).settingsApps),
key: ValueKey(SettingsCategories.apps.name),
tiles: <SettingsTile>[
@ -170,30 +158,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
],
),
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),
@ -222,39 +187,12 @@ class _SettingsPageState extends State<SettingsPage> {
],
),
],
...buildAccountCategory(),
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),
),
),
],
),
SettingsCategory(
hasLeading: true,
title: Text(NeonLocalizations.of(context).optionsCategoryOther),
key: ValueKey(SettingsCategories.other.name),
tiles: <SettingsTile>[
tiles: [
if (branding.sourceCodeURL != null)
CustomSettingsTile(
leading: Icon(
@ -363,8 +301,6 @@ class _SettingsPageState extends State<SettingsPage> {
],
),
],
),
),
);
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);

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

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: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(
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: title!,
child: header!,
),
...tiles,
]
.intersperse(
const SizedBox(
height: 10,
),
)
.toList(),
...children,
],
);
}
}

13
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),
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],
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: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,

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