From 5ade674897d51ec028aaa08d09c607e73f18c75d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 23 Jun 2026 18:29:43 -0400 Subject: [PATCH 01/11] initialize settings page There are no settings yet --- lib/pages/settings_page.dart | 20 +++++++++++++++++--- lib/widgets/sidebar.dart | 8 ++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 505904c..8efabc6 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -5,7 +5,21 @@ class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - return Placeholder(); - } + Widget build(BuildContext context, WidgetRef ref) => Scaffold( + appBar: AppBar( + title: Text("Settings"), + actionsPadding: .symmetric(horizontal: 12), + actions: [ + SearchAnchor( + builder: (context, controller) => IconButton( + icon: const Icon(Icons.search), + onPressed: controller.openView, + ), + suggestionsBuilder: (context, controller) { + return []; + }, + ), + ], + ), + ); } diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index 56f8d57..7a03fc9 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -6,6 +6,7 @@ import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; import "package:nexus/controllers/key_controller.dart"; import "package:nexus/controllers/spaces_controller.dart"; import "package:nexus/models/room.dart"; +import "package:nexus/pages/settings_page.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/divider_widget.dart"; import "package:nexus/widgets/join_dialog.dart"; @@ -181,10 +182,9 @@ class Sidebar extends HookConsumerWidget { ), IconButton( tooltip: "Open settings", - onPressed: null, - // () => Navigator.of( - // context, - // ).push(MaterialPageRoute(builder: (_) => SettingsPage())), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => SettingsPage()), + ), icon: Icon(Icons.settings), ), ], -- 2.54.0 From a91601380098b22ab3bbceaf5b8764d9a0df7c8e Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 23 Jun 2026 19:11:26 -0400 Subject: [PATCH 02/11] make settings page a dialog This will need changes to work properly on mobile --- lib/pages/settings_page.dart | 64 ++++++++++++++++++++++++++++-------- lib/widgets/sidebar.dart | 5 +-- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 8efabc6..09fdb14 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,25 +1,61 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) => Scaffold( - appBar: AppBar( - title: Text("Settings"), - actionsPadding: .symmetric(horizontal: 12), - actions: [ - SearchAnchor( - builder: (context, controller) => IconButton( - icon: const Icon(Icons.search), - onPressed: controller.openView, - ), - suggestionsBuilder: (context, controller) { - return []; - }, + Widget build(BuildContext context, WidgetRef ref) => Dialog( + constraints: .loose(Size(900, 600)), + child: ClipRRect( + borderRadius: BorderRadiusGeometry.circular(12), + child: 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) { + return []; + }, + ), + ], ), - ], + body: Padding( + padding: .symmetric(vertical: 8), + child: Row( + children: [ + Padding( + padding: .symmetric(vertical: 4), + child: NavigationRailM3E( + type: .alwaysExpand, + sections: [ + .new( + destinations: [ + .new(icon: Icon(Icons.abc), label: "Account"), + .new(icon: Icon(Icons.abc), label: "Account"), + .new(icon: Icon(Icons.abc), label: "Account"), + .new(icon: Icon(Icons.abc), label: "Account"), + .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) {}, + ), + ), + VerticalDivider(), + ], + ), + ), + ), ), ); } diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index 7a03fc9..f382b97 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -182,8 +182,9 @@ class Sidebar extends HookConsumerWidget { ), IconButton( tooltip: "Open settings", - onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => SettingsPage()), + onPressed: () => showDialog( + context: context, + builder: (_) => SettingsPage(), ), icon: Icon(Icons.settings), ), -- 2.54.0 From ab051c75f5e03c6eb85d2ab4fc80fe23c89e7ddc Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 23 Jun 2026 20:02:15 -0400 Subject: [PATCH 03/11] add categoriesArePages variable --- lib/pages/settings_page.dart | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 09fdb14..722d1ce 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,16 +1,17 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) => Dialog( - constraints: .loose(Size(900, 600)), - child: ClipRRect( - borderRadius: BorderRadiusGeometry.circular(12), - child: Scaffold( + Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( + builder: (context, constraints) { + final categoriesArePages = constraints.maxWidth < 550; + + final settingsContent = Scaffold( appBar: AppBar( title: Text("Settings"), actionsPadding: .symmetric(horizontal: 12), @@ -52,10 +53,31 @@ class SettingsPage extends ConsumerWidget { ), ), VerticalDivider(), + Expanded( + child: SuperListView( + children: [ + SwitchListTile( + title: Text("Settings Title"), + value: false, + onChanged: (value) {}, + ), + ], + ), + ), ], ), ), - ), - ), + ); + + return constraints.maxWidth < 650 + ? settingsContent + : Dialog( + constraints: .loose(Size(900, 600)), + child: ClipRRect( + borderRadius: BorderRadiusGeometry.circular(12), + child: settingsContent, + ), + ); + }, ); } -- 2.54.0 From 3e01e3c50e0dfd0a2eb5c4eada84a90fb5134ccd Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 23 Jun 2026 20:22:04 -0400 Subject: [PATCH 04/11] adjust padding --- lib/pages/settings_page.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 722d1ce..d8272e3 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -28,11 +28,11 @@ class SettingsPage extends ConsumerWidget { ], ), body: Padding( - padding: .symmetric(vertical: 8), + padding: .symmetric(vertical: 12), child: Row( children: [ Padding( - padding: .symmetric(vertical: 4), + padding: .symmetric(vertical: 8), child: NavigationRailM3E( type: .alwaysExpand, sections: [ -- 2.54.0 From 6ed01cf61bf22306dc3db9c6eb7bf2341b3850b2 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 26 Jun 2026 14:17:31 -0400 Subject: [PATCH 05/11] mobile settings mockup --- lib/pages/settings_page.dart | 126 ++++++++++++++++++++++++++--------- 1 file changed, 95 insertions(+), 31 deletions(-) diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index d8272e3..ca42bc4 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,6 +1,8 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:m3e_card_list/m3e_card_list.dart"; import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:nexus/widgets/divider_text.dart"; import "package:super_sliver_list/super_sliver_list.dart"; class SettingsPage extends ConsumerWidget { @@ -29,43 +31,105 @@ class SettingsPage extends ConsumerWidget { ), body: Padding( padding: .symmetric(vertical: 12), - child: Row( - children: [ - Padding( - padding: .symmetric(vertical: 8), - child: NavigationRailM3E( - type: .alwaysExpand, - sections: [ - .new( - destinations: [ - .new(icon: Icon(Icons.abc), label: "Account"), - .new(icon: Icon(Icons.abc), label: "Account"), - .new(icon: Icon(Icons.abc), label: "Account"), - .new(icon: Icon(Icons.abc), label: "Account"), - .new(icon: Icon(Icons.abc), label: "Account"), - .new(icon: Icon(Icons.abc), label: "Account"), - .new(icon: Icon(Icons.abc), label: "Account"), - ], + child: 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"), + ), ), ], - selectedIndex: 0, - onDestinationSelected: (value) {}, - ), - ), - VerticalDivider(), - Expanded( - child: SuperListView( + ) + : Row( children: [ - SwitchListTile( - title: Text("Settings Title"), - value: false, - onChanged: (value) {}, + 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) {}, + ), + ), + VerticalDivider(), + Expanded( + child: SuperListView( + children: [ + SwitchListTile( + title: Text("Settings Title"), + value: false, + onChanged: (value) {}, + ), + SwitchListTile( + title: Text("Settings Title"), + value: false, + onChanged: (value) {}, + ), + SwitchListTile( + title: Text("Settings Title"), + value: false, + onChanged: (value) {}, + ), + SwitchListTile( + title: Text("Settings Title"), + value: false, + onChanged: (value) {}, + ), + SwitchListTile( + title: Text("Settings Title"), + value: false, + onChanged: (value) {}, + ), + ], + ), ), ], ), - ), - ], - ), ), ); -- 2.54.0 From 6726e544bd39d5eb4e2c5d9608c3184d2655ff6d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 26 Jun 2026 15:49:37 -0400 Subject: [PATCH 06/11] dynamic category groups --- lib/models/setting.dart | 12 + lib/models/settings_category.dart | 14 + lib/pages/settings_page.dart | 281 ++++++++++++++------- lib/widgets/settings/dialog_list_tile.dart | 65 +++++ lib/widgets/settings/radio_dialog.dart | 55 ++++ 5 files changed, 335 insertions(+), 92 deletions(-) create mode 100644 lib/models/setting.dart create mode 100644 lib/models/settings_category.dart create mode 100644 lib/widgets/settings/dialog_list_tile.dart create mode 100644 lib/widgets/settings/radio_dialog.dart 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"), + ), + ], + ); + } +} -- 2.54.0 From 6263fe3a37136ffb411519070a87ee5de9ff97fc Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 26 Jun 2026 15:50:48 -0400 Subject: [PATCH 07/11] slightly tweak padding --- lib/pages/settings_page.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 0f20768..1a357f5 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -139,13 +139,15 @@ class SettingsPage extends ConsumerWidget { (categoryGroup, categories) => [ SliverToBoxAdapter( child: Padding( - padding: .symmetric(horizontal: 16), + padding: EdgeInsets.symmetric( + horizontal: 16, + ).copyWith(top: 8, bottom: 4), child: DividerText(categoryGroup), ), ), SliverM3ECardList( padding: .symmetric(horizontal: 4, vertical: 8), - margin: .all(12), + margin: .symmetric(horizontal: 12), color: Theme.of( context, ).colorScheme.primaryContainer, -- 2.54.0 From f0c3d4376ff2e57491ecf310eec38d8181490034 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 26 Jun 2026 16:16:36 -0400 Subject: [PATCH 08/11] add searchbar --- lib/pages/settings_page.dart | 88 +++++++----------------------------- 1 file changed, 17 insertions(+), 71 deletions(-) diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 1a357f5..8ce4401 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -46,64 +46,6 @@ class SettingsPage extends ConsumerWidget { ), ]), ), - .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) {}, - ), - ), - ]), - ), ]), }); @@ -115,33 +57,35 @@ class SettingsPage extends ConsumerWidget { final selected = useState(0); + final searchBar = SearchAnchor.bar( + barHintText: "Search...", + suggestionsBuilder: (context, controller) { + // TODO + return []; + }, + ); + 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 []; - }, - ), - ], ), body: categoriesArePages ? CustomScrollView( slivers: settingsCategoryGroups .mapTo( (categoryGroup, categories) => [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(12).copyWith(bottom: 4), + child: searchBar, + ), + ), SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric( horizontal: 16, - ).copyWith(top: 8, bottom: 4), + ).copyWith(bottom: 4), child: DividerText(categoryGroup), ), ), @@ -175,6 +119,8 @@ class SettingsPage extends ConsumerWidget { children: [ NavigationRailM3E( type: .alwaysExpand, + trailing: searchBar, + scrollable: true, sections: settingsCategoryGroups .mapTo( (categoryGroup, categories) => -- 2.54.0 From 1a2f40d144ab1799146cbd0ed001db7da8a02726 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 26 Jun 2026 16:21:45 -0400 Subject: [PATCH 09/11] Fix searchbar appearing once per category on thin screens --- lib/pages/settings_page.dart | 80 ++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 8ce4401..e5fb992 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -72,48 +72,50 @@ class SettingsPage extends ConsumerWidget { ), body: categoriesArePages ? CustomScrollView( - slivers: settingsCategoryGroups - .mapTo( - (categoryGroup, categories) => [ - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(12).copyWith(bottom: 4), - child: searchBar, - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 16, - ).copyWith(bottom: 4), - child: DividerText(categoryGroup), - ), - ), - SliverM3ECardList( - padding: .symmetric(horizontal: 4, vertical: 8), - margin: .symmetric(horizontal: 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(), - ), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(12).copyWith(bottom: 4), + child: searchBar, + ), + ), + ...settingsCategoryGroups + .mapTo( + (categoryGroup, categories) => [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + ).copyWith(bottom: 4), + child: DividerText(categoryGroup), ), ), - itemBuilder: (context, index) => ListTile( - leading: Icon(categories[index].icon), - title: Text(categories[index].title), + SliverM3ECardList( + padding: .symmetric(horizontal: 4, vertical: 8), + margin: .symmetric(horizontal: 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, + ], + ) + .flattened, + ], ) : Row( children: [ -- 2.54.0 From 2bcf350989d5516d6d4a4b020318d1b1215d43b6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 30 Jun 2026 21:48:08 -0400 Subject: [PATCH 10/11] further progress --- lib/models/setting.dart | 29 +++-- lib/pages/settings_page.dart | 142 ++++++++++++--------- lib/widgets/settings/dialog_list_tile.dart | 6 +- 3 files changed, 102 insertions(+), 75 deletions(-) diff --git a/lib/models/setting.dart b/lib/models/setting.dart index ae87792..7c643a5 100644 --- a/lib/models/setting.dart +++ b/lib/models/setting.dart @@ -1,12 +1,23 @@ 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; +class Setting { + final String id; + final String title; + final T initialValue; + final String description; + final Widget Function( + String title, + String description, + ValueChanged onChanged, + T currentValue, + ) + builder; + + Setting({ + required this.id, + required this.title, + required this.initialValue, + required this.description, + required this.builder, + }); } diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index e5fb992..716dd04 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -6,6 +6,7 @@ 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/setting.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; @@ -14,45 +15,70 @@ 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) {}, - ), - ), - ]), - ), - ]), - }); - @override Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( builder: (_, constraints) => HookBuilder( builder: (context) { + final IMap> + settingsCategoryGroups = .new({ + "General": .new([ + .new( + title: "Appearance", + icon: Icons.brush, + settings: .new([ + Setting( + id: "dark_mode", + title: "Dark Mode", + initialValue: ThemeMode.system, + description: + "Toggle between Light Mode, Dark Mode, and System themes.", + builder: (title, description, onChanged, currentValue) => + DialogListTile( + icon: Icon(Icons.palette), + title: title, + subtitle: Text(description), + initialValue: currentValue, + options: ThemeMode.values, + getName: (option) => + toBeginningOfSentenceCase(option.name), + onChanged: onChanged, + ), + ), + Setting( + id: "use_csd", + initialValue: true, + title: "Use Client Side Decorations", + description: + "On desktop, toggle between client-side or server-side decorations", + builder: (title, description, onChanged, currentValue) => + SwitchListTile( + title: Text(title), + secondary: Icon(Icons.border_top), + subtitle: Text(description), + value: currentValue, + onChanged: onChanged, + ), + ), + Setting( + id: "use_system_font", + title: "Use System Font", + initialValue: true, + description: + "Use the system's sans and emoji fonts, instead of Flutter's bundled fonts. Turn this off if you are having issues rendering emoji.", + builder: (title, description, onChanged, currentValue) => + SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(Icons.abc), + value: currentValue, + onChanged: onChanged, + ), + ), + ]), + ), + ]), + }); + final categoriesArePages = constraints.maxWidth < 550; final selected = useState(0); @@ -75,7 +101,7 @@ class SettingsPage extends ConsumerWidget { slivers: [ SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(12).copyWith(bottom: 4), + padding: EdgeInsets.all(12).copyWith(bottom: 8), child: searchBar, ), ), @@ -146,33 +172,23 @@ class SettingsPage extends ConsumerWidget { VerticalDivider(), Expanded( child: SuperListView( - children: [ - SwitchListTile( - title: Text("Settings Title"), - value: false, - onChanged: (value) {}, - ), - SwitchListTile( - title: Text("Settings Title"), - value: false, - onChanged: (value) {}, - ), - SwitchListTile( - title: Text("Settings Title"), - value: false, - onChanged: (value) {}, - ), - SwitchListTile( - title: Text("Settings Title"), - value: false, - onChanged: (value) {}, - ), - SwitchListTile( - title: Text("Settings Title"), - value: false, - onChanged: (value) {}, - ), - ], + padding: .symmetric(vertical: 12), + children: settingsCategoryGroups + .values + .flattenedToList[selected.value] + .settings + .map( + (setting) => Padding( + padding: .only(bottom: 4), + child: setting.builder( + setting.title, + setting.description, + (value) {}, + setting.initialValue, + ), + ), + ) + .toList(), ), ), ], diff --git a/lib/widgets/settings/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart index cd03d7e..89b9159 100644 --- a/lib/widgets/settings/dialog_list_tile.dart +++ b/lib/widgets/settings/dialog_list_tile.dart @@ -5,7 +5,7 @@ import "package:nexus/widgets/settings/radio_dialog.dart"; class DialogListTile extends ConsumerWidget { final T? initialValue; final String title; - final String? description; + final Widget? subtitle; final List options; final bool required; final Icon icon; @@ -19,7 +19,7 @@ class DialogListTile extends ConsumerWidget { required this.options, required this.onChanged, required this.getName, - this.description, + this.subtitle, this.required = true, }); @@ -51,7 +51,7 @@ class DialogListTile extends ConsumerWidget { ), ), title: Text(title), - subtitle: description == null ? null : Text(description!), + subtitle: subtitle, leading: icon, trailing: Chip( label: Text( -- 2.54.0 From 37d4590cb8ec27b7b2e30e7ea80061d3a68462e3 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 3 Jul 2026 14:03:29 -0400 Subject: [PATCH 11/11] more restrictive ai policy --- DEVELOPMENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bfd78a8..fb69a56 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -63,7 +63,7 @@ When possible, we prefer not to create global variables or methods. You can usua ## LLM/AI Assisted Contributions -Largely LLM generated code is NOT allowed. All contributions should be written by humans, with minimal to no LLM assistance. Please disclose any usage of LLMs. +LLM generated code is NOT allowed. All contributions should be written by humans. Using LLMs for interacting with others, e.g. for Comments, PRs, etc, is also not allowed. ## Code of Conduct -- 2.54.0