From 64c653e2a344b57e206ea3e2c939e6300364475d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:26:11 +0300 Subject: [PATCH 1/3] feat(theme): integrate ThemeRegistry into ThemeController Load registry definitions on startup, restore selected theme by id, and add setThemeById/previewThemeById while preserving legacy preset behavior. --- lib/core/theme/theme_controller.dart | 189 ++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 7 deletions(-) diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 8abdb638..6bb3f590 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/theme/querya_material_theme.dart'; @@ -10,11 +11,17 @@ import 'parser/vscode_colors_merge.dart'; import 'parser/vscode_theme_manifest.dart'; import 'querya_theme.dart'; import 'querya_theme_preset.dart'; +import 'theme_definition.dart'; import 'theme_import_service.dart'; +import 'theme_load_result.dart'; +import 'theme_registry_service.dart'; /// Active theme state: preset, optional imported colors, user overrides. class ThemeController extends ChangeNotifier { - ThemeController._(); + ThemeRegistryService _registryService; + + ThemeController._({ThemeRegistryService? registryService}) + : _registryService = registryService ?? ThemeRegistryService(); static final ThemeController instance = ThemeController._(); @@ -27,6 +34,13 @@ class ThemeController extends ChangeNotifier { bool _loaded = false; bool _themeAnimationEnabled = false; + List _availableThemes = const []; + String? _selectedThemeId; + String? _selectedThemePath; + String? _selectedThemeLoadError; + QueryaTheme? _registryTheme; + bool _registrySelectionFailed = false; + QueryaTheme? _cachedLightTheme; QueryaTheme? _cachedDarkTheme; QueryaTheme? _cachedActiveTheme; @@ -48,6 +62,14 @@ class ThemeController extends ChangeNotifier { String? get importedThemeName => _importedThemeName; + List get availableThemes => List.unmodifiable(_availableThemes); + + String? get selectedThemeId => _selectedThemeId; + + String? get selectedThemePath => _selectedThemePath; + + String? get selectedThemeLoadError => _selectedThemeLoadError; + /// User `workbench.colorCustomizations` layer (VS Code keys → hex). Map get userColorOverrides => Map.unmodifiable(_userOverrides); @@ -81,15 +103,13 @@ class ThemeController extends ChangeNotifier { /// Workbench + editor tokens for the current preset/mode and overrides. QueryaTheme get activeTheme => - _cachedActiveTheme ??= _themeForBrightness(_effectiveBrightness()); + _resolvedThemeForBrightness(_effectiveBrightness()); ThemeData get lightShadcnTheme => _cachedLightShadcnTheme ??= - (_cachedLightTheme ??= _themeForBrightness(Brightness.light)) - .toShadcnThemeData(); + _resolvedThemeForBrightness(Brightness.light).toShadcnThemeData(); ThemeData get darkShadcnTheme => _cachedDarkShadcnTheme ??= - (_cachedDarkTheme ??= _themeForBrightness(Brightness.dark)) - .toShadcnThemeData(); + _resolvedThemeForBrightness(Brightness.dark).toShadcnThemeData(); /// Cached Material theme for dialogs/dropdowns (avoids rebuild churn). material.ThemeData materialThemeFor(ColorScheme scheme) { @@ -101,6 +121,14 @@ class ThemeController extends ChangeNotifier { return _cachedMaterialTheme = materialThemeFromQuerya(scheme); } + @visibleForTesting + void setRegistryServiceForTest(ThemeRegistryService service) { + _registryService = service; + } + + @visibleForTesting + ThemeRegistryService get registryServiceForTest => _registryService; + void _invalidateThemeCache() { _cachedLightTheme = null; _cachedDarkTheme = null; @@ -143,10 +171,66 @@ class ThemeController extends ChangeNotifier { _importedColors = Map.unmodifiable(imported); _themeAnimationEnabled = await AppSettings.instance.getThemeAnimationEnabled(); + + _availableThemes = await _registryService.loadThemeDefinitions(); + await _restoreSelectedRegistryTheme(); + _loaded = true; _notifyThemeChanged(); } + Future loadAvailableThemes() async { + _availableThemes = await _registryService.loadThemeDefinitions(); + notifyListeners(); + } + + Future setThemeById(String id) async { + final definition = _definitionById(id); + if (definition == null) { + _selectedThemeLoadError = 'Theme "$id" not found.'; + notifyListeners(); + return; + } + + final result = await _registryService.loadTheme(definition); + switch (result) { + case ThemeLoadSuccess(:final theme, :final definition): + _registryTheme = theme; + _registrySelectionFailed = false; + _selectedThemeId = definition.id; + _selectedThemePath = definition.path; + _selectedThemeLoadError = null; + _themeMode = theme.brightness == Brightness.light + ? ThemeMode.light + : ThemeMode.dark; + await AppSettings.instance.setSelectedThemeId(definition.id); + await AppSettings.instance.setSelectedThemeSource(definition.source.name); + await AppSettings.instance.setSelectedThemePath(definition.path); + await AppSettings.instance.setThemeMode(_themeMode); + _notifyThemeChanged(); + case ThemeLoadFailure(:final message): + _selectedThemeLoadError = message; + notifyListeners(); + } + } + + Future previewThemeById(String id) async { + final definition = _definitionById(id); + if (definition == null) { + return ThemeLoadFailure( + definition: ThemeDefinition( + id: id, + name: id, + source: ThemeSource.builtin, + format: ThemeFormat.queryaCustom, + isDark: true, + ), + message: 'Theme "$id" not found.', + ); + } + return _registryService.loadTheme(definition); + } + Future setThemeAnimationEnabled(bool enabled) async { _themeAnimationEnabled = enabled; await AppSettings.instance.setThemeAnimationEnabled(enabled); @@ -155,7 +239,7 @@ class ThemeController extends ChangeNotifier { Future setThemeMode(ThemeMode mode) async { _themeMode = mode; - if (_preset != QueryaThemePreset.imported) { + if (_registryTheme == null && _preset != QueryaThemePreset.imported) { _preset = mode == ThemeMode.light ? QueryaThemePreset.queryaLight : QueryaThemePreset.queryaDark; @@ -169,6 +253,7 @@ class ThemeController extends ChangeNotifier { if (preset == QueryaThemePreset.imported && !hasImportedTheme) { return; } + await _clearRegistrySelection(); _preset = preset; if (preset == QueryaThemePreset.imported) { await AppSettings.instance.setThemePreset(preset); @@ -193,6 +278,7 @@ class ThemeController extends ChangeNotifier { :final tokenColors, :final storedPath, ): + await _clearRegistrySelection(); _importedColors = Map.unmodifiable(colors); _importedTokenColors = List.unmodifiable(tokenColors); _importedThemeName = name; @@ -203,6 +289,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemeImportPath(storedPath); await AppSettings.instance.setThemePreset(QueryaThemePreset.imported); await AppSettings.instance.setThemeMode(_themeMode); + _availableThemes = await _registryService.loadThemeDefinitions(); _notifyThemeChanged(); return result; case ThemeImportFailure(): @@ -238,11 +325,13 @@ class ThemeController extends ChangeNotifier { _importedTokenColors = const []; _importedThemeName = null; if (_preset == QueryaThemePreset.imported) { + await _clearRegistrySelection(); _preset = QueryaThemePreset.queryaDark; _themeMode = ThemeMode.dark; await AppSettings.instance.setThemePreset(_preset); await AppSettings.instance.setThemeMode(_themeMode); } + _availableThemes = await _registryService.loadThemeDefinitions(); _notifyThemeChanged(); } @@ -256,9 +345,95 @@ class ThemeController extends ChangeNotifier { _userOverrides = const {}; _importedThemeName = null; _themeAnimationEnabled = false; + _availableThemes = const []; + _selectedThemeId = null; + _selectedThemePath = null; + _selectedThemeLoadError = null; + _registryTheme = null; + _registrySelectionFailed = false; _notifyThemeChanged(); } + Future _restoreSelectedRegistryTheme() async { + _selectedThemeId = await AppSettings.instance.getSelectedThemeId(); + _selectedThemePath = await AppSettings.instance.getSelectedThemePath(); + final selectedSource = await AppSettings.instance.getSelectedThemeSource(); + _selectedThemeLoadError = null; + _registryTheme = null; + _registrySelectionFailed = false; + + if (_selectedThemeId == null) return; + + final definition = _definitionById( + _selectedThemeId!, + source: selectedSource, + path: _selectedThemePath, + ); + if (definition == null) { + _registrySelectionFailed = true; + _selectedThemeLoadError = + 'Selected theme "${_selectedThemeId!}" is not available.'; + return; + } + + final result = await _registryService.loadTheme(definition); + switch (result) { + case ThemeLoadSuccess(:final theme, :final definition): + _registryTheme = theme; + _selectedThemeId = definition.id; + _selectedThemePath = definition.path; + _themeMode = theme.brightness == Brightness.light + ? ThemeMode.light + : ThemeMode.dark; + case ThemeLoadFailure(:final message): + _registrySelectionFailed = true; + _selectedThemeLoadError = message; + } + } + + Future _clearRegistrySelection() async { + _registryTheme = null; + _registrySelectionFailed = false; + _selectedThemeId = null; + _selectedThemePath = null; + _selectedThemeLoadError = null; + await AppSettings.instance.clearSelectedThemeRegistry(); + } + + ThemeDefinition? _definitionById( + String id, { + String? source, + String? path, + }) { + final matches = _availableThemes.where((definition) => definition.id == id); + if (matches.isEmpty) return null; + + if (source != null && source.isNotEmpty) { + final bySource = + matches.where((definition) => definition.source.name == source); + if (bySource.isNotEmpty) return bySource.first; + } + if (path != null && path.isNotEmpty) { + final byPath = matches.where((definition) => definition.path == path); + if (byPath.isNotEmpty) return byPath.first; + } + return matches.first; + } + + QueryaTheme _resolvedThemeForBrightness(Brightness brightness) { + if (_registryTheme != null) return _registryTheme!; + if (_registrySelectionFailed && _selectedThemeId != null) { + return QueryaTheme.darkDefault; + } + if (brightness == Brightness.light) { + return _cachedLightTheme ??= _themeForBrightness(Brightness.light); + } + if (brightness == Brightness.dark) { + return _cachedDarkTheme ??= _themeForBrightness(Brightness.dark); + } + return _cachedActiveTheme ??= _themeForBrightness(brightness); + } + Brightness _effectiveBrightness() { if (_themeMode == ThemeMode.system) { final b = WidgetsBinding.instance.platformDispatcher.platformBrightness; From e55dd572150521cce5018e27a493cd575b42376d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:26:11 +0300 Subject: [PATCH 2/3] test(theme): cover ThemeController registry selection and fallback Closes #112. --- test/core/theme/theme_controller_test.dart | 102 +++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index ece87f8c..d767ac26 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -1,13 +1,17 @@ import 'dart:io'; 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/parser/color_parser.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; 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/core/theme/theme_registry_service.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; class _FakePathProvider extends PathProviderPlatform { @@ -24,10 +28,18 @@ class _FakePathProvider extends PathProviderPlatform { Future getApplicationDocumentsPath() async => _root; } +Future _copyFixture(String fixtureName, File destination) async { + final source = File(p.join('test/fixtures/themes', fixtureName)); + await destination.writeAsString(await source.readAsString()); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; + late Directory themesDir; + late Directory importedDir; + late ThemeRegistryService registry; setUpAll(() async { tempDir = @@ -36,6 +48,17 @@ void main() { 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); + }); + tearDownAll(() async { await LocalDb.instance.close(); if (await tempDir.exists()) { @@ -45,6 +68,11 @@ void main() { 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(); }); @@ -139,4 +167,78 @@ void main() { expect(c.themeMode, ThemeMode.light); expect(c.activeTheme, QueryaTheme.lightDefault); }); + + group('registry integration', () { + test('setThemeById applies registry theme and persists selection', () async { + final c = ThemeController.instance; + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + await c.load(); + + await c.setThemeById('fixture-custom-dark'); + + expect(c.selectedThemeId, 'fixture-custom-dark'); + expect(c.selectedThemeLoadError, isNull); + expect(c.activeTheme.colorScheme.primary, parseQueryaThemeColor('#38BDF8')); + expect(await AppSettings.instance.getSelectedThemeId(), 'fixture-custom-dark'); + expect( + await AppSettings.instance.getSelectedThemeSource(), + 'filesystem', + ); + }); + + test('previewThemeById does not change activeTheme', () async { + final c = ThemeController.instance; + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + await c.load(); + + final before = c.activeTheme; + final preview = await c.previewThemeById('fixture-custom-dark'); + + expect(preview, isA()); + expect(c.activeTheme, same(before)); + expect(c.selectedThemeId, isNull); + }); + + test('broken selected id falls back to Querya Dark without clearing settings', + () async { + final c = ThemeController.instance; + await AppSettings.instance.setSelectedThemeId('missing-theme'); + await AppSettings.instance.setSelectedThemeSource('filesystem'); + await AppSettings.instance.setSelectedThemePath('/tmp/missing-theme.json'); + await AppSettings.instance.setThemePreset(QueryaThemePreset.queryaLight); + + await c.load(); + + expect(c.activeTheme, QueryaTheme.darkDefault); + expect(c.selectedThemeLoadError, isNotNull); + expect(await AppSettings.instance.getSelectedThemeId(), 'missing-theme'); + expect( + await AppSettings.instance.getThemePreset(), + QueryaThemePreset.queryaLight, + ); + }); + + test('setPreset clears registry selection', () async { + final c = ThemeController.instance; + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + await c.load(); + await c.setThemeById('fixture-custom-dark'); + expect(c.selectedThemeId, 'fixture-custom-dark'); + + await c.setPreset(QueryaThemePreset.queryaLight); + + expect(c.selectedThemeId, isNull); + expect(c.activeTheme, QueryaTheme.lightDefault); + expect(await AppSettings.instance.getSelectedThemeId(), isNull); + }); + }); } From accdd4578a9a00f8e22e821b37ef37b86137143f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:29:04 +0300 Subject: [PATCH 3/3] fix(theme): remove unnecessary foundation import in ThemeController visibleForTesting is already available through shadcn_flutter. --- lib/core/theme/theme_controller.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 6bb3f590..05184492 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/theme/querya_material_theme.dart';