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 diff --git a/lib/models/setting.dart b/lib/models/setting.dart new file mode 100644 index 0000000..7c643a5 --- /dev/null +++ b/lib/models/setting.dart @@ -0,0 +1,23 @@ +import "package:flutter/material.dart"; + +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/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 505904c..716dd04 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,11 +1,210 @@ +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/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"; +import "package:super_sliver_list/super_sliver_list.dart"; class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - return Placeholder(); - } + 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); + + final searchBar = SearchAnchor.bar( + barHintText: "Search...", + suggestionsBuilder: (context, controller) { + // TODO + return []; + }, + ); + + final settingsContent = Scaffold( + appBar: AppBar( + title: Text("Settings"), + actionsPadding: .symmetric(horizontal: 12), + ), + body: categoriesArePages + ? CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(12).copyWith(bottom: 8), + child: searchBar, + ), + ), + ...settingsCategoryGroups + .mapTo( + (categoryGroup, categories) => [ + 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(), + ), + ), + ), + itemBuilder: (context, index) => ListTile( + leading: Icon(categories[index].icon), + title: Text(categories[index].title), + ), + ), + ], + ) + .flattened, + ], + ) + : Row( + children: [ + NavigationRailM3E( + type: .alwaysExpand, + trailing: searchBar, + scrollable: true, + 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( + child: SuperListView( + 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(), + ), + ), + ], + ), + ); + + 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..89b9159 --- /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 Widget? subtitle; + 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.subtitle, + 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: subtitle, + 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"), + ), + ], + ); + } +} diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index 56f8d57..f382b97 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,10 @@ class Sidebar extends HookConsumerWidget { ), IconButton( tooltip: "Open settings", - onPressed: null, - // () => Navigator.of( - // context, - // ).push(MaterialPageRoute(builder: (_) => SettingsPage())), + onPressed: () => showDialog( + context: context, + builder: (_) => SettingsPage(), + ), icon: Icon(Icons.settings), ), ],