WIP: Add settings page #46

Draft
Henry-Hiles wants to merge 11 commits from settings-page into main
7 changed files with 366 additions and 9 deletions

View file

@ -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

23
lib/models/setting.dart Normal file
View file

@ -0,0 +1,23 @@
import "package:flutter/material.dart";
class Setting<T> {
final String id;
final String title;
final T initialValue;
final String description;
final Widget Function(
String title,
String description,
ValueChanged<T> onChanged,
T currentValue,
)
builder;
Setting({
required this.id,
required this.title,
required this.initialValue,
required this.description,
required this.builder,
});
}

View file

@ -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<Setting> settings,
}) = _SettingsCategory;
}

View file

@ -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<String, IList<SettingsCategory>>
settingsCategoryGroups = .new({
"General": .new([
.new(
title: "Appearance",
icon: Icons.brush,
settings: .new([
Setting<ThemeMode>(
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<ThemeMode>(
icon: Icon(Icons.palette),
title: title,
subtitle: Text(description),
initialValue: currentValue,
options: ThemeMode.values,
getName: (option) =>
toBeginningOfSentenceCase(option.name),
onChanged: onChanged,
),
),
Setting<bool>(
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<bool>(
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,
),
);
},
),
);
}

View file

@ -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<T> extends ConsumerWidget {
final T? initialValue;
final String title;
final Widget? subtitle;
final List<T> 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<T>(
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,
),
),
),
),
);
}

View file

@ -0,0 +1,55 @@
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
class RadioDialog<T> extends HookWidget {
final T? value;
final String title;
final List<T> 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<T?>(null);
return AlertDialog(
title: Text(title),
content: RadioGroup<T>(
groupValue: mutValue.value ?? value,
onChanged: (value) => mutValue.value = value ?? mutValue.value,
child: Column(
mainAxisSize: MainAxisSize.min,
children: options
.map(
(option) => RadioListTile<T>(
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"),
),
],
);
}
}

View file

@ -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),
),
],