Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 93 additions & 5 deletions lib/core/theme/theme_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThemeDefinition> _builtinThemeDefinitions = [
builtinQueryaDarkDefinition,
builtinQueryaLightDefinition,
];

ThemeRegistryService _registryService;

ThemeController._({ThemeRegistryService? registryService})
Expand Down Expand Up @@ -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<String, String> get userColorOverrides =>
Map.unmodifiable(_userOverrides);
Expand Down Expand Up @@ -171,19 +205,32 @@ class ThemeController extends ChangeNotifier {
_themeAnimationEnabled =
await AppSettings.instance.getThemeAnimationEnabled();

_availableThemes = await _registryService.loadThemeDefinitions();
_availableThemes = _mergeBuiltinThemes(
await _registryService.loadThemeDefinitions(),
);
await _restoreSelectedRegistryTheme();

_loaded = true;
_notifyThemeChanged();
}

Future<void> loadAvailableThemes() async {
_availableThemes = await _registryService.loadThemeDefinitions();
_availableThemes = _mergeBuiltinThemes(
await _registryService.loadThemeDefinitions(),
);
notifyListeners();
}

Future<void> 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.';
Expand Down Expand Up @@ -214,6 +261,19 @@ class ThemeController extends ChangeNotifier {
}

Future<ThemeLoadResult> 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(
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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();
}

Expand All @@ -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;
Expand Down Expand Up @@ -399,6 +463,30 @@ class ThemeController extends ChangeNotifier {
await AppSettings.instance.clearSelectedThemeRegistry();
}

Future<void> _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<ThemeDefinition> _mergeBuiltinThemes(List<ThemeDefinition> scanned) {
final merged = <ThemeDefinition>[..._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,
Expand Down
64 changes: 36 additions & 28 deletions lib/features/settings/preferences_appearance_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -43,8 +45,16 @@ class _PreferencesAppearanceSectionState
await _controller.setThemeMode(mode);
}

Future<void> _setPreset(QueryaThemePreset preset) async {
await _controller.setPreset(preset);
Future<void> _setThemeById(String id) async {
await _controller.setThemeById(id);
}

Future<ThemePreviewResult> _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<void> _pickAndImportTheme() async {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -125,30 +133,30 @@ class _PreferencesAppearanceSectionState
),
const material.SizedBox(height: 12),
PreferencesFieldRow(
label: 'Color preset',
control: PreferencesDropdownMenu<QueryaThemePreset>(
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:
Expand Down
42 changes: 42 additions & 0 deletions test/core/theme/theme_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ThemeLoadSuccess>());
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);
});
});
}
Loading
Loading