feat(settings): schema-driven settings engine (T-448)

The core of the settings panel (epic T-444). Subsystems describe a category
as data — a SettingsCategory of carded SettingsSections of SettingsFields
(toggle / select / text / number / opens-external-file), each bound to a
SettingsStore key with help text, a default, and reset-to-default.

Registration is declarative: a new SettingsCategoryContribution carries the
category; the extension manager routes it into a new kernel SettingsRegistry
(exposed on KernelServices), which the panel reads via ClideKernel. Adding a
category is now pure data + a contribution — no widget code.

SettingsCategoryView renders a category into carded sections per ui-design
surface.md: panelHeader card fill, dividerColor border, inputs receding to
panelBackground; select reuses the anchored-overlay menu, text/number commit
on Enter or blur (numeric clamps to bounds). The modal panel now shows the
selected/first registered category, falling back to the empty state.

Tests: registry (sort / dedup / notify), contribution routing on activation,
renderer (render + toggle/select write-through + reset), modal-with-category.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 12:07:51 +02:00
co-authored by Claude Opus 4.8
parent 643f40d7b2
commit 4bbb0ee4b3
15 changed files with 890 additions and 22 deletions
@@ -0,0 +1,48 @@
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
SettingsCategory _cat(String id, {String? title, int priority = 0}) =>
SettingsCategory(id: id, title: title ?? id, priority: priority, sections: const []);
void main() {
group('SettingsRegistry', () {
test('register exposes categories sorted by (priority, title)', () {
final r = SettingsRegistry();
r.register(_cat('b', title: 'Beta', priority: 10));
r.register(_cat('a', title: 'Alpha', priority: 10));
r.register(_cat('z', title: 'Zeta', priority: 0));
expect(r.categories.map((c) => c.id).toList(), ['z', 'a', 'b']);
});
test('byId resolves a registered category', () {
final r = SettingsRegistry()..register(_cat('editor', title: 'Editor'));
expect(r.byId('editor')?.title, 'Editor');
expect(r.byId('missing'), isNull);
});
test('duplicate id throws (rolls activation back)', () {
final r = SettingsRegistry()..register(_cat('dup'));
expect(() => r.register(_cat('dup')), throwsStateError);
});
test('notifies on register and unregister', () {
final r = SettingsRegistry();
var n = 0;
r.addListener(() => n++);
r.register(_cat('x'));
expect(n, 1);
r.unregister('x');
expect(n, 2);
expect(r.categories, isEmpty);
});
test('unregister of an unknown id is a no-op (no notify)', () {
final r = SettingsRegistry();
var n = 0;
r.addListener(() => n++);
r.unregister('nope');
expect(n, 0);
});
});
}