diff --git a/lib/models/setting.dart b/lib/models/setting.dart new file mode 100644 index 0000000..ae87792 --- /dev/null +++ b/lib/models/setting.dart @@ -0,0 +1,12 @@ +import "package:flutter/material.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +part "setting.freezed.dart"; + +@freezed +abstract class Setting with _$Setting { + const factory Setting({ + required String title, + required String description, + required Widget widget, + }) = _Setting; +} diff --git a/lib/models/settings_category.dart b/lib/models/settings_category.dart new file mode 100644 index 0000000..88bfdd0 --- /dev/null +++ b/lib/models/settings_category.dart @@ -0,0 +1,14 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/setting.dart"; +part "settings_category.freezed.dart"; + +@freezed +abstract class SettingsCategory with _$SettingsCategory { + const factory SettingsCategory({ + required String title, + required IconData icon, + required IList settings, + }) = _SettingsCategory; +} diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index ca42bc4..0f20768 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,100 +1,197 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:intl/intl.dart"; import "package:m3e_card_list/m3e_card_list.dart"; import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:nexus/models/settings_category.dart"; import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:super_sliver_list/super_sliver_list.dart"; class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); + static final IMap> + settingsCategoryGroups = .new({ + "General": .new([ + .new( + title: "Appearance", + icon: Icons.brush, + settings: .new([ + .new( + title: "Dark Mode", + description: + "Toggle between Light Mode, Dark Mode, and System themes.", + widget: DialogListTile( + icon: Icon(Icons.palette), + title: "Dark Mode", + initialValue: ThemeMode.system, + options: ThemeMode.values, + getName: (option) => toBeginningOfSentenceCase(option.name), + onChanged: (value) {}, + ), + ), + .new( + title: "Use Client Side Decorations", + description: + "On desktop, toggle between client-side or server-side decorations", + widget: SwitchListTile( + title: Text("Client Side Decorations"), + value: true, + onChanged: (value) {}, + ), + ), + ]), + ), + .new( + title: "Appearance", + icon: Icons.brush, + settings: .new([ + .new( + title: "Dark Mode", + description: + "Toggle between Light Mode, Dark Mode, and System themes.", + widget: DialogListTile( + icon: Icon(Icons.palette), + title: "Dark Mode", + initialValue: ThemeMode.system, + options: ThemeMode.values, + getName: (option) => toBeginningOfSentenceCase(option.name), + onChanged: (value) {}, + ), + ), + .new( + title: "Use Client Side Decorations", + description: + "On desktop, toggle between client-side or server-side decorations", + widget: SwitchListTile( + title: Text("Client Side Decorations"), + value: true, + onChanged: (value) {}, + ), + ), + ]), + ), + .new( + title: "Appearance", + icon: Icons.brush, + settings: .new([ + .new( + title: "Dark Mode", + description: + "Toggle between Light Mode, Dark Mode, and System themes.", + widget: DialogListTile( + icon: Icon(Icons.palette), + title: "Dark Mode", + initialValue: ThemeMode.system, + options: ThemeMode.values, + getName: (option) => toBeginningOfSentenceCase(option.name), + onChanged: (value) {}, + ), + ), + .new( + title: "Use Client Side Decorations", + description: + "On desktop, toggle between client-side or server-side decorations", + widget: SwitchListTile( + title: Text("Client Side Decorations"), + value: true, + onChanged: (value) {}, + ), + ), + ]), + ), + ]), + }); + @override Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( - builder: (context, constraints) { - final categoriesArePages = constraints.maxWidth < 550; + builder: (_, constraints) => HookBuilder( + builder: (context) { + final categoriesArePages = constraints.maxWidth < 550; - final settingsContent = Scaffold( - appBar: AppBar( - title: Text("Settings"), - actionsPadding: .symmetric(horizontal: 12), - actions: [ - SearchAnchor( - builder: (_, controller) => IconButton( - icon: const Icon(Icons.search), - onPressed: controller.openView, + final selected = useState(0); + + final settingsContent = Scaffold( + appBar: AppBar( + title: Text("Settings"), + actionsPadding: .symmetric(horizontal: 12), + actions: [ + SearchAnchor( + builder: (_, controller) => IconButton( + icon: const Icon(Icons.search), + onPressed: controller.openView, + ), + suggestionsBuilder: (context, controller) { + // TODO + return []; + }, ), - suggestionsBuilder: (context, controller) { - return []; - }, - ), - ], - ), - body: Padding( - padding: .symmetric(vertical: 12), - child: categoriesArePages + ], + ), + body: categoriesArePages ? CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: .symmetric(horizontal: 16), - child: DividerText("Account Settings"), - ), - ), - SliverM3ECardList( - padding: .symmetric(horizontal: 4, vertical: 8), - margin: .all(12), - color: Theme.of(context).colorScheme.primaryContainer, - itemCount: 3, - itemBuilder: (context, index) => ListTile( - leading: Icon(Icons.abc), - title: Text("Account"), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: .symmetric(horizontal: 16), - child: DividerText("Some Other Settings"), - ), - ), - SliverM3ECardList( - padding: .symmetric(horizontal: 4, vertical: 8), - margin: .symmetric(horizontal: 12), - color: Theme.of(context).colorScheme.primaryContainer, - itemCount: 4, - itemBuilder: (context, index) => ListTile( - leading: Icon(Icons.abc), - title: Text("Account"), - ), - ), - ], + slivers: settingsCategoryGroups + .mapTo( + (categoryGroup, categories) => [ + SliverToBoxAdapter( + child: Padding( + padding: .symmetric(horizontal: 16), + child: DividerText(categoryGroup), + ), + ), + SliverM3ECardList( + padding: .symmetric(horizontal: 4, vertical: 8), + margin: .all(12), + color: Theme.of( + context, + ).colorScheme.primaryContainer, + itemCount: categories.length, + onTap: (index) => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar( + title: Text(categories[index].title), + ), + body: ListView(), + ), + ), + ), + itemBuilder: (context, index) => ListTile( + leading: Icon(categories[index].icon), + title: Text(categories[index].title), + ), + ), + ], + ) + .flattenedToList, ) : Row( children: [ - Padding( - padding: .symmetric(vertical: 8), - child: NavigationRailM3E( - type: .alwaysExpand, - sections: [ - .new( - header: DividerText("Account Settings"), - destinations: [ - .new(icon: Icon(Icons.abc), label: "Account"), - .new(icon: Icon(Icons.abc), label: "Account"), - .new(icon: Icon(Icons.abc), label: "Account"), - ], - ), - .new( - header: DividerText("Some Other Settings"), - destinations: [ - .new(icon: Icon(Icons.abc), label: "Account"), - .new(icon: Icon(Icons.abc), label: "Account"), - .new(icon: Icon(Icons.abc), label: "Account"), - ], - ), - ], - selectedIndex: 0, - onDestinationSelected: (value) {}, - ), + NavigationRailM3E( + type: .alwaysExpand, + sections: settingsCategoryGroups + .mapTo( + (categoryGroup, categories) => + NavigationRailM3ESection( + header: DividerText(categoryGroup), + destinations: categories + .map( + (category) => + NavigationRailM3EDestination( + icon: Icon(category.icon), + label: category.title, + ), + ) + .toList(), + ), + ) + .toList(), + selectedIndex: selected.value, + onDestinationSelected: (value) => selected.value = value, ), VerticalDivider(), Expanded( @@ -130,18 +227,18 @@ class SettingsPage extends ConsumerWidget { ), ], ), - ), - ); + ); - return constraints.maxWidth < 650 - ? settingsContent - : Dialog( - constraints: .loose(Size(900, 600)), - child: ClipRRect( - borderRadius: BorderRadiusGeometry.circular(12), - child: settingsContent, - ), - ); - }, + return constraints.maxWidth < 650 + ? settingsContent + : Dialog( + constraints: .loose(Size(900, 600)), + child: ClipRRect( + borderRadius: BorderRadiusGeometry.circular(12), + child: settingsContent, + ), + ); + }, + ), ); } diff --git a/lib/widgets/settings/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart new file mode 100644 index 0000000..cd03d7e --- /dev/null +++ b/lib/widgets/settings/dialog_list_tile.dart @@ -0,0 +1,65 @@ +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/widgets/settings/radio_dialog.dart"; + +class DialogListTile extends ConsumerWidget { + final T? initialValue; + final String title; + final String? description; + final List options; + final bool required; + final Icon icon; + final void Function(T value)? onChanged; + final String Function(T option) getName; + const DialogListTile({ + super.key, + required this.icon, + required this.title, + required this.initialValue, + required this.options, + required this.onChanged, + required this.getName, + this.description, + this.required = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) => FormField( + validator: (value) => + value == null && required == true ? "This field is required." : null, + initialValue: initialValue, + builder: (field) => InputDecorator( + decoration: InputDecoration( + errorText: field.errorText, + contentPadding: EdgeInsets.zero, + enabledBorder: InputBorder.none, + ), + child: ListTile( + onTap: () => showDialog( + context: context, + builder: (context) => RadioDialog( + title: title, + getName: getName, + onChanged: onChanged == null + ? null + : (value) { + field.didChange(value); + onChanged!.call(value); + }, + value: field.value, + options: options, + ), + ), + title: Text(title), + subtitle: description == null ? null : Text(description!), + leading: icon, + trailing: Chip( + label: Text( + field.value == null ? "None" : getName(field.value as T), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/settings/radio_dialog.dart b/lib/widgets/settings/radio_dialog.dart new file mode 100644 index 0000000..7695b9d --- /dev/null +++ b/lib/widgets/settings/radio_dialog.dart @@ -0,0 +1,55 @@ +import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +class RadioDialog extends HookWidget { + final T? value; + final String title; + final List options; + final void Function(T value)? onChanged; + final String Function(T option) getName; + const RadioDialog({ + super.key, + required this.title, + required this.value, + required this.options, + required this.onChanged, + required this.getName, + }); + + @override + Widget build(BuildContext context) { + final mutValue = useState(null); + return AlertDialog( + title: Text(title), + content: RadioGroup( + groupValue: mutValue.value ?? value, + onChanged: (value) => mutValue.value = value ?? mutValue.value, + + child: Column( + mainAxisSize: MainAxisSize.min, + children: options + .map( + (option) => RadioListTile( + enabled: onChanged != null, + value: option, + title: Text(getName(option)), + dense: true, + ), + ) + .toList(), + ), + ), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + if (onChanged != null) + TextButton( + onPressed: () { + if (mutValue.value != null) onChanged!(mutValue.value as T); + Navigator.of(context).pop(); + }, + child: Text("OK"), + ), + ], + ); + } +}