feat(settings): Appearance category + custom-control escape hatch (T-452)

Add the one bespoke control the schema engine defers to. New
SettingsControlContribution routes a WidgetBuilder into a kernel
SettingsControlRegistry under a customId; a SettingsFieldKind.custom field
names that id, and the renderer draws the registered widget full-width
(label on top, no scope tag — the control owns its own apply + scope).

The theme-picker extension uses it: an Appearance category whose theme field
is custom, backed by AppearanceThemeControl — base-theme chips + a
high-contrast toggle that apply live through ThemeController (persisted by
theme_persistence). Reuses the shared theme_families helpers.

Tests: control registry (register/dup/unregister), the renderer's custom-field
path, and the Appearance contribution + live theme apply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 13:04:52 +02:00
co-authored by Claude Opus 4.8
parent 727fe8fdd4
commit 757d6f71fa
16 changed files with 324 additions and 14 deletions
@@ -149,6 +149,25 @@ void main() {
});
});
group('custom field (T-452)', () {
testWidgets('renders the control registered for its customId, full-width with its label', (tester) async {
f.services.settingsControlRegistry.register('probe', (_) => const ClideText('PROBE-CONTROL'));
const cat = SettingsCategory(
id: 'c',
title: 'C',
sections: [
SettingsSection(
label: 'S',
fields: [SettingsField(key: 'app.c.x', kind: SettingsFieldKind.custom, label: 'Custom', customId: 'probe')],
),
],
);
await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: cat))));
expect(find.text('PROBE-CONTROL'), findsOneWidget);
expect(find.text('Custom'), findsOneWidget);
});
});
group('scope tag (T-449)', () {
testWidgets('an unset field shows the Default scope tag', (tester) async {
await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: _category))));
@@ -187,5 +187,21 @@ void main() {
expect(resp.ok, isTrue);
expect(resp.data['selected'], 'forest');
});
test('contributes an Appearance category + theme.picker control (T-452)', () {
final ext = ThemePickerExtension();
final cat = ext.contributions.whereType<SettingsCategoryContribution>().firstWhere((c) => c.id == 'appearance').category;
expect(cat.title, 'Appearance');
expect(ext.contributions.whereType<SettingsControlContribution>().any((c) => c.customId == 'theme.picker'), isTrue);
});
testWidgets('AppearanceThemeControl lists base themes and applies a pick (T-452)', (tester) async {
await tester.pumpWidget(harness(f, const SizedBox(width: 420, child: AppearanceThemeControl())));
expect(find.text('summer-night'), findsOneWidget);
expect(find.text('forest'), findsOneWidget);
await tester.tap(find.text('forest'));
await tester.pump();
expect(f.services.theme.currentName, 'forest');
});
});
}
@@ -0,0 +1,26 @@
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('SettingsControlRegistry', () {
Widget builder(BuildContext _) => const SizedBox.shrink();
test('register exposes a builder by id', () {
final r = SettingsControlRegistry()..register('theme.picker', builder);
expect(r.builderFor('theme.picker'), isNotNull);
expect(r.builderFor('missing'), isNull);
});
test('duplicate id throws', () {
final r = SettingsControlRegistry()..register('dup', builder);
expect(() => r.register('dup', builder), throwsStateError);
});
test('unregister removes the builder', () {
final r = SettingsControlRegistry()..register('x', builder);
r.unregister('x');
expect(r.builderFor('x'), isNull);
});
});
}