diff --git a/assets/themes/cyberpunk-neon.json b/assets/themes/cyberpunk-neon.json new file mode 100644 index 00000000..26ac9fbe --- /dev/null +++ b/assets/themes/cyberpunk-neon.json @@ -0,0 +1,96 @@ +{ + "name": "Querya Cyberpunk Neon", + "type": "dark", + "colors": { + "activityBar.background": "#050508", + "statusBar.background": "#050508", + "sideBar.background": "#0c0820", + "sideBar.foreground": "#8b7cf8", + "tab.activeBackground": "#14102a", + "panel.background": "#14102a", + "input.background": "#14102a", + "editor.background": "#0a0a14", + "editor.foreground": "#e8f4ff", + "editor.selectionBackground": "#ff2a6d44", + "editorLineNumber.foreground": "#4a3f7a", + "editorBracketMatch.background": "#00f5ff33", + "editorWidget.border": "#00f5ff66", + "focusBorder": "#00f5ff", + "list.hoverBackground": "#ff2a6d22", + "gitDecoration.modifiedResourceForeground": "#fcee09", + "gitDecoration.untrackedResourceForeground": "#39ff14" + }, + "tokenColors": [ + { + "name": "Comments", + "scope": ["comment", "comment.line", "comment.block", "punctuation.definition.comment"], + "settings": { + "foreground": "#5c4d8a", + "fontStyle": "italic" + } + }, + { + "name": "Keywords", + "scope": [ + "keyword", + "keyword.control", + "keyword.operator.logical", + "storage.type", + "storage.modifier" + ], + "settings": { + "foreground": "#ff2a6d", + "fontStyle": "bold" + } + }, + { + "name": "Strings", + "scope": ["string", "string.quoted.single", "string.quoted.double"], + "settings": { + "foreground": "#fcee09" + } + }, + { + "name": "Numbers", + "scope": ["constant.numeric", "constant.language"], + "settings": { + "foreground": "#bd00ff" + } + }, + { + "name": "Functions", + "scope": ["entity.name.function", "support.function"], + "settings": { + "foreground": "#00f5ff" + } + }, + { + "name": "Types / classes", + "scope": ["entity.name.type", "support.type"], + "settings": { + "foreground": "#8b7cf8" + } + }, + { + "name": "Variables", + "scope": ["variable", "variable.other"], + "settings": { + "foreground": "#e8f4ff" + } + }, + { + "name": "JSON keys", + "scope": ["support.type.property-name.json"], + "settings": { + "foreground": "#00f5ff" + } + }, + { + "name": "JSON strings", + "scope": ["string.quoted.double.json"], + "settings": { + "foreground": "#39ff14" + } + } + ] +} diff --git a/lib/core/theme/builtin_theme_assets.dart b/lib/core/theme/builtin_theme_assets.dart new file mode 100644 index 00000000..70d0a044 --- /dev/null +++ b/lib/core/theme/builtin_theme_assets.dart @@ -0,0 +1,11 @@ +/// Bundled theme JSON files shipped in the Flutter asset bundle. +abstract final class BuiltinThemeAssets { + static const directory = 'assets/themes'; + + /// File names under [directory] that are registered as built-in themes. + static const bundledFiles = [ + 'cyberpunk-neon.json', + ]; + + static String assetPath(String fileName) => '$directory/$fileName'; +} diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 6f995c1e..fbe168bf 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -459,7 +459,9 @@ class ThemeController extends ChangeNotifier { _userOverrides = const {}; _importedThemeName = null; _themeAnimationEnabled = false; - _availableThemes = List.unmodifiable(_builtinThemeDefinitions); + _availableThemes = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); _selectedThemeId = null; _selectedThemePath = null; _selectedThemeLoadError = null; diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart index 1a75a63b..43a65efd 100644 --- a/lib/core/theme/theme_registry_service.dart +++ b/lib/core/theme/theme_registry_service.dart @@ -2,9 +2,11 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle; import 'package:path/path.dart' as p; import '../storage/app_settings.dart'; +import 'builtin_theme_assets.dart'; import 'parser/jsonc_preprocessor.dart'; import 'parser/querya_theme_from_manifest.dart'; import 'parser/querya_theme_from_vscode.dart'; @@ -21,17 +23,24 @@ class ThemeRegistryService { ThemeRegistryService({ Future Function()? userThemesDirectory, Future Function()? importedThemesDirectory, + Future Function(String assetPath)? assetLoader, + List? bundledThemeAssetFiles, int maxCacheEntries = 16, }) : _userThemesDirectory = userThemesDirectory ?? ThemePaths.userThemesDirectory, _importedThemesDirectory = importedThemesDirectory ?? ThemePaths.importedThemesDirectory, + _assetLoader = assetLoader ?? ((path) => rootBundle.loadString(path)), + _bundledThemeAssetFiles = + bundledThemeAssetFiles ?? BuiltinThemeAssets.bundledFiles, _themeCache = _ThemeLruCache(maxEntries: maxCacheEntries); static const defaultMaxCacheEntries = 16; final Future Function() _userThemesDirectory; final Future Function() _importedThemesDirectory; + final Future Function(String assetPath) _assetLoader; + final List _bundledThemeAssetFiles; final _ThemeLruCache _themeCache; int _themeParseCount = 0; @@ -48,6 +57,8 @@ class ThemeRegistryService { Future> loadThemeDefinitions() async { final definitions = []; + await _loadBuiltinAssetDefinitions(definitions); + await _scanDirectory( await _userThemesDirectory(), ThemeSource.filesystem, @@ -171,14 +182,6 @@ class ThemeRegistryService { ); } - final file = File(path); - if (!await file.exists()) { - return ThemeLoadFailure( - definition: definition, - message: 'Theme file not found.', - ); - } - final cacheKey = definition.stableCacheKey; final cachedTheme = _themeCache.get(cacheKey); if (cachedTheme != null) { @@ -186,7 +189,14 @@ class ThemeRegistryService { } try { - final raw = await file.readAsString(); + final raw = await _readThemeRaw(definition); + if (raw == null) { + return ThemeLoadFailure( + definition: definition, + message: 'Theme file not found.', + ); + } + final theme = switch (definition.format) { ThemeFormat.queryaCustom => _loadCustomTheme(raw), ThemeFormat.vscode => _loadVsCodeTheme(raw), @@ -305,6 +315,56 @@ class ThemeRegistryService { return null; } + Future _loadBuiltinAssetDefinitions(List out) async { + for (final fileName in _bundledThemeAssetFiles) { + final assetPath = BuiltinThemeAssets.assetPath(fileName); + try { + final raw = await _readAssetString(assetPath); + final hash = _contentHash(raw); + final json = _decodeRoot(raw); + if (json == null) { + _logScanError(assetPath, 'Invalid JSON'); + continue; + } + + final definition = _definitionFromRaw( + json: json, + path: assetPath, + fileBaseName: p.basenameWithoutExtension(fileName), + source: ThemeSource.builtin, + contentHash: hash, + ); + if (definition != null) { + out.add(definition); + } + } on Object catch (e) { + _logScanError(assetPath, e); + } + } + } + + Future _readThemeRaw(ThemeDefinition definition) async { + final path = definition.path; + if (path == null || path.isEmpty) return null; + + if (definition.source == ThemeSource.builtin && _isAssetPath(path)) { + try { + return await _readAssetString(path); + } on Object { + return null; + } + } + + final file = File(path); + if (!await file.exists()) return null; + return file.readAsString(); + } + + Future _readAssetString(String assetPath) => + _assetLoader(assetPath); + + static bool _isAssetPath(String path) => path.startsWith('assets/'); + Future _scanDirectory( Directory directory, ThemeSource source, @@ -349,21 +409,23 @@ class ThemeRegistryService { final schema = json['schema']?.toString(); if (schema == queryaThemeSchemaV1) { - return _customDefinition( + return _definitionFromRaw( json: json, - file: file, + path: file.path, + fileBaseName: p.basenameWithoutExtension(file.path), source: source, - lastModified: stat.modified, contentHash: hash, + lastModified: stat.modified, ); } - return _vscodeDefinition( + return _definitionFromRaw( json: json, - file: file, + path: file.path, + fileBaseName: p.basenameWithoutExtension(file.path), source: source, - lastModified: stat.modified, contentHash: hash, + lastModified: stat.modified, ); } on Object catch (e) { _logScanError(file.path, e); @@ -371,23 +433,52 @@ class ThemeRegistryService { } } + ThemeDefinition? _definitionFromRaw({ + required Map json, + required String path, + required String fileBaseName, + required ThemeSource source, + required String contentHash, + DateTime? lastModified, + }) { + final schema = json['schema']?.toString(); + if (schema == queryaThemeSchemaV1) { + return _customDefinition( + json: json, + source: source, + contentHash: contentHash, + path: path, + lastModified: lastModified, + ); + } + + return _vscodeDefinition( + json: json, + source: source, + contentHash: contentHash, + fileBaseName: fileBaseName, + path: path, + lastModified: lastModified, + ); + } + ThemeDefinition? _customDefinition({ required Map json, - required File file, required ThemeSource source, - required DateTime lastModified, required String contentHash, + required String path, + DateTime? lastModified, }) { final id = json['id']?.toString().trim(); final name = json['name']?.toString().trim(); final type = json['type']?.toString().trim().toLowerCase(); if (id == null || id.isEmpty || name == null || name.isEmpty) { - _logScanError(file.path, 'Missing required custom theme fields'); + _logScanError(path, 'Missing required custom theme fields'); return null; } if (type != 'dark' && type != 'light') { - _logScanError(file.path, 'Invalid custom theme type "$type"'); + _logScanError(path, 'Invalid custom theme type "$type"'); return null; } @@ -397,7 +488,7 @@ class ThemeRegistryService { source: source, format: ThemeFormat.queryaCustom, isDark: type == 'dark', - path: file.path, + path: path, lastModified: lastModified, contentHash: contentHash, ); @@ -405,23 +496,23 @@ class ThemeRegistryService { ThemeDefinition? _vscodeDefinition({ required Map json, - required File file, required ThemeSource source, - required DateTime lastModified, required String contentHash, + required String fileBaseName, + required String path, + DateTime? lastModified, }) { - final fileId = p.basenameWithoutExtension(file.path); final rawName = json['name']?.toString().trim(); - final name = rawName != null && rawName.isNotEmpty ? rawName : fileId; + final name = rawName != null && rawName.isNotEmpty ? rawName : fileBaseName; final type = json['type']?.toString().trim().toLowerCase(); return ThemeDefinition( - id: fileId, + id: fileBaseName, name: name, source: source, format: ThemeFormat.vscode, isDark: type == 'dark', - path: file.path, + path: path, lastModified: lastModified, contentHash: contentHash, ); diff --git a/pubspec.yaml b/pubspec.yaml index 3f764c8a..83da6093 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,3 +42,4 @@ flutter: uses-material-design: true assets: - assets/images/ + - assets/themes/ diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 48a55c8c..f71315df 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -50,6 +50,11 @@ class _GatedRegistryService extends ThemeRegistryService { } } +Future _fixtureAssetLoader(String assetPath) async { + final fileName = p.basename(assetPath); + return File(p.join('test/fixtures/themes', fileName)).readAsString(); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -72,6 +77,7 @@ void main() { registry = ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, + assetLoader: _fixtureAssetLoader, ); ThemeController.instance.setRegistryServiceForTest(registry); }); @@ -263,6 +269,20 @@ void main() { expect(await AppSettings.instance.getSelectedThemeId(), isNull); }); + test('setThemeById applies built-in asset theme from registry', () async { + final c = ThemeController.instance; + await c.load(); + + await c.setThemeById('cyberpunk-neon'); + + expect(c.selectedThemeId, 'cyberpunk-neon'); + expect(c.selectedThemeLoadError, isNull); + expect(c.activeTheme.brightness, Brightness.dark); + expect(c.activeTheme.editor.background, parseQueryaThemeColor('#0a0a14')); + expect(await AppSettings.instance.getSelectedThemeId(), 'cyberpunk-neon'); + expect(await AppSettings.instance.getSelectedThemeSource(), 'builtin'); + }); + test('setThemeById applies built-in Querya Light preset', () async { final c = ThemeController.instance; await c.load(); @@ -296,6 +316,7 @@ void main() { expect(c.availableThemes.map((theme) => theme.id), containsAll([ ThemeController.builtinQueryaDarkId, ThemeController.builtinQueryaLightId, + 'cyberpunk-neon', ])); expect(c.effectiveSelectedThemeId, ThemeController.builtinQueryaDarkId); }); diff --git a/test/core/theme/theme_registry_builtin_assets_test.dart b/test/core/theme/theme_registry_builtin_assets_test.dart new file mode 100644 index 00000000..0844b354 --- /dev/null +++ b/test/core/theme/theme_registry_builtin_assets_test.dart @@ -0,0 +1,117 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +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/theme/builtin_theme_assets.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/theme_definition.dart'; +import 'package:querya_desktop/core/theme/theme_load_result.dart'; +import 'package:querya_desktop/core/theme/theme_registry_service.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; +} + +Future _fixtureAssetLoader(String assetPath) async { + final fileName = p.basename(assetPath); + return File(p.join('test/fixtures/themes', fileName)).readAsString(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late Directory themesDir; + late Directory importedDir; + late ThemeRegistryService registry; + + setUpAll(() async { + tempDir = + await Directory.systemTemp.createTemp('querya_builtin_theme_assets_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + }); + + 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, + assetLoader: _fixtureAssetLoader, + ); + }); + + tearDown(() async { + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + }); + + tearDownAll(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('ThemeRegistryService built-in asset themes', () { + test('includes bundled cyberpunk-neon definition without filesystem scan', + () async { + final definitions = await registry.loadThemeDefinitions(); + + expect(definitions, hasLength(1)); + final cyberpunk = definitions.single; + expect(cyberpunk.id, 'cyberpunk-neon'); + expect(cyberpunk.name, 'Querya Cyberpunk Neon'); + expect(cyberpunk.source, ThemeSource.builtin); + expect(cyberpunk.format, ThemeFormat.vscode); + expect(cyberpunk.isDark, isTrue); + expect(cyberpunk.isFileBacked, isFalse); + expect( + cyberpunk.path, + BuiltinThemeAssets.assetPath('cyberpunk-neon.json'), + ); + expect(cyberpunk.contentHash, isNotEmpty); + }); + + test('loads built-in asset theme from bundle', () async { + final definition = (await registry.loadThemeDefinitions()).single; + final result = await registry.loadTheme(definition); + + expect(result, isA()); + final success = result as ThemeLoadSuccess; + expect(success.theme.brightness, Brightness.dark); + expect( + success.theme.editor.background, + parseQueryaThemeColor('#0a0a14'), + ); + }); + + test('sorts built-in assets with filesystem themes by name', () async { + await File(p.join(themesDir.path, 'z-theme.json')).writeAsString(''' +{ + "schema": "querya.theme.v1", + "id": "z-theme", + "name": "Zebra Theme", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {} +} +'''); + + final definitions = await registry.loadThemeDefinitions(); + + expect(definitions.map((d) => d.name), [ + 'Querya Cyberpunk Neon', + 'Zebra Theme', + ]); + }); + }); +} diff --git a/test/core/theme/theme_registry_cache_test.dart b/test/core/theme/theme_registry_cache_test.dart index 9e379d49..60029779 100644 --- a/test/core/theme/theme_registry_cache_test.dart +++ b/test/core/theme/theme_registry_cache_test.dart @@ -40,6 +40,7 @@ void main() { registry = ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, + bundledThemeAssetFiles: const [], ); }); @@ -95,6 +96,7 @@ void main() { maxCacheEntries: 2, userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, + bundledThemeAssetFiles: const [], ); await _copyFixture( diff --git a/test/core/theme/theme_registry_legacy_import_test.dart b/test/core/theme/theme_registry_legacy_import_test.dart index 318b4d52..8604ffe1 100644 --- a/test/core/theme/theme_registry_legacy_import_test.dart +++ b/test/core/theme/theme_registry_legacy_import_test.dart @@ -44,6 +44,7 @@ void main() { registry = ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, + bundledThemeAssetFiles: const [], ); }); diff --git a/test/core/theme/theme_registry_service_test.dart b/test/core/theme/theme_registry_service_test.dart index d870d2a6..2e0734a5 100644 --- a/test/core/theme/theme_registry_service_test.dart +++ b/test/core/theme/theme_registry_service_test.dart @@ -44,6 +44,7 @@ void main() { registry = ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, + bundledThemeAssetFiles: const [], ); }); diff --git a/test/features/settings/preferences_appearance_section_test.dart b/test/features/settings/preferences_appearance_section_test.dart index f237a37a..547c938e 100644 --- a/test/features/settings/preferences_appearance_section_test.dart +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -14,6 +14,11 @@ import 'package:querya_desktop/features/settings/theme_picker_button.dart'; import '../../support/querya_theme_test_shell.dart'; +Future _fixtureAssetLoader(String assetPath) async { + final fileName = p.basename(assetPath); + return File(p.join('test/fixtures/themes', fileName)).readAsString(); +} + class _FakePathProvider extends PathProviderPlatform { _FakePathProvider(this._root); final String _root; @@ -50,6 +55,7 @@ void main() { registry = ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, + assetLoader: _fixtureAssetLoader, ); ThemeController.instance.setRegistryServiceForTest(registry); await ThemeController.instance.load(); @@ -67,6 +73,7 @@ void main() { ThemeRegistryService( userThemesDirectory: () async => themesDir, importedThemesDirectory: () async => importedDir, + assetLoader: _fixtureAssetLoader, ), ); await AppSettings.instance.clearThemeSettings(); diff --git a/test/fixtures/themes/cyberpunk-neon.json b/test/fixtures/themes/cyberpunk-neon.json new file mode 100644 index 00000000..26ac9fbe --- /dev/null +++ b/test/fixtures/themes/cyberpunk-neon.json @@ -0,0 +1,96 @@ +{ + "name": "Querya Cyberpunk Neon", + "type": "dark", + "colors": { + "activityBar.background": "#050508", + "statusBar.background": "#050508", + "sideBar.background": "#0c0820", + "sideBar.foreground": "#8b7cf8", + "tab.activeBackground": "#14102a", + "panel.background": "#14102a", + "input.background": "#14102a", + "editor.background": "#0a0a14", + "editor.foreground": "#e8f4ff", + "editor.selectionBackground": "#ff2a6d44", + "editorLineNumber.foreground": "#4a3f7a", + "editorBracketMatch.background": "#00f5ff33", + "editorWidget.border": "#00f5ff66", + "focusBorder": "#00f5ff", + "list.hoverBackground": "#ff2a6d22", + "gitDecoration.modifiedResourceForeground": "#fcee09", + "gitDecoration.untrackedResourceForeground": "#39ff14" + }, + "tokenColors": [ + { + "name": "Comments", + "scope": ["comment", "comment.line", "comment.block", "punctuation.definition.comment"], + "settings": { + "foreground": "#5c4d8a", + "fontStyle": "italic" + } + }, + { + "name": "Keywords", + "scope": [ + "keyword", + "keyword.control", + "keyword.operator.logical", + "storage.type", + "storage.modifier" + ], + "settings": { + "foreground": "#ff2a6d", + "fontStyle": "bold" + } + }, + { + "name": "Strings", + "scope": ["string", "string.quoted.single", "string.quoted.double"], + "settings": { + "foreground": "#fcee09" + } + }, + { + "name": "Numbers", + "scope": ["constant.numeric", "constant.language"], + "settings": { + "foreground": "#bd00ff" + } + }, + { + "name": "Functions", + "scope": ["entity.name.function", "support.function"], + "settings": { + "foreground": "#00f5ff" + } + }, + { + "name": "Types / classes", + "scope": ["entity.name.type", "support.type"], + "settings": { + "foreground": "#8b7cf8" + } + }, + { + "name": "Variables", + "scope": ["variable", "variable.other"], + "settings": { + "foreground": "#e8f4ff" + } + }, + { + "name": "JSON keys", + "scope": ["support.type.property-name.json"], + "settings": { + "foreground": "#00f5ff" + } + }, + { + "name": "JSON strings", + "scope": ["string.quoted.double.json"], + "settings": { + "foreground": "#39ff14" + } + } + ] +}