Add settings page (#46)

Fixes #37, fixes #47.

Reviewed-on: Nexus/nexus#46
This commit is contained in:
Henry Hiles 2026-07-17 20:36:41 -04:00 committed by Henry Hiles
commit 0ef9b25320
20 changed files with 970 additions and 28 deletions

View file

@ -1,11 +1,236 @@
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:m3e_card_list/m3e_card_list.dart";
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:nexus/controllers/settings_sections_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/show_about_dialog.dart";
import "package:nexus/pages/settings_category_page.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/highlight_wrapper.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 categoriesArePages = constraints.maxWidth < 550;
final selected = useState(0);
final highlightedMatch = useState<(int, int)?>(null);
final listController = useRef(ListController());
final scrollController = useScrollController();
final searchBar = SearchAnchor.bar(
barHintText: "Search...",
suggestionsBuilder: (suggestionsContext, controller) async {
final categories = await ref.watch(
SettingsSectionsController.provider.future,
);
final query = controller.text.toLowerCase();
final matches = categories.values
.expand((categoryList) => categoryList.asMap().entries)
.expand(
(categoryEntry) => categoryEntry.value.settings
.asMap()
.entries
.where(
(settingEntry) =>
settingEntry.value.title.toLowerCase().contains(
query,
) ||
settingEntry.value.description
.toLowerCase()
.contains(query),
)
.map(
(settingEntry) => (
(categoryEntry.key, settingEntry.key),
settingEntry.value,
),
),
)
.toIList();
return matches.map(
(match) => ListTile(
onTap: () async {
if (context.mounted) Navigator.of(suggestionsContext).pop();
controller.text = "";
if (categoriesArePages) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => SettingsCategoryPage(
match.$1.$1,
initialHighlight: match.$1.$2,
),
),
);
} else {
selected.value = match.$1.$1;
listController.value.animateToItem(
index: match.$1.$2,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut,
);
highlightedMatch.value = match.$1;
await Future.delayed(.new(seconds: 1), () {
if (highlightedMatch.value == match.$1) {
highlightedMatch.value = null;
}
});
}
},
leading: Icon(match.$2.icon),
title: Text(match.$2.title),
subtitle: Text(match.$2.description),
),
);
},
);
final settingsContent = Scaffold(
appBar: AppBar(
title: Text("Settings"),
actionsPadding: .symmetric(horizontal: 12),
actions: [
IconButton(
onPressed: () => context.showAboutDialog(ref),
icon: Icon(Icons.info_outline),
),
],
),
body: ref
.watch(SettingsSectionsController.provider)
.betterWhen(
data: (sections) => categoriesArePages
? CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(12).copyWith(bottom: 8),
child: searchBar,
),
),
...sections
.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) =>
SettingsCategoryPage(index),
),
),
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: sections
.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(
listController: listController.value,
controller: scrollController,
padding: .symmetric(vertical: 12),
children: sections
.values
.flattenedToList[selected.value]
.settings
.mapIndexed(
(index, setting) => Padding(
padding: .only(bottom: 4),
child: HighlightWrapper(
setting.builder(
setting.title,
setting.description,
setting.icon,
),
isHighlighted:
highlightedMatch.value ==
(selected.value, index),
),
),
)
.toList(),
),
),
],
),
),
);
return constraints.maxWidth < 650
? settingsContent
: Dialog(
constraints: .loose(Size(900, 600)),
child: ClipRRect(
borderRadius: BorderRadiusGeometry.circular(12),
child: settingsContent,
),
);
},
),
);
}