From cba6907d58bfc8b296f2819898313fcce354cba5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:01:27 +0300 Subject: [PATCH 1/2] feat(settings): wire ThemePickerButton into Preferences appearance Replace the Color preset dropdown with a registry-backed Theme row, inject built-in Querya Dark/Light definitions, and route selection and hover preview through ThemeController. --- lib/core/theme/theme_controller.dart | 98 ++++++++++++++++++- .../preferences_appearance_section.dart | 64 ++++++------ 2 files changed, 129 insertions(+), 33 deletions(-) diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 05184492..29ae0193 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -17,6 +17,30 @@ import 'theme_registry_service.dart'; /// Active theme state: preset, optional imported colors, user overrides. class ThemeController extends ChangeNotifier { + static const String builtinQueryaDarkId = 'querya-dark'; + static const String builtinQueryaLightId = 'querya-light'; + + static const ThemeDefinition builtinQueryaDarkDefinition = ThemeDefinition( + id: builtinQueryaDarkId, + name: 'Querya Dark', + source: ThemeSource.builtin, + format: ThemeFormat.queryaCustom, + isDark: true, + ); + + static const ThemeDefinition builtinQueryaLightDefinition = ThemeDefinition( + id: builtinQueryaLightId, + name: 'Querya Light', + source: ThemeSource.builtin, + format: ThemeFormat.queryaCustom, + isDark: false, + ); + + static const List _builtinThemeDefinitions = [ + builtinQueryaDarkDefinition, + builtinQueryaLightDefinition, + ]; + ThemeRegistryService _registryService; ThemeController._({ThemeRegistryService? registryService}) @@ -69,6 +93,16 @@ class ThemeController extends ChangeNotifier { String? get selectedThemeLoadError => _selectedThemeLoadError; + /// Theme id for registry-backed selection, or built-in/legacy preset ids. + String get effectiveSelectedThemeId { + if (_selectedThemeId != null) return _selectedThemeId!; + return switch (_preset) { + QueryaThemePreset.queryaLight => builtinQueryaLightId, + QueryaThemePreset.imported => ThemeImportService.legacyImportedThemeId, + _ => builtinQueryaDarkId, + }; + } + /// User `workbench.colorCustomizations` layer (VS Code keys → hex). Map get userColorOverrides => Map.unmodifiable(_userOverrides); @@ -171,7 +205,9 @@ class ThemeController extends ChangeNotifier { _themeAnimationEnabled = await AppSettings.instance.getThemeAnimationEnabled(); - _availableThemes = await _registryService.loadThemeDefinitions(); + _availableThemes = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); await _restoreSelectedRegistryTheme(); _loaded = true; @@ -179,11 +215,22 @@ class ThemeController extends ChangeNotifier { } Future loadAvailableThemes() async { - _availableThemes = await _registryService.loadThemeDefinitions(); + _availableThemes = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); notifyListeners(); } Future setThemeById(String id) async { + if (id == builtinQueryaDarkId) { + await _applyBuiltinPreset(QueryaThemePreset.queryaDark); + return; + } + if (id == builtinQueryaLightId) { + await _applyBuiltinPreset(QueryaThemePreset.queryaLight); + return; + } + final definition = _definitionById(id); if (definition == null) { _selectedThemeLoadError = 'Theme "$id" not found.'; @@ -214,6 +261,19 @@ class ThemeController extends ChangeNotifier { } Future previewThemeById(String id) async { + if (id == builtinQueryaDarkId) { + return const ThemeLoadSuccess( + definition: builtinQueryaDarkDefinition, + theme: QueryaTheme.darkDefault, + ); + } + if (id == builtinQueryaLightId) { + return const ThemeLoadSuccess( + definition: builtinQueryaLightDefinition, + theme: QueryaTheme.lightDefault, + ); + } + final definition = _definitionById(id); if (definition == null) { return ThemeLoadFailure( @@ -288,7 +348,9 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemeImportPath(storedPath); await AppSettings.instance.setThemePreset(QueryaThemePreset.imported); await AppSettings.instance.setThemeMode(_themeMode); - _availableThemes = await _registryService.loadThemeDefinitions(); + _availableThemes = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); _notifyThemeChanged(); return result; case ThemeImportFailure(): @@ -330,7 +392,9 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemePreset(_preset); await AppSettings.instance.setThemeMode(_themeMode); } - _availableThemes = await _registryService.loadThemeDefinitions(); + _availableThemes = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); _notifyThemeChanged(); } @@ -344,7 +408,7 @@ class ThemeController extends ChangeNotifier { _userOverrides = const {}; _importedThemeName = null; _themeAnimationEnabled = false; - _availableThemes = const []; + _availableThemes = List.unmodifiable(_builtinThemeDefinitions); _selectedThemeId = null; _selectedThemePath = null; _selectedThemeLoadError = null; @@ -399,6 +463,30 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.clearSelectedThemeRegistry(); } + Future _applyBuiltinPreset(QueryaThemePreset preset) async { + await _clearRegistrySelection(); + _preset = preset; + _themeMode = preset == QueryaThemePreset.queryaLight + ? ThemeMode.light + : ThemeMode.dark; + await AppSettings.instance.setThemePreset(preset); + await AppSettings.instance.setThemeMode(_themeMode); + _notifyThemeChanged(); + } + + List _mergeBuiltinThemes(List scanned) { + final merged = [..._builtinThemeDefinitions]; + for (final definition in scanned) { + if (!_builtinThemeDefinitions.any((builtin) => builtin.id == definition.id)) { + merged.add(definition); + } + } + merged.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + return List.unmodifiable(merged); + } + ThemeDefinition? _definitionById( String id, { String? source, diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index c5706448..032e361a 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -2,10 +2,12 @@ import 'dart:async' show unawaited; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; +import 'package:querya_desktop/core/theme/theme_load_result.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/features/settings/theme_picker_button.dart'; +import 'package:querya_desktop/features/settings/theme_preview_card.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Appearance / theme controls for [PreferencesDialog]. @@ -43,8 +45,16 @@ class _PreferencesAppearanceSectionState await _controller.setThemeMode(mode); } - Future _setPreset(QueryaThemePreset preset) async { - await _controller.setPreset(preset); + Future _setThemeById(String id) async { + await _controller.setThemeById(id); + } + + Future _previewThemeById(String id) async { + final result = await _controller.previewThemeById(id); + return switch (result) { + ThemeLoadSuccess(:final theme) => ThemePreviewResult.theme(theme), + ThemeLoadFailure(:final message) => ThemePreviewResult.error(message), + }; } Future _pickAndImportTheme() async { @@ -91,9 +101,7 @@ class _PreferencesAppearanceSectionState @override material.Widget build(material.BuildContext context) { final c = _controller; - final importedLabel = c.hasImportedTheme - ? 'Imported: ${c.importedThemeName ?? 'theme'}' - : 'Imported theme (none)'; + final themes = c.availableThemes; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, @@ -125,30 +133,30 @@ class _PreferencesAppearanceSectionState ), const material.SizedBox(height: 12), PreferencesFieldRow( - label: 'Color preset', - control: PreferencesDropdownMenu( - value: c.preset, - onSelected: (v) { - if (v != null) unawaited(_setPreset(v)); - }, - entries: [ - const material.DropdownMenuEntry( - value: QueryaThemePreset.queryaDark, - label: 'Querya Dark', - ), - const material.DropdownMenuEntry( - value: QueryaThemePreset.queryaLight, - label: 'Querya Light', - ), - material.DropdownMenuEntry( - value: QueryaThemePreset.imported, - enabled: c.hasImportedTheme, - label: importedLabel, - ), - ], + label: 'Theme', + control: ThemePickerButton( + themes: themes, + selectedThemeId: c.effectiveSelectedThemeId, + expandToParent: true, + onSelected: (id) => unawaited(_setThemeById(id)), + onPreviewTheme: _previewThemeById, ), ), - const material.SizedBox(height: 12), + if (c.selectedThemeLoadError != null) ...[ + const material.SizedBox(height: 8), + material.Padding( + padding: const material.EdgeInsets.only( + left: kPreferencesLabelWidth + 12, + ), + child: material.Text( + c.selectedThemeLoadError!, + style: material.TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.destructive, + ), + ), + ), + ], const PreferencesFieldRow( label: 'Interface scale', hint: From 124d51d009a8c2c89e49f8eac8d7ca0c3750c6ca Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:01:27 +0300 Subject: [PATCH 2/2] test(settings): cover Preferences theme picker integration Closes #116 --- test/core/theme/theme_controller_test.dart | 42 +++++++ .../preferences_appearance_section_test.dart | 113 ++++++++++++++++++ .../settings/theme_picker_button_test.dart | 44 +++++++ 3 files changed, 199 insertions(+) create mode 100644 test/features/settings/preferences_appearance_section_test.dart diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index d767ac26..4e852fa5 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -83,6 +83,11 @@ void main() { expect(c.preset, QueryaThemePreset.queryaDark); expect(c.activeTheme, QueryaTheme.darkDefault); expect(c.isLoaded, isTrue); + expect(c.availableThemes.map((theme) => theme.id), containsAll([ + ThemeController.builtinQueryaDarkId, + ThemeController.builtinQueryaLightId, + ])); + expect(c.effectiveSelectedThemeId, ThemeController.builtinQueryaDarkId); }); test('setThemeMode light persists and updates activeTheme', () async { @@ -240,5 +245,42 @@ void main() { expect(c.activeTheme, QueryaTheme.lightDefault); expect(await AppSettings.instance.getSelectedThemeId(), isNull); }); + + test('setThemeById applies built-in Querya Light preset', () async { + final c = ThemeController.instance; + await c.load(); + + await c.setThemeById(ThemeController.builtinQueryaLightId); + + expect(c.preset, QueryaThemePreset.queryaLight); + expect(c.selectedThemeId, isNull); + expect(c.effectiveSelectedThemeId, ThemeController.builtinQueryaLightId); + expect(c.activeTheme, QueryaTheme.lightDefault); + expect(await AppSettings.instance.getSelectedThemeId(), isNull); + }); + + test('previewThemeById returns built-in theme without registry file', + () async { + final c = ThemeController.instance; + await c.load(); + + final result = await c.previewThemeById(ThemeController.builtinQueryaDarkId); + + expect(result, isA()); + expect((result as ThemeLoadSuccess).theme, QueryaTheme.darkDefault); + }); + + test('resetToDefaults keeps built-in themes in picker list', () async { + final c = ThemeController.instance; + await c.load(); + await c.setThemeMode(ThemeMode.light); + await c.resetToDefaults(); + + expect(c.availableThemes.map((theme) => theme.id), containsAll([ + ThemeController.builtinQueryaDarkId, + ThemeController.builtinQueryaLightId, + ])); + expect(c.effectiveSelectedThemeId, ThemeController.builtinQueryaDarkId); + }); }); } diff --git a/test/features/settings/preferences_appearance_section_test.dart b/test/features/settings/preferences_appearance_section_test.dart new file mode 100644 index 00000000..40cbeb54 --- /dev/null +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:querya_desktop/core/theme/theme_import_service.dart'; +import 'package:querya_desktop/core/theme/theme_registry_service.dart'; +import 'package:querya_desktop/features/settings/preferences_appearance_section.dart'; +import 'package:querya_desktop/features/settings/theme_picker_button.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late Directory themesDir; + late Directory importedDir; + late ThemeRegistryService registry; + + setUpAll(() async { + tempDir = await Directory.systemTemp + .createTemp('querya_preferences_appearance_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + setUp(() async { + themesDir = Directory(p.join(tempDir.path, 'themes')); + importedDir = Directory(p.join(themesDir.path, 'imported')); + await importedDir.create(recursive: true); + registry = ThemeRegistryService( + userThemesDirectory: () async => themesDir, + importedThemesDirectory: () async => importedDir, + ); + ThemeController.instance.setRegistryServiceForTest(registry); + await ThemeController.instance.load(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + await AppSettings.instance.clearThemeSettings(); + await ThemeImportService.deletePersistedImport(); + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + ThemeController.instance.setRegistryServiceForTest(ThemeRegistryService()); + await ThemeController.instance.load(); + }); + + group('PreferencesAppearanceSection', () { + Future pumpSection(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const material.Size(1280, 900)); + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: material.SizedBox( + width: 640, + child: PreferencesAppearanceSection(), + ), + ), + ), + ); + await tester.pump(); + } + + testWidgets('shows built-in themes in ThemePickerButton', (tester) async { + await pumpSection(tester); + + expect(find.text('Theme'), findsOneWidget); + expect(find.text('Color preset'), findsNothing); + expect(find.text('Querya Dark'), findsOneWidget); + expect(find.byType(ThemePickerButton), findsOneWidget); + + await tester.tap(find.text('Querya Dark')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('Querya Light'), findsOneWidget); + }); + + testWidgets('import and reset buttons remain visible', (tester) async { + await pumpSection(tester); + + expect(find.text('Import theme…'), findsOneWidget); + expect(find.text('Reset appearance'), findsOneWidget); + }); + }); +} diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index dcfe929f..ec3726e6 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_definition.dart'; import 'package:querya_desktop/features/settings/theme_picker_button.dart'; import 'package:querya_desktop/features/settings/theme_preview_card.dart'; @@ -415,5 +416,48 @@ void main() { expect(find.byType(ThemePreviewCard), findsNothing); }); + + testWidgets('tap row still selects when onPreviewTheme is provided', + (tester) async { + String? picked; + const themes = [ + ThemeDefinition( + id: ThemeController.builtinQueryaDarkId, + name: 'Querya Dark', + source: ThemeSource.builtin, + format: ThemeFormat.queryaCustom, + isDark: true, + ), + ThemeDefinition( + id: ThemeController.builtinQueryaLightId, + name: 'Querya Light', + source: ThemeSource.builtin, + format: ThemeFormat.queryaCustom, + isDark: false, + ), + ]; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: ThemeController.builtinQueryaDarkId, + expandToParent: true, + onSelected: (id) => picked = id, + onPreviewTheme: (_) async => + const ThemePreviewResult.theme(QueryaTheme.darkDefault), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Querya Dark')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Querya Light')); + await tester.pumpAndSettle(); + + expect(picked, ThemeController.builtinQueryaLightId); + }); }); }