From af46339efdf77448d5b612b69cc552f42a096691 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:38:18 +0300 Subject: [PATCH] feat(theme): appearance settings and VS Code theme import (#43) Add Preferences Appearance section, ThemeImportService with persisted import file, ThemeController.importThemeFromFile, and imported preset. Closes #43 --- docs/theme-import.md | 11 + lib/core/storage/app_settings.dart | 95 +++++++++ lib/core/theme/querya_theme_preset.dart | 5 +- lib/core/theme/theme_controller.dart | 101 +++++++-- lib/core/theme/theme_import_service.dart | 101 +++++++++ .../preferences_appearance_section.dart | 192 ++++++++++++++++++ lib/features/settings/preferences_dialog.dart | 5 +- test/core/theme/theme_controller_test.dart | 17 ++ .../core/theme/theme_import_service_test.dart | 60 ++++++ 9 files changed, 572 insertions(+), 15 deletions(-) create mode 100644 lib/core/theme/theme_import_service.dart create mode 100644 lib/features/settings/preferences_appearance_section.dart create mode 100644 test/core/theme/theme_import_service_test.dart diff --git a/docs/theme-import.md b/docs/theme-import.md index 2097fcad..b4e2cdaa 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -43,6 +43,17 @@ Hex strings as in VS Code: `#RRGGBB`, `#RRGGBBAA`, `#RGB`, `#RGBA` (see Comments and trailing commas are stripped before parse (`stripJsonc`). +## Preferences UI (#43) + +In **Preferences → Appearance**: + +- **Theme mode** — Dark / Light / System +- **Color preset** — Querya Dark, Querya Light, or imported theme name +- **Import theme…** — pick `.json` / `.jsonc` (VS Code format) +- **Reset appearance** — clears import, overrides, returns to Querya Dark + +Imported files are copied to app data (`themes/imported.json`) and survive restarts. + ## User overrides (#45) User customizations are stored as VS Code keys → hex strings in diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 660ab4f3..85b13b38 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -53,6 +53,9 @@ abstract final class AppSettingsKeys { static const themeMode = 'theme_mode'; static const themePreset = 'theme_preset'; static const themeOverridesJson = 'theme_overrides_json'; + static const themeImportPath = 'theme_import_path'; + static const themeImportName = 'theme_import_name'; + static const themeImportedColorsJson = 'theme_imported_colors_json'; } /// Bumps [listenable] when any preference is persisted so open screens can reload. @@ -201,6 +204,7 @@ class AppSettings { final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.themePreset); return switch (v) { 'querya_light' => QueryaThemePreset.queryaLight, + 'imported' => QueryaThemePreset.imported, _ => QueryaThemePreset.queryaDark, }; } @@ -208,12 +212,102 @@ class AppSettings { Future setThemePreset(QueryaThemePreset preset) async { final stored = switch (preset) { QueryaThemePreset.queryaLight => 'querya_light', + QueryaThemePreset.imported => 'imported', QueryaThemePreset.queryaDark => 'querya_dark', }; await LocalDb.instance.setAppSetting(AppSettingsKeys.themePreset, stored); AppSettingsRevision.bump(); } + Future getThemeImportName() async { + return LocalDb.instance.getAppSetting(AppSettingsKeys.themeImportName); + } + + Future setThemeImportName(String? name) async { + if (name == null || name.isEmpty) { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeImportName); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.themeImportName, + name, + ); + } + AppSettingsRevision.bump(); + } + + Future getThemeImportPath() async { + return LocalDb.instance.getAppSetting(AppSettingsKeys.themeImportPath); + } + + Future setThemeImportPath(String? path) async { + if (path == null || path.isEmpty) { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeImportPath); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.themeImportPath, + path, + ); + } + AppSettingsRevision.bump(); + } + + Future> getThemeImportedColors() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.themeImportedColorsJson, + ); + if (v == null || v.isEmpty) return {}; + try { + final decoded = jsonDecode(v); + if (decoded is! Map) return {}; + final out = {}; + for (final entry in decoded.entries) { + final key = entry.key?.toString(); + final value = entry.value?.toString(); + if (key != null && + key.isNotEmpty && + value != null && + value.isNotEmpty) { + out[key] = value; + } + } + return out; + } on FormatException { + return {}; + } + } + + Future setThemeImportedColors(Map colors) async { + if (colors.isEmpty) { + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.themeImportedColorsJson, + ); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.themeImportedColorsJson, + jsonEncode(colors), + ); + } + AppSettingsRevision.bump(); + } + + Future clearThemeImport() async { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeImportPath); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeImportName); + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.themeImportedColorsJson, + ); + AppSettingsRevision.bump(); + } + + /// Clears import metadata without bumping (for batched clears). + Future deleteThemeImportKeys() async { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeImportPath); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeImportName); + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.themeImportedColorsJson, + ); + } + Future> getThemeColorOverrides() async { final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.themeOverridesJson); @@ -259,6 +353,7 @@ class AppSettings { await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeMode); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themePreset); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeOverridesJson); + await deleteThemeImportKeys(); AppSettingsRevision.bump(); } } diff --git a/lib/core/theme/querya_theme_preset.dart b/lib/core/theme/querya_theme_preset.dart index cb80b4c7..3b31a7f3 100644 --- a/lib/core/theme/querya_theme_preset.dart +++ b/lib/core/theme/querya_theme_preset.dart @@ -1,5 +1,8 @@ -/// Built-in theme presets (imported VS Code themes — #44). +/// Built-in and imported theme presets. enum QueryaThemePreset { queryaDark, queryaLight, + + /// VS Code theme imported from a `.json` / `.jsonc` file (#43). + imported, } diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 94bea9ad..4b38e8d2 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -6,6 +6,7 @@ import 'parser/querya_theme_from_vscode.dart'; import 'parser/vscode_colors_merge.dart'; import 'querya_theme.dart'; import 'querya_theme_preset.dart'; +import 'theme_import_service.dart'; /// Active theme state: preset, optional imported colors, user overrides. class ThemeController extends ChangeNotifier { @@ -17,6 +18,7 @@ class ThemeController extends ChangeNotifier { QueryaThemePreset _preset = QueryaThemePreset.queryaDark; Map _importedColors = const {}; Map _userOverrides = const {}; + String? _importedThemeName; bool _loaded = false; ThemeMode get themeMode => _themeMode; @@ -25,18 +27,27 @@ class ThemeController extends ChangeNotifier { bool get isLoaded => _loaded; + bool get hasImportedTheme => _importedColors.isNotEmpty; + + String? get importedThemeName => _importedThemeName; + /// User `workbench.colorCustomizations` layer (VS Code keys → hex). Map get userColorOverrides => Map.unmodifiable(_userOverrides); - /// Imported theme `colors` layer (from file import; empty until wired). + /// Imported theme `colors` layer (from file import). Map get importedColors => Map.unmodifiable(_importedColors); - /// Merged VS Code color keys: imported → user overrides. - Map get effectiveVsCodeColors => mergeVsCodeColorLayers([ + /// Merged VS Code color keys for the active preset. + Map get effectiveVsCodeColors { + if (_preset == QueryaThemePreset.imported) { + return mergeVsCodeColorLayers([ _importedColors, _userOverrides, ]); + } + return mergeVsCodeColorLayers([_userOverrides]); + } /// Parsed effective colors for supported VS Code keys only. Map get effectiveWorkbenchColors { @@ -62,35 +73,82 @@ class ThemeController extends ChangeNotifier { Future load() async { final mode = await AppSettings.instance.getThemeMode(); - final preset = await AppSettings.instance.getThemePreset(); + var preset = await AppSettings.instance.getThemePreset(); final overrides = await AppSettings.instance.getThemeColorOverrides(); + var imported = await AppSettings.instance.getThemeImportedColors(); + _importedThemeName = await AppSettings.instance.getThemeImportName(); + + if (imported.isEmpty) { + final fromDisk = await ThemeImportService.loadPersistedColors(); + if (fromDisk != null && fromDisk.isNotEmpty) { + imported = fromDisk; + await AppSettings.instance.setThemeImportedColors(imported); + } + } + + if (preset == QueryaThemePreset.imported && imported.isEmpty) { + preset = QueryaThemePreset.queryaDark; + await AppSettings.instance.setThemePreset(preset); + } + _themeMode = mode; _preset = preset; _userOverrides = Map.unmodifiable(overrides); + _importedColors = Map.unmodifiable(imported); _loaded = true; notifyListeners(); } Future setThemeMode(ThemeMode mode) async { _themeMode = mode; - _preset = mode == ThemeMode.light - ? QueryaThemePreset.queryaLight - : QueryaThemePreset.queryaDark; + if (_preset != QueryaThemePreset.imported) { + _preset = mode == ThemeMode.light + ? QueryaThemePreset.queryaLight + : QueryaThemePreset.queryaDark; + await AppSettings.instance.setThemePreset(_preset); + } await AppSettings.instance.setThemeMode(mode); - await AppSettings.instance.setThemePreset(_preset); notifyListeners(); } Future setPreset(QueryaThemePreset preset) async { + if (preset == QueryaThemePreset.imported && !hasImportedTheme) { + return; + } _preset = preset; - _themeMode = preset == QueryaThemePreset.queryaLight - ? ThemeMode.light - : ThemeMode.dark; - await AppSettings.instance.setThemePreset(preset); - await AppSettings.instance.setThemeMode(_themeMode); + if (preset == QueryaThemePreset.imported) { + await AppSettings.instance.setThemePreset(preset); + } else { + _themeMode = preset == QueryaThemePreset.queryaLight + ? ThemeMode.light + : ThemeMode.dark; + await AppSettings.instance.setThemePreset(preset); + await AppSettings.instance.setThemeMode(_themeMode); + } notifyListeners(); } + /// Parses a VS Code theme file, persists it, and activates the imported preset. + Future importThemeFromFile(String path) async { + final result = await ThemeImportService.importFromPath(path); + switch (result) { + case ThemeImportSuccess(:final name, :final isDark, :final colors, :final storedPath): + _importedColors = Map.unmodifiable(colors); + _importedThemeName = name; + _preset = QueryaThemePreset.imported; + _themeMode = isDark ? ThemeMode.dark : ThemeMode.light; + await AppSettings.instance.setThemeImportedColors(colors); + await AppSettings.instance.setThemeImportName(name); + await AppSettings.instance.setThemeImportPath(storedPath); + await AppSettings.instance.setThemePreset(QueryaThemePreset.imported); + await AppSettings.instance.setThemeMode(_themeMode); + notifyListeners(); + return result; + case ThemeImportFailure(): + return result; + } + } + /// Sets or clears a user override for a VS Code `colors` key. Future setWorkbenchColor(String vscodeKey, Color? value) async { final next = Map.from(_userOverrides); @@ -111,12 +169,29 @@ class ThemeController extends ChangeNotifier { notifyListeners(); } + /// Clears imported theme file and settings; falls back to Querya Dark. + Future clearImportedTheme() async { + await ThemeImportService.deletePersistedImport(); + await AppSettings.instance.clearThemeImport(); + _importedColors = const {}; + _importedThemeName = null; + if (_preset == QueryaThemePreset.imported) { + _preset = QueryaThemePreset.queryaDark; + _themeMode = ThemeMode.dark; + await AppSettings.instance.setThemePreset(_preset); + await AppSettings.instance.setThemeMode(_themeMode); + } + notifyListeners(); + } + Future resetToDefaults() async { + await ThemeImportService.deletePersistedImport(); await AppSettings.instance.clearThemeSettings(); _themeMode = ThemeMode.dark; _preset = QueryaThemePreset.queryaDark; _importedColors = const {}; _userOverrides = const {}; + _importedThemeName = null; notifyListeners(); } diff --git a/lib/core/theme/theme_import_service.dart b/lib/core/theme/theme_import_service.dart new file mode 100644 index 00000000..3d1d3459 --- /dev/null +++ b/lib/core/theme/theme_import_service.dart @@ -0,0 +1,101 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'parser/vscode_theme_manifest.dart'; + +/// Result of importing a VS Code theme file. +sealed class ThemeImportResult { + const ThemeImportResult(); +} + +class ThemeImportSuccess extends ThemeImportResult { + const ThemeImportSuccess({ + required this.name, + required this.isDark, + required this.colors, + required this.storedPath, + }); + + final String name; + final bool isDark; + final Map colors; + final String storedPath; +} + +class ThemeImportFailure extends ThemeImportResult { + const ThemeImportFailure(this.message); + final String message; +} + +/// Parses and persists an imported VS Code theme under app support. +abstract final class ThemeImportService { + static const String _storedFileName = 'imported.json'; + + /// Reads [sourcePath], parses JSON/JSONC, copies to app data, returns colors. + static Future importFromPath(String sourcePath) async { + try { + final source = File(sourcePath); + if (!await source.exists()) { + return const ThemeImportFailure('Theme file not found.'); + } + final raw = await source.readAsString(); + final manifest = VsCodeThemeManifest.fromJsonString(raw); + if (manifest.colors.isEmpty) { + return const ThemeImportFailure( + 'Theme file has no "colors" section to import.', + ); + } + + final storedFile = await _storedThemeFile(); + await storedFile.parent.create(recursive: true); + await storedFile.writeAsString(raw); + + final name = manifest.name?.trim().isNotEmpty == true + ? manifest.name!.trim() + : p.basenameWithoutExtension(sourcePath); + + return ThemeImportSuccess( + name: name, + isDark: manifest.isDark || !manifest.isLight, + colors: Map.unmodifiable(manifest.colors), + storedPath: storedFile.path, + ); + } on VsCodeThemeParseException catch (e) { + return ThemeImportFailure(e.message); + } on FormatException catch (e) { + return ThemeImportFailure(e.message); + } on IOException catch (e) { + return ThemeImportFailure(e.toString()); + } on Object catch (e) { + return ThemeImportFailure(e.toString()); + } + } + + /// Reloads colors from the persisted import file, if present. + static Future?> loadPersistedColors() async { + final file = await _storedThemeFile(); + if (!await file.exists()) return null; + try { + final manifest = + VsCodeThemeManifest.fromJsonString(await file.readAsString()); + if (manifest.colors.isEmpty) return null; + return manifest.colors; + } on Object { + return null; + } + } + + static Future deletePersistedImport() async { + final file = await _storedThemeFile(); + if (await file.exists()) { + await file.delete(); + } + } + + static Future _storedThemeFile() async { + final support = await getApplicationSupportDirectory(); + return File(p.join(support.path, 'themes', _storedFileName)); + } +} diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart new file mode 100644 index 00000000..21280fd4 --- /dev/null +++ b/lib/features/settings/preferences_appearance_section.dart @@ -0,0 +1,192 @@ +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/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Appearance / theme controls for [PreferencesDialog]. +class PreferencesAppearanceSection extends material.StatefulWidget { + const PreferencesAppearanceSection({super.key}); + + @override + material.State createState() => + _PreferencesAppearanceSectionState(); +} + +class _PreferencesAppearanceSectionState + extends material.State { + final _controller = ThemeController.instance; + String? _importError; + bool _importing = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_onThemeChanged); + } + + @override + void dispose() { + _controller.removeListener(_onThemeChanged); + super.dispose(); + } + + void _onThemeChanged() { + if (mounted) setState(() {}); + } + + Future _setThemeMode(ThemeMode mode) async { + await _controller.setThemeMode(mode); + } + + Future _setPreset(QueryaThemePreset preset) async { + await _controller.setPreset(preset); + } + + Future _pickAndImportTheme() async { + setState(() { + _importing = true; + _importError = null; + }); + try { + final file = await openFile( + acceptedTypeGroups: const [ + XTypeGroup( + label: 'VS Code theme', + extensions: ['json', 'jsonc'], + ), + ], + ); + if (file == null) return; + final path = file.path; + if (path.isEmpty) return; + final result = await _controller.importThemeFromFile(path); + if (!mounted) return; + switch (result) { + case ThemeImportSuccess(): + setState(() => _importError = null); + case ThemeImportFailure(:final message): + setState(() => _importError = message); + } + } finally { + if (mounted) { + setState(() => _importing = false); + } + } + } + + Future _resetAppearance() async { + await _controller.resetToDefaults(); + if (mounted) setState(() => _importError = null); + } + + @override + material.Widget build(material.BuildContext context) { + final c = _controller; + final importedLabel = c.hasImportedTheme + ? 'Imported: ${c.importedThemeName ?? 'theme'}' + : 'Imported theme (none)'; + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Appearance').semiBold().small(), + const material.SizedBox(height: 8), + material.Row( + children: [ + const Text('Theme mode').small(), + const material.SizedBox(width: 12), + material.DropdownButton( + value: c.themeMode, + onChanged: (v) { + if (v != null) unawaited(_setThemeMode(v)); + }, + items: const [ + material.DropdownMenuItem( + value: ThemeMode.dark, + child: material.Text('Dark'), + ), + material.DropdownMenuItem( + value: ThemeMode.light, + child: material.Text('Light'), + ), + material.DropdownMenuItem( + value: ThemeMode.system, + child: material.Text('System'), + ), + ], + ), + ], + ), + const material.SizedBox(height: 12), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Padding( + padding: const material.EdgeInsets.only(top: 8), + child: const Text('Color preset').small(), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: material.DropdownButton( + value: c.preset, + isExpanded: true, + onChanged: (v) { + if (v != null) unawaited(_setPreset(v)); + }, + items: [ + const material.DropdownMenuItem( + value: QueryaThemePreset.queryaDark, + child: material.Text('Querya Dark'), + ), + const material.DropdownMenuItem( + value: QueryaThemePreset.queryaLight, + child: material.Text('Querya Light'), + ), + material.DropdownMenuItem( + value: QueryaThemePreset.imported, + enabled: c.hasImportedTheme, + child: material.Text(importedLabel), + ), + ], + ), + ), + ], + ), + const material.SizedBox(height: 12), + material.Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlineButton( + onPressed: _importing ? null : () => unawaited(_pickAndImportTheme()), + child: material.Text(_importing ? 'Importing…' : 'Import theme…'), + ), + OutlineButton( + onPressed: () => unawaited(_resetAppearance()), + child: const Text('Reset appearance'), + ), + ], + ), + if (_importError != null) ...[ + const material.SizedBox(height: 8), + material.Text( + _importError!, + style: material.TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.destructive, + ), + ), + ], + const material.SizedBox(height: 4), + const Text( + 'Import VS Code theme JSON/JSONC (.colors subset). Changes apply immediately.', + ).muted().xSmall(), + ], + ); + } +} diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 701a7ce5..d510469a 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -3,6 +3,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/features/settings/preferences_appearance_section.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -89,7 +90,7 @@ class _PreferencesDialogContentState extends material.State<_PreferencesDialogCo constraints: const material.BoxConstraints( maxWidth: 480, minWidth: 360, - maxHeight: 560, + maxHeight: 640, ), decoration: material.BoxDecoration( color: theme.popover, @@ -128,6 +129,8 @@ class _PreferencesDialogContentState extends material.State<_PreferencesDialogCo : material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ + const PreferencesAppearanceSection(), + const material.SizedBox(height: 24), const Text('SQL — PostgreSQL').semiBold().small(), const material.SizedBox(height: 8), material.Row( diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 0d4b7e4c..c1b87cd8 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/storage/local_db.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:shadcn_flutter/shadcn_flutter.dart'; class _FakePathProvider extends PathProviderPlatform { @@ -100,6 +101,22 @@ void main() { ); }); + test('importThemeFromFile applies imported colors to activeTheme', () async { + final c = ThemeController.instance; + await c.load(); + final fixture = File('test/fixtures/themes/dark_subset.json'); + final result = await c.importThemeFromFile(fixture.path); + expect(result, isA()); + expect(c.preset, QueryaThemePreset.imported); + expect(c.hasImportedTheme, isTrue); + expect( + c.activeTheme.workbench.editorBackground, + const Color(0xFF1E1E1E), + ); + await c.resetToDefaults(); + expect(c.preset, QueryaThemePreset.queryaDark); + }); + test('clearColorOverrides does not reset theme mode', () async { final c = ThemeController.instance; await c.setThemeMode(ThemeMode.light); diff --git a/test/core/theme/theme_import_service_test.dart b/test/core/theme/theme_import_service_test.dart new file mode 100644 index 00000000..da998cb8 --- /dev/null +++ b/test/core/theme/theme_import_service_test.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/theme/theme_import_service.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; + + setUpAll(() async { + tempDir = + await Directory.systemTemp.createTemp('querya_theme_import_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + }); + + tearDown(() async { + await ThemeImportService.deletePersistedImport(); + }); + + tearDownAll(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('importFromPath parses fixture and persists copy', () async { + final fixture = File('test/fixtures/themes/dark_subset.json'); + final result = await ThemeImportService.importFromPath(fixture.path); + expect(result, isA()); + final success = result as ThemeImportSuccess; + expect(success.name, 'Fixture Dark Subset'); + expect(success.isDark, isTrue); + expect(success.colors['editor.background'], '#1e1e1e'); + + final reloaded = await ThemeImportService.loadPersistedColors(); + expect(reloaded?['editor.background'], '#1e1e1e'); + }); + + test('importFromPath returns failure for missing file', () async { + final result = + await ThemeImportService.importFromPath('/no/such/theme.json'); + expect(result, isA()); + }); +}