Compare commits

...
Author SHA1 Message Date
37d4590cb8
more restrictive ai policy 2026-07-03 14:03:29 -04:00
2bcf350989
further progress 2026-06-30 21:48:08 -04:00
1a2f40d144
Fix searchbar appearing once per category on thin screens 2026-06-26 16:21:45 -04:00
f0c3d4376f
add searchbar 2026-06-26 16:16:36 -04:00
6263fe3a37
slightly tweak padding 2026-06-26 15:50:48 -04:00
6726e544bd
dynamic category groups 2026-06-26 15:49:37 -04:00
6ed01cf61b
mobile settings mockup 2026-06-26 14:17:31 -04:00
3e01e3c50e
adjust padding 2026-06-23 20:22:04 -04:00
ab051c75f5
add categoriesArePages variable 2026-06-23 20:02:15 -04:00
a916013800
make settings page a dialog
This will need changes to work properly on mobile
2026-06-23 19:12:29 -04:00
5ade674897
initialize settings page
There are no settings yet
2026-06-23 18:29:43 -04:00
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 ## 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 ## 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/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 { class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
return Placeholder(); 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/key_controller.dart";
import "package:nexus/controllers/spaces_controller.dart"; import "package:nexus/controllers/spaces_controller.dart";
import "package:nexus/models/room.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/avatar_or_hash.dart";
import "package:nexus/widgets/divider_widget.dart"; import "package:nexus/widgets/divider_widget.dart";
import "package:nexus/widgets/join_dialog.dart"; import "package:nexus/widgets/join_dialog.dart";
@ -181,10 +182,10 @@ class Sidebar extends HookConsumerWidget {
), ),
IconButton( IconButton(
tooltip: "Open settings", tooltip: "Open settings",
onPressed: null, onPressed: () => showDialog(
// () => Navigator.of( context: context,
// context, builder: (_) => SettingsPage(),
// ).push(MaterialPageRoute(builder: (_) => SettingsPage())), ),
icon: Icon(Icons.settings), icon: Icon(Icons.settings),
), ),
], ],