diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 29ae0193..c187a40c 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -63,6 +63,7 @@ class ThemeController extends ChangeNotifier { String? _selectedThemeLoadError; QueryaTheme? _registryTheme; bool _registrySelectionFailed = false; + bool _isLoadingAvailableThemes = false; QueryaTheme? _cachedLightTheme; QueryaTheme? _cachedDarkTheme; @@ -87,6 +88,9 @@ class ThemeController extends ChangeNotifier { List get availableThemes => List.unmodifiable(_availableThemes); + /// True while [loadAvailableThemes] is scanning the registry. + bool get isLoadingAvailableThemes => _isLoadingAvailableThemes; + String? get selectedThemeId => _selectedThemeId; String? get selectedThemePath => _selectedThemePath; @@ -215,10 +219,40 @@ class ThemeController extends ChangeNotifier { } Future loadAvailableThemes() async { - _availableThemes = _mergeBuiltinThemes( - await _registryService.loadThemeDefinitions(), - ); + if (_isLoadingAvailableThemes) return; + + _isLoadingAvailableThemes = true; notifyListeners(); + + try { + final scanned = await _registryService.loadThemeDefinitions(); + _availableThemes = _mergeBuiltinThemes(scanned); + _syncSelectedThemeAfterRefresh(); + } on Object { + // Registry scan skips broken files per entry; keep the prior list on failure. + } finally { + _isLoadingAvailableThemes = false; + notifyListeners(); + } + } + + void _syncSelectedThemeAfterRefresh() { + final selectedId = _selectedThemeId; + if (selectedId == null) return; + + final stillAvailable = _definitionById( + selectedId, + path: _selectedThemePath, + ); + if (stillAvailable == null) { + _selectedThemeLoadError = + 'Selected theme "$selectedId" is not available.'; + return; + } + + if (_registryTheme != null) { + _selectedThemeLoadError = null; + } } Future setThemeById(String id) async { diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 032e361a..a23dd277 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -94,6 +94,10 @@ class _PreferencesAppearanceSectionState if (mounted) setState(() => _importError = null); } + Future _refreshThemes() async { + await _controller.loadAvailableThemes(); + } + Future _setThemeAnimation(bool enabled) async { await _controller.setThemeAnimationEnabled(enabled); } @@ -102,6 +106,7 @@ class _PreferencesAppearanceSectionState material.Widget build(material.BuildContext context) { final c = _controller; final themes = c.availableThemes; + final refreshingThemes = c.isLoadingAvailableThemes; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, @@ -138,6 +143,7 @@ class _PreferencesAppearanceSectionState themes: themes, selectedThemeId: c.effectiveSelectedThemeId, expandToParent: true, + isLoading: refreshingThemes, onSelected: (id) => unawaited(_setThemeById(id)), onPreviewTheme: _previewThemeById, ), @@ -196,6 +202,14 @@ class _PreferencesAppearanceSectionState _importing ? null : () => unawaited(_pickAndImportTheme()), child: material.Text(_importing ? 'Importing…' : 'Import theme…'), ), + OutlineButton( + onPressed: (_importing || refreshingThemes) + ? null + : () => unawaited(_refreshThemes()), + child: material.Text( + refreshingThemes ? 'Refreshing…' : 'Refresh themes', + ), + ), OutlineButton( onPressed: () => unawaited(_resetAppearance()), child: const Text('Reset appearance'), diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 4e852fa5..0ef9b839 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -11,6 +12,7 @@ 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_definition.dart'; import 'package:querya_desktop/core/theme/theme_registry_service.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -33,6 +35,21 @@ Future _copyFixture(String fixtureName, File destination) async { await destination.writeAsString(await source.readAsString()); } +class _GatedRegistryService extends ThemeRegistryService { + _GatedRegistryService({ + required super.userThemesDirectory, + required super.importedThemesDirectory, + }); + + final gate = Completer(); + + @override + Future> loadThemeDefinitions() async { + await gate.future; + return super.loadThemeDefinitions(); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -283,4 +300,64 @@ void main() { expect(c.effectiveSelectedThemeId, ThemeController.builtinQueryaDarkId); }); }); + + group('loadAvailableThemes', () { + test('picks up newly added filesystem theme', () async { + final c = ThemeController.instance; + await c.load(); + final beforeCount = c.availableThemes.length; + + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + await c.loadAvailableThemes(); + + expect(c.availableThemes.length, greaterThan(beforeCount)); + expect( + c.availableThemes.map((theme) => theme.id), + contains('fixture-custom-dark'), + ); + expect(c.isLoadingAvailableThemes, isFalse); + }); + + test('preserves active registry theme without reloading from disk', + () 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'); + final before = c.activeTheme; + + await File(p.join(themesDir.path, 'querya_custom_dark.json')) + .writeAsString('not valid theme json'); + + await c.loadAvailableThemes(); + + expect(c.activeTheme, same(before)); + expect(c.selectedThemeId, 'fixture-custom-dark'); + }); + + test('sets isLoadingAvailableThemes while refresh is in progress', () async { + final c = ThemeController.instance; + await c.load(); + + final gated = _GatedRegistryService( + userThemesDirectory: () async => themesDir, + importedThemesDirectory: () async => importedDir, + ); + c.setRegistryServiceForTest(gated); + + final refresh = c.loadAvailableThemes(); + expect(c.isLoadingAvailableThemes, isTrue); + + gated.gate.complete(); + await refresh; + + expect(c.isLoadingAvailableThemes, isFalse); + }); + }); } diff --git a/test/features/settings/preferences_appearance_section_test.dart b/test/features/settings/preferences_appearance_section_test.dart index 40cbeb54..f237a37a 100644 --- a/test/features/settings/preferences_appearance_section_test.dart +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -63,12 +63,17 @@ void main() { }); tearDown(() async { + ThemeController.instance.setRegistryServiceForTest( + ThemeRegistryService( + userThemesDirectory: () async => themesDir, + importedThemesDirectory: () async => importedDir, + ), + ); await AppSettings.instance.clearThemeSettings(); await ThemeImportService.deletePersistedImport(); if (await themesDir.exists()) { await themesDir.delete(recursive: true); } - ThemeController.instance.setRegistryServiceForTest(ThemeRegistryService()); await ThemeController.instance.load(); }); @@ -107,6 +112,7 @@ void main() { await pumpSection(tester); expect(find.text('Import theme…'), findsOneWidget); + expect(find.text('Refresh themes'), findsOneWidget); expect(find.text('Reset appearance'), findsOneWidget); }); });