working recording of settings

Only tested on Linux, but should work elsewhere hopefully.
This commit is contained in:
Henry Hiles 2026-07-14 21:20:37 -04:00
commit 89057b48c2
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
7 changed files with 213 additions and 143 deletions

View file

@ -0,0 +1,27 @@
import "dart:convert";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/settings_file_controller.dart";
import "package:nexus/models/settings.dart";
class SettingsController extends AsyncNotifier<Settings> {
@override
Future<Settings> build() async {
final file = await ref.watch(SettingsFileController.provider.future);
try {
return Settings.fromJson(json.decode(await file.readAsString()));
} catch (_) {
return Settings();
}
}
Future<void> set(Settings settings) async {
state = AsyncData(settings);
final file = await ref.watch(SettingsFileController.provider.future);
await file.writeAsString(json.encode(settings.toJson()));
}
static final provider = AsyncNotifierProvider<SettingsController, Settings>(
SettingsController.new,
);
}

View file

@ -0,0 +1,23 @@
import "dart:io";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:path/path.dart";
import "package:path_provider/path_provider.dart";
import "package:xdg_directories/xdg_directories.dart";
class SettingsFileController extends AsyncNotifier<File> {
@override
Future<File> build() async {
final directory = await switch (Platform.isLinux) {
true => Directory(
join(configHome.absolute.path, "nexus"),
).create(recursive: true),
false => getApplicationSupportDirectory(),
};
return File(join(directory.absolute.path, "config.json"));
}
static final provider = AsyncNotifierProvider<SettingsFileController, File>(
SettingsFileController.new,
);
}

View file

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

16
lib/models/settings.dart Normal file
View file

@ -0,0 +1,16 @@
import "package:flutter/material.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "settings.freezed.dart";
part "settings.g.dart";
@freezed
abstract class Settings with _$Settings {
const factory Settings({
@Default(ThemeMode.light) ThemeMode theme,
@Default(true) bool useClientSideGenerations,
@Default(false) bool useSystemFont,
}) = _Settings;
factory Settings.fromJson(Map<String, Object?> json) =>
_$SettingsFromJson(json);
}

View file

@ -6,11 +6,15 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:intl/intl.dart"; import "package:intl/intl.dart";
import "package:m3e_card_list/m3e_card_list.dart"; import "package:m3e_card_list/m3e_card_list.dart";
import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:nexus/controllers/settings_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/setting.dart"; import "package:nexus/models/setting.dart";
import "package:nexus/models/settings.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:super_sliver_list/super_sliver_list.dart"; import "package:super_sliver_list/super_sliver_list.dart";
import "package:nexus/main.dart";
class SettingsPage extends ConsumerWidget { class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@ -19,60 +23,60 @@ class SettingsPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
builder: (_, constraints) => HookBuilder( builder: (_, constraints) => HookBuilder(
builder: (context) { builder: (context) {
final IMap<String, IList<SettingsCategory>> IMap<String, IList<SettingsCategory>> buildCategories(
settingsCategoryGroups = .new({ Settings settings,
) => .new({
"General": .new([ "General": .new([
.new( .new(
title: "Appearance", title: "Appearance",
icon: Icons.brush, icon: Icons.brush,
settings: .new([ settings: .new([
Setting<ThemeMode>( Setting(
id: "dark_mode", title: "Theme",
title: "Dark Mode",
initialValue: ThemeMode.system,
description: description:
"Toggle between Light Mode, Dark Mode, and System themes.", "Toggle between Light Mode, Dark Mode, and System themes.",
builder: (title, description, onChanged, currentValue) => builder: (title, description) => DialogListTile<ThemeMode>(
DialogListTile<ThemeMode>( icon: Icon(Icons.palette),
icon: Icon(Icons.palette), title: title,
title: title, subtitle: Text(description),
subtitle: Text(description), initialValue: settings.theme,
initialValue: currentValue, options: ThemeMode.values,
options: ThemeMode.values, getName: (option) => toBeginningOfSentenceCase(option.name),
getName: (option) => onChanged: (value) => ref
toBeginningOfSentenceCase(option.name), .watch(SettingsController.provider.notifier)
onChanged: onChanged, .set(settings.copyWith(theme: value))
), .onError(showError),
),
), ),
Setting<bool>( Setting(
id: "use_csd",
initialValue: true,
title: "Use Client Side Decorations", title: "Use Client Side Decorations",
description: description:
"On desktop, toggle between client-side or server-side decorations", "On desktop, toggle between client-side or server-side decorations",
builder: (title, description, onChanged, currentValue) => builder: (title, description) => SwitchListTile(
SwitchListTile( title: Text(title),
title: Text(title), secondary: Icon(Icons.border_top),
secondary: Icon(Icons.border_top), subtitle: Text(description),
subtitle: Text(description), value: settings.useClientSideGenerations,
value: currentValue, onChanged: (value) => ref
onChanged: onChanged, .watch(SettingsController.provider.notifier)
), .set(settings.copyWith(useClientSideGenerations: value))
.onError(showError),
),
), ),
Setting<bool>( Setting(
id: "use_system_font",
title: "Use System Font", title: "Use System Font",
initialValue: true,
description: 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.", "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) => builder: (title, description) => SwitchListTile(
SwitchListTile( title: Text(title),
title: Text(title), subtitle: Text(description),
subtitle: Text(description), secondary: Icon(Icons.abc),
secondary: Icon(Icons.abc), value: settings.useSystemFont,
value: currentValue, onChanged: (value) => ref
onChanged: onChanged, .watch(SettingsController.provider.notifier)
), .set(settings.copyWith(useSystemFont: value))
.onError(showError),
),
), ),
]), ]),
), ),
@ -96,103 +100,112 @@ class SettingsPage extends ConsumerWidget {
title: Text("Settings"), title: Text("Settings"),
actionsPadding: .symmetric(horizontal: 12), actionsPadding: .symmetric(horizontal: 12),
), ),
body: categoriesArePages body: ref
? CustomScrollView( .watch(SettingsController.provider)
slivers: [ .betterWhen(
SliverToBoxAdapter( data: (settings) => categoriesArePages
child: Padding( ? CustomScrollView(
padding: EdgeInsets.all(12).copyWith(bottom: 8), slivers: [
child: searchBar, SliverToBoxAdapter(
), child: Padding(
), padding: EdgeInsets.all(12).copyWith(bottom: 8),
...settingsCategoryGroups child: searchBar,
.mapTo(
(categoryGroup, categories) => [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 4),
child: DividerText(categoryGroup),
),
), ),
SliverM3ECardList( ),
padding: .symmetric(horizontal: 4, vertical: 8), ...buildCategories(settings)
margin: .symmetric(horizontal: 12), .mapTo(
color: Theme.of( (categoryGroup, categories) => [
context, SliverToBoxAdapter(
).colorScheme.primaryContainer, child: Padding(
itemCount: categories.length, padding: EdgeInsets.symmetric(
onTap: (index) => Navigator.of(context).push( horizontal: 16,
MaterialPageRoute( ).copyWith(bottom: 4),
builder: (context) => Scaffold( child: DividerText(categoryGroup),
appBar: AppBar( ),
),
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), title: Text(categories[index].title),
), ),
body: ListView(),
), ),
), ],
), )
itemBuilder: (context, index) => ListTile( .flattened,
leading: Icon(categories[index].icon), ],
title: Text(categories[index].title), )
), : Row(
children: [
NavigationRailM3E(
type: .alwaysExpand,
trailing: searchBar,
scrollable: true,
sections: buildCategories(settings)
.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: buildCategories(settings)
.values
.flattenedToList[selected.value]
.settings
.map(
(setting) => Padding(
padding: .only(bottom: 4),
child: setting.builder(
setting.title,
setting.description,
),
),
)
.toList(),
), ),
], ),
) ],
.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 return constraints.maxWidth < 650

View file

@ -1646,7 +1646,7 @@ packages:
source: hosted source: hosted
version: "0.5.1" version: "0.5.1"
xdg_directories: xdg_directories:
dependency: transitive dependency: "direct main"
description: description:
name: xdg_directories name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"

View file

@ -67,6 +67,7 @@ dependencies:
url: https://github.com/Henry-Hiles/material_3_expressive url: https://github.com/Henry-Hiles/material_3_expressive
path: packages/navigation_rail_m3e path: packages/navigation_rail_m3e
m3e_card_list: ^0.1.0 m3e_card_list: ^0.1.0
xdg_directories: ^1.1.0
dev_dependencies: dev_dependencies:
build_runner: 2.15.0 build_runner: 2.15.0