From 124b71b5096d08327b0ff6840454367a3b06cbe4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 23:54:34 +0300 Subject: [PATCH 01/72] docs(theme): add custom JSON theme parser requirements Capture the source spec for querya.theme.v1 parsing and theme system scaling. --- docs/scheme-parcer.md | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/scheme-parcer.md diff --git a/docs/scheme-parcer.md b/docs/scheme-parcer.md new file mode 100644 index 00000000..761a1ce1 --- /dev/null +++ b/docs/scheme-parcer.md @@ -0,0 +1,54 @@ +ТЗ 1: Разработка парсера кастомных JSON-тем + +Цель: Реализовать утилиту, которая динамически считывает .json файлы (например, пресеты cyberpunk ) и конвертирует их в объекты ThemeData (для shadcn_flutter) и ThemeExtension (для уникальных элементов). + +1. Архитектура и расположение + + Локация: Вся логика парсинга должна находиться в lib/core/ (например, lib/core/theme/theme_parser.dart). + + Интеграция: Применение распарсенной темы происходит в lib/app/. + +2. Требования к JSON-структуре +Файл темы должен быть разделен на две логические части: + + shadcn_colors: базовые токены для кнопок, фонов и инпутов (соответствуют палитре shadcn_flutter ). + + editor_colors: кастомные токены для подсветки синтаксиса и сайдбаров (базовых цветов для этого не хватит ). + +3. Функционал парсера + + Десериализация: Чтение JSON и безопасное извлечение строковых значений HEX-цветов (например, #1E1E1E или 1E1E1E). + + Конвертер HEX -> Color: Утилита для преобразования строковых HEX-значений в объекты Color фреймворка Flutter. + + Маппинг: Генерация объекта ColorScheme (для shadcn_flutter) и пользовательского EditorThemeExtension. + +4. Обработка ошибок (Фолбэк) + + Если JSON файл поврежден или отсутствуют обязательные ключи, парсер должен тихо (без краша приложения) откатываться к дефолтной темной теме приложения. + +ТЗ 2: Аудит и масштабирование системы тем (Подготовка к 50+ темам) + +Цель: Обеспечить плавную работу UI, отсутствие утечек памяти и удобный UX при наличии большого количества кастомных тем. + +1. Оптимизация UI выбора тем (Preferences) + + Проблема: Если тем станет много, простой список вызовет проблемы с отрисовкой и перекрытием окна. + + Решение: Выпадающий список выбора темы должен использовать MenuAnchor. Обязательно внедрить жесткое ограничение высоты (например, maxHeight: 300.0) и внутренний скроллбар. + + Предпросмотр (Live Preview): При наведении на название темы в списке (состояние hover ), интерфейс не должен полностью перестраиваться, если тема еще не применена окончательно (избегаем лагов). + +2. Управление состоянием и хранение + + Кэширование: Парсинг JSON-файлов — это ресурсоемкая операция. Распарсенные объекты ThemeData должны кэшироваться в памяти (например, в Map), чтобы повторное переключение происходило мгновенно. + + Персистентность: Сохранять выбранный ID темы (или путь к файлу) необходимо в локальную базу данных SQLite, которая уже используется в проекте для метаданных. + +3. Интеграция с нативными элементами окна + + Синхронизация рамок: Приложение использует bitsdojo_window для отрисовки кастомных заголовков. При смене темы через парсер, цвета кнопок управления окном (свернуть/развернуть/закрыть) и цвет самого заголовка должны динамически перекрашиваться в цвет background новой темы. + +4. Динамическая загрузка из файловой системы + + Необходимо заложить возможность сканирования определенной папки в ОС пользователя (например, ~/.querya/themes/) при старте приложения, чтобы подтягивать не только встроенные themes/samples/, но и скачанные пользователями файлы. \ No newline at end of file From 747043461acc20cd24be3124fab0a6d73523d4dc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 23:54:34 +0300 Subject: [PATCH 02/72] docs(theme): add theme parser implementation plan Break the custom theme parser epic into concrete tasks aligned with the current architecture. --- docs/theme-parser-implementation-tasks.md | 591 ++++++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 docs/theme-parser-implementation-tasks.md diff --git a/docs/theme-parser-implementation-tasks.md b/docs/theme-parser-implementation-tasks.md new file mode 100644 index 00000000..ef34773a --- /dev/null +++ b/docs/theme-parser-implementation-tasks.md @@ -0,0 +1,591 @@ +# План реализации парсера кастомных JSON-тем + +Исходное ТЗ: [`scheme-parcer.md`](scheme-parcer.md). +Цель этого документа — разбить работу на маленькие задачи так, чтобы реализация хорошо ложилась на текущую архитектуру Querya и не ухудшала производительность при 50+ темах. + +## Текущее состояние + +В проекте уже есть большая часть инфраструктуры тем: + +- `lib/core/theme/querya_theme.dart` — главный объект темы: `QueryaWorkbenchTheme`, `QueryaEditorTheme`, `ColorScheme`, `tokenColors`. +- `lib/core/theme/theme_controller.dart` — singleton-контроллер темы, кэширует `QueryaTheme`, `ThemeData`, Material theme. +- `lib/core/theme/theme_import_service.dart` — импорт одного VS Code JSON/JSONC файла в app support. +- `lib/core/theme/parser/` — парсинг VS Code colors/tokenColors, JSONC, color parsing. +- `lib/features/settings/preferences_appearance_section.dart` — UI выбора темы и импорта. +- `lib/app/app.dart` — применение темы через `ShadcnApp` и `QueryaThemeScope`. + +Поэтому не нужно создавать параллельную систему `ThemeData` с нуля. Лучше добавить новый слой: **реестр тем + парсер кастомного формата**, который на выходе дает существующий `QueryaTheme`. + +## Целевая архитектура + +```text +themes/*.json / ~/.querya/themes/*.json + | + v +ThemeRegistryService + - сканирует директории + - хранит легкие manifest-метаданные + - лениво парсит выбранную тему + | + v +QueryaThemeManifestParser + - custom Querya JSON + - VS Code JSON/JSONC compatibility + | + v +QueryaThemeFactory + - manifest -> QueryaTheme + - fallback на QueryaTheme.darkDefault/lightDefault + | + v +ThemeController + - selectedThemeId + - cache + - persist в AppSettings/SQLite + | + v +ShadcnApp + QueryaThemeScope + bitsdojo window colors +``` + +## JSON-формат + +Поддержать новый формат с версией схемы, но сохранить совместимость с текущим VS Code import. + +Минимальная структура: + +```json +{ + "schema": "querya.theme.v1", + "id": "cyberpunk-neon", + "name": "Cyberpunk Neon", + "type": "dark", + "shadcn_colors": { + "background": "#09090B", + "foreground": "#F8FAFC", + "card": "#111113", + "cardForeground": "#F8FAFC", + "popover": "#111113", + "popoverForeground": "#F8FAFC", + "primary": "#22D3EE", + "primaryForeground": "#020617", + "secondary": "#18181B", + "secondaryForeground": "#F8FAFC", + "muted": "#18181B", + "mutedForeground": "#94A3B8", + "accent": "#27272A", + "accentForeground": "#F8FAFC", + "destructive": "#EF4444", + "destructiveForeground": "#F8FAFC", + "border": "#27272A", + "input": "#27272A", + "ring": "#22D3EE" + }, + "editor_colors": { + "background": "#09090B", + "foreground": "#E5E7EB", + "selection": "#155E75", + "lineNumber": "#64748B", + "bracketMatch": "#164E63", + "widgetBorder": "#22D3EE", + "sidebarBackground": "#020617", + "surface": "#111113", + "accent": "#22D3EE" + }, + "tokenColors": [] +} +``` + +Правила: + +- `schema`, `id`, `name`, `type` — обязательные. +- `type`: `dark` или `light`. +- Все цвета можно писать как `#RRGGBB`, `RRGGBB`, `#AARRGGBB`, `AARRGGBB`, короткие `#RGB/#RGBA` лучше поддержать только если это уже легко переиспользуется из `parseVsCodeColor`. +- Отсутствующие необязательные ключи добираются из `QueryaTheme.darkDefault` / `QueryaTheme.lightDefault`. +- Неизвестные ключи игнорируются, но в debug можно логировать. +- Поврежденный файл не должен ломать запуск приложения. + +## Мини-задачи + +### 1. Зафиксировать формат и тестовые fixtures + +**Файлы:** + +- `docs/theme-parser-implementation-tasks.md` +- `test/fixtures/themes/querya_custom_dark.json` +- `test/fixtures/themes/querya_custom_light.json` +- `test/fixtures/themes/querya_custom_invalid.json` + +**Что сделать:** + +- Добавить 2 валидных custom JSON темы и 1 битую. +- Описать обязательные/необязательные поля в `docs/theme-import.md` или отдельном `docs/theme-custom-json.md`. +- Явно указать, что текущий VS Code import остается поддержанным. + +**Definition of Done:** + +- Есть fixtures для dark/light/invalid. +- В документации есть пример структуры и fallback-правила. + +### 2. Добавить модели manifest для custom themes + +**Файлы:** + +- `lib/core/theme/parser/querya_theme_manifest.dart` + +**Что сделать:** + +- Создать immutable-модель: + - `QueryaThemeManifest` + - `QueryaThemeType` + - `QueryaThemeParseException` +- Поля: + - `schema` + - `id` + - `name` + - `isDark` + - `shadcnColors: Map` + - `editorColors: Map` + - `tokenColors: List` +- Метод `fromJsonString(String raw)`. +- Для JSONC использовать существующий `stripJsonc`. + +**Производительность:** + +- Не создавать `Color`/`ThemeData` на этапе чтения списка тем. +- Manifest-метаданные должны быть легкими. + +**Definition of Done:** + +- Парсер возвращает manifest без зависимости от Flutter widget layer. +- Ошибки возвращаются контролируемо через exception/result, без краша. + +### 3. Унифицировать HEX parsing + +**Файлы:** + +- `lib/core/theme/parser/color_parser.dart` + +**Что сделать:** + +- Проверить, покрывает ли `parseVsCodeColor` все нужные форматы. +- Если нет — добавить wrapper: + - `parseQueryaThemeColor(String raw)` + - принимает `#RRGGBB`, `RRGGBB`, `#AARRGGBB`, `AARRGGBB` + - нормализует ошибки в `FormatException` +- Не плодить второй несовместимый парсер. + +**Тесты:** + +- `test/core/theme/parser/color_parser_test.dart` +- Валидные и невалидные HEX. + +**Definition of Done:** + +- Все color formats из документации покрыты тестами. +- Invalid color не валит всю тему, если ключ необязательный. + +### 4. Маппинг custom manifest -> QueryaTheme + +**Файлы:** + +- `lib/core/theme/parser/querya_theme_from_manifest.dart` + +**Что сделать:** + +- Реализовать pure-функцию: + +```dart +QueryaTheme queryaThemeFromManifest(QueryaThemeManifest manifest) +``` + +- Базовый fallback: + - `manifest.isDark ? QueryaTheme.darkDefault : QueryaTheme.lightDefault` +- `shadcn_colors` маппить в `ColorScheme`. +- `editor_colors` маппить в: + - `QueryaWorkbenchTheme` + - `QueryaEditorTheme` +- Для пересечения ключей (`background`, `accent`, `border`) выбрать единый источник: + - UI/shadcn берет `shadcn_colors` + - editor/workbench берет `editor_colors` +- `tokenColors` передать в `QueryaTheme.tokenColors`. + +**Важно:** + +- Не возвращать напрямую `ThemeData`. Внутри приложения единый источник истины — `QueryaTheme`, а `ThemeData` создается через `toShadcnThemeData()`. + +**Definition of Done:** + +- Custom manifest можно превратить в `QueryaTheme`. +- Missing optional fields берутся из fallback. +- Required missing fields дают controlled failure. + +### 5. Результаты парсинга и fallback без крашей + +**Файлы:** + +- `lib/core/theme/theme_parse_result.dart` или рядом с сервисом + +**Что сделать:** + +- Ввести result-типы: + - `ThemeLoadSuccess` + - `ThemeLoadFailure` +- Для UI показывать failure message. +- Для старта приложения: + - если выбранная тема сломана/удалена — тихо применить Querya Dark + - сохранить в лог/debug причину + - не перезаписывать пользовательские настройки сразу, чтобы файл можно было восстановить + +**Definition of Done:** + +- Поврежденный JSON не ломает запуск. +- Preferences показывает понятную ошибку при ручном импорте. + +### 6. Реестр тем вместо одного imported.json + +**Файлы:** + +- `lib/core/theme/theme_registry_service.dart` +- `lib/core/theme/theme_definition.dart` + +**Что сделать:** + +- Добавить `ThemeDefinition`: + - `id` + - `name` + - `source` (`builtin`, `imported`, `filesystem`) + - `path` + - `isDark` + - `format` (`queryaCustom`, `vscode`) + - `lastModified` + - `contentHash` +- `ThemeRegistryService.loadThemeDefinitions()`: + - встроенные темы из `themes/samples/` или будущего `assets/themes/` + - persisted imported + - пользовательская папка +- На первом этапе можно не делать asset bundle, а начать с app support + manual import. + +**Производительность:** + +- Сканирование читает только первые KB/manifest, а не строит `ThemeData`. +- Полный парсинг только при выборе/preview. +- Если 50+ файлов, UI получает список `ThemeDefinition`, а не тяжелые темы. + +**Definition of Done:** + +- Можно получить список доступных тем. +- Список не парсит каждую тему полностью. + +### 7. Кэш parsed theme и ThemeData + +**Файлы:** + +- `lib/core/theme/theme_controller.dart` +- `lib/core/theme/theme_registry_service.dart` + +**Что сделать:** + +- Кэшировать минимум: + - `Map _themeCache` + - `Map _shadcnThemeCache` +- Ключ кэша: + - `themeId + contentHash + brightness` +- При изменении файла: + - обновить `contentHash` + - инвалидировать только эту тему. +- Ограничить кэш, например LRU на 12-20 тем. + +**Производительность:** + +- Повторное переключение на уже открытую тему не читает файл и не парсит JSON. +- `ThemeController._invalidateThemeCache()` не должен сбрасывать весь registry без причины. + +**Definition of Done:** + +- Повторный выбор темы мгновенный. +- Тест проверяет, что один и тот же файл не парсится повторно без изменения hash. + +### 8. Persist выбранной темы + +**Файлы:** + +- `lib/core/storage/app_settings.dart` + +**Что сделать:** + +- Добавить настройки: + - `theme_selected_id` + - `theme_selected_source` + - `theme_selected_path` для filesystem themes +- Для совместимости: + - текущий `QueryaThemePreset.imported` продолжает работать + - при наличии old imported theme создать `ThemeDefinition` с id `imported` + +**SQLite vs settings key-value:** + +- Для выбранной темы достаточно текущего key-value слоя `AppSettings`. +- Для списка импортированных тем лучше отдельная таблица позже: + - `theme_id` + - `name` + - `path` + - `format` + - `last_modified` + - `content_hash` + +**Definition of Done:** + +- После перезапуска выбранная тема восстанавливается. +- Старые imported themes не ломаются. + +### 9. Динамическая папка тем + +**Файлы:** + +- `lib/core/theme/theme_registry_service.dart` +- `lib/core/theme/theme_paths.dart` + +**Что сделать:** + +- Определить папку: + - Linux/macOS: `${appSupport}/themes/` + - можно дополнительно поддержать `~/.querya/themes/`, но лучше app support как основной путь. +- Методы: + - `Future userThemesDirectory()` + - `Future> scanThemeFiles()` +- Поддержать расширения: + - `.json` + - `.jsonc` +- Не использовать watcher на первом этапе. Достаточно кнопки `Refresh themes`. + +**Производительность:** + +- Сканировать async. +- Не блокировать startup дольше 50-100ms: если файлов много, загрузить built-in/default сразу, список пользовательских тем догрузить после первого кадра. + +**Definition of Done:** + +- Файлы, добавленные в папку, появляются после refresh/restart. +- Битый файл не ломает список. + +### 10. Preferences UI для 50+ тем + +**Файлы:** + +- `lib/features/settings/preferences_appearance_section.dart` +- `lib/shared/widgets/querya_dropdown.dart` + +**Что сделать:** + +- Текущий `QueryaDropdown` уже построен на `MenuAnchor`, имеет `menuMaxHeight`. +- Для 50+ тем лучше сделать отдельный `ThemePickerButton`: + - trigger показывает текущую тему + - popup max height 300-360px + - `ListView.builder` + - scrollbar + - search/filter по названию + - source badge: Built-in / Imported / File +- Не строить превью каждой темы в списке. +- Для каждой строки использовать только `ThemeDefinition`. + +**Live preview:** + +- Hover не должен применять тему ко всему app. +- Если нужен preview: + - показывать справа маленькую карточку-превью + - парсить тему debounce 100-150ms + - не вызывать `ThemeController.setTheme(...)` на hover +- Полное применение — только click/select. + +**Definition of Done:** + +- 50+ тем открываются без лагов. +- Hover по списку не перестраивает `ShadcnApp`. +- Popup не выходит за экран и скроллится. + +### 11. Интеграция в ThemeController + +**Файлы:** + +- `lib/core/theme/theme_controller.dart` +- `lib/core/theme/querya_theme_preset.dart` + +**Что сделать:** + +- Не раздувать enum preset под каждую тему. +- Добавить понятие `selectedThemeId`. +- Сохранить старые preset-значения: + - `queryaDark` + - `queryaLight` + - `imported` как legacy/single import +- Новый путь: + - `ThemeController.loadAvailableThemes()` + - `ThemeController.setThemeById(String id)` + - `ThemeController.previewThemeById(String id)` только для preview-card, не для app. +- `activeTheme` должен брать тему из cache/registry. + +**Definition of Done:** + +- Старые тесты на presets проходят. +- Новые темы выбираются по id. +- Нет полного reparse при каждом rebuild. + +### 12. Синхронизация bitsdojo_window + +**Файлы:** + +- `lib/main.dart` +- место, где настраивается окно/кнопки bitsdojo +- возможно `lib/features/main_screen/main_screen.dart` + +**Что сделать:** + +- Найти текущую точку отрисовки title bar и window buttons. +- Использовать `QueryaThemeScope.of(context).workbench.canvas/background`. +- Цвет кнопок/hover должен зависеть от текущей темы. +- Не обращаться к `ThemeController.instance.activeTheme` глубоко в виджетах, если можно получить тему из `QueryaThemeScope`. + +**Производительность:** + +- Title bar должен перестраиваться только при смене темы, не при scale preview/обычных workspace state changes. + +**Definition of Done:** + +- При смене темы title bar и кнопки окна меняют цвет. +- На hover кнопок нет лишнего app-wide rebuild. + +### 13. Built-in themes и packaging + +**Файлы:** + +- `themes/samples/` +- возможно `assets/themes/` +- `pubspec.yaml` + +**Что сделать:** + +- Решить, shipped themes — это: + - dev-only samples (`themes/samples/`) + - или bundled assets (`assets/themes/`) для пользователей. +- Для релизной функциональности лучше `assets/themes/`. +- Добавить в `pubspec.yaml` assets: + +```yaml +flutter: + assets: + - assets/themes/ +``` + +- `ThemeRegistryService` должен читать built-in themes через `AssetManifest`. + +**Definition of Done:** + +- В релизной сборке встроенные темы доступны без файловой системы проекта. +- Samples остаются для docs/tests. + +### 14. Тесты парсера и registry + +**Файлы:** + +- `test/core/theme/querya_theme_manifest_test.dart` +- `test/core/theme/querya_theme_from_manifest_test.dart` +- `test/core/theme/theme_registry_service_test.dart` +- `test/features/settings/theme_picker_test.dart` + +**Что покрыть:** + +- Валидный dark custom JSON. +- Валидный light custom JSON. +- Missing optional keys fallback. +- Missing required keys failure. +- Invalid HEX skipped/failure по правилам. +- JSONC comments/trailing commas. +- 50 fake definitions в picker без overflow. +- Cache hit: повторный выбор не вызывает parse повторно. +- Broken persisted selected theme falls back to Querya Dark. + +**Definition of Done:** + +- `flutter analyze` clean. +- `flutter test` green. +- Есть тест на производительный сценарий 50+ themes. + +### 15. Миграция текущего imported theme + +**Что сделать:** + +- При `ThemeController.load()`: + - если есть старые `theme_import_*` настройки — создать legacy `ThemeDefinition`. + - `QueryaThemePreset.imported` продолжает работать. +- Не удалять `ThemeImportService` сразу. +- После внедрения registry можно постепенно заменить `ThemeImportService.importFromPath` на `ThemeRegistryService.importTheme`. + +**Definition of Done:** + +- Пользователь, который уже импортировал VS Code theme, не теряет тему после обновления. + +### 16. Документация для пользователей + +**Файлы:** + +- `docs/theme-import.md` +- новый `docs/theme-custom-json.md` +- `README.md` короткая ссылка при необходимости + +**Что описать:** + +- Куда класть темы. +- Формат custom JSON. +- Отличие VS Code JSON от Querya custom JSON. +- Как работает fallback. +- Как импортировать через Preferences. + +## Рекомендуемый порядок PR + +1. **Parser core only** + - manifest model + - color parser wrapper + - manifest -> QueryaTheme + - fixtures/tests + +2. **Registry + cache** + - `ThemeDefinition` + - scan app support themes + - LRU/cache by hash + - persistence selected id + +3. **Preferences UI** + - theme picker with max height / search / scrollbar + - no app-wide preview on hover + - import/refresh folder actions + +4. **Built-in assets + docs** + - package built-in themes + - docs and samples + +5. **Window chrome sync** + - title bar/window button colors from `QueryaThemeScope` + - focused tests/manual smoke + +## Performance rules + +- Никогда не строить `ThemeData` для всех тем при открытии Preferences. +- Не применять тему на hover. +- Не читать все файлы синхронно в `build()`. +- Не хранить `ThemeData` в SQLite; хранить только id/path/hash. +- Полный parse делать async и только для выбранной/preview темы. +- Кэшировать `QueryaTheme` и `ThemeData`. +- Для 50+ тем UI должен работать на `ThemeDefinition`, а не на parsed theme. +- Любая ошибка файла темы должна превращаться в fallback или UI error, но не в crash. + +## Acceptance checklist + +- [ ] Querya custom JSON импортируется и применяется. +- [ ] VS Code JSON/JSONC import продолжает работать. +- [ ] 50+ тем в Preferences не вызывают overflow и заметные лаги. +- [ ] Hover в списке не перестраивает весь app. +- [ ] Повторное переключение на уже открытую тему мгновенное. +- [ ] Сломанная выбранная тема не ломает запуск приложения. +- [ ] Выбранная тема сохраняется после рестарта. +- [ ] Window title bar синхронизирован с background/canvas темы. +- [ ] `flutter analyze` clean. +- [ ] `flutter test` green. From 2fc199b86b578facd3f0f8e20acf60ed571251db Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 23:54:34 +0300 Subject: [PATCH 03/72] docs(theme): add GitHub issues for custom theme parser epic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document TP-01 through TP-30 as ready-to-track GitHub issue templates (#96–#125). --- docs/theme-parser-github-issues.md | 1626 ++++++++++++++++++++++++++++ 1 file changed, 1626 insertions(+) create mode 100644 docs/theme-parser-github-issues.md diff --git a/docs/theme-parser-github-issues.md b/docs/theme-parser-github-issues.md new file mode 100644 index 00000000..58dc4b9c --- /dev/null +++ b/docs/theme-parser-github-issues.md @@ -0,0 +1,1626 @@ +# GitHub Issues: кастомные JSON-темы и масштабирование theme system + +Источник: [`theme-parser-implementation-tasks.md`](theme-parser-implementation-tasks.md). +Формат ниже рассчитан на перенос в GitHub Issues: каждый блок можно заводить как отдельный issue. + +## Labels + +Рекомендуемые labels: + +- `theme` +- `frontend` +- `performance` +- `parser` +- `settings` +- `docs` +- `tests` +- `good first issue` — только для изолированных docs/fixtures/test задач + +## Milestones / Epics + +- **Epic A — Custom theme parser core** +- **Epic B — Theme registry and caching** +- **Epic C — Preferences theme picker for 50+ themes** +- **Epic D — Built-in themes, filesystem loading, docs** +- **Epic E — Window chrome theme sync** + +## Recommended order + +1. TP-01 → TP-07: parser core. +2. TP-08 → TP-14: registry, persistence, cache. +3. TP-15 → TP-20: Preferences UI and import flow. +4. TP-21 → TP-24: built-in assets, filesystem folder, docs. +5. TP-25 → TP-27: window chrome sync and final hardening. +6. TP-28 → TP-30: QA, regression tests, release checklist. + +--- + +## TP-01 — Document Querya custom theme JSON schema + +**Labels:** `theme`, `docs`, `good first issue` +**Epic:** A +**Depends on:** none + +### Goal + +Create public documentation for the new Querya custom theme JSON format (`querya.theme.v1`) before implementing the parser. + +### Context + +Current `docs/theme-import.md` describes VS Code theme import. The new format must be documented separately so parser behavior is clear and testable. + +### Implementation + +Create `docs/theme-custom-json.md` with: + +- purpose of the Querya custom format; +- required root fields: + - `schema` + - `id` + - `name` + - `type` + - `shadcn_colors` + - `editor_colors` +- optional root fields: + - `tokenColors` + - `description` + - `author` + - `version` +- accepted `type` values: `dark`, `light`; +- accepted color formats: + - `#RRGGBB` + - `RRGGBB` + - `#AARRGGBB` + - `AARRGGBB` + - optionally `#RGB` / `#RGBA` if supported by existing parser; +- fallback rules: + - missing optional colors fallback to `QueryaTheme.darkDefault` / `QueryaTheme.lightDefault`; + - invalid optional color is ignored; + - missing required root field fails parsing; + - broken selected theme falls back to Querya Dark on startup; +- difference between VS Code JSON/JSONC and Querya custom JSON; +- one minimal working example and one full example. + +Update `docs/theme-import.md` with a short link to `docs/theme-custom-json.md`. + +### Acceptance Criteria + +- `docs/theme-custom-json.md` exists. +- It includes a valid copy-pastable JSON example. +- It explicitly says VS Code import remains supported. +- It documents fallback/error behavior. + +### Tests + +Docs only. No automated tests required. + +--- + +## TP-02 — Add custom theme JSON fixtures + +**Labels:** `theme`, `tests`, `good first issue` +**Epic:** A +**Depends on:** TP-01 + +### Goal + +Add stable fixtures for parser and factory tests. + +### Files + +- `test/fixtures/themes/querya_custom_dark.json` +- `test/fixtures/themes/querya_custom_light.json` +- `test/fixtures/themes/querya_custom_minimal.json` +- `test/fixtures/themes/querya_custom_invalid_missing_id.json` +- `test/fixtures/themes/querya_custom_invalid_color.json` +- `test/fixtures/themes/querya_custom_jsonc.jsonc` + +### Implementation + +Add fixtures: + +- `querya_custom_dark.json` + - full dark theme with `shadcn_colors`, `editor_colors`, and sample `tokenColors`; +- `querya_custom_light.json` + - full light theme; +- `querya_custom_minimal.json` + - only required root fields and a small set of colors; +- `querya_custom_invalid_missing_id.json` + - no `id`; +- `querya_custom_invalid_color.json` + - one optional invalid color and enough valid fields to test skip/failure policy; +- `querya_custom_jsonc.jsonc` + - comments and trailing commas. + +### Acceptance Criteria + +- Fixtures are small enough to read in tests. +- Dark/light/minimal fixtures use distinct values so tests can assert mapping. +- Invalid fixtures target one failure mode each. + +### Tests + +No parser tests in this issue. Fixtures are consumed by later issues. + +--- + +## TP-03 — Add `QueryaThemeManifest` model + +**Labels:** `theme`, `parser` +**Epic:** A +**Depends on:** TP-02 + +### Goal + +Create an immutable model for Querya custom theme manifests without converting colors to Flutter `Color` yet. + +### Files + +- `lib/core/theme/parser/querya_theme_manifest.dart` +- `test/core/theme/parser/querya_theme_manifest_test.dart` + +### Implementation + +Add: + +```dart +enum QueryaThemeType { dark, light } + +class QueryaThemeManifest { + const QueryaThemeManifest({ + required this.schema, + required this.id, + required this.name, + required this.type, + required this.shadcnColors, + required this.editorColors, + this.tokenColors = const [], + this.description, + this.author, + this.version, + }); +} + +class QueryaThemeManifestParseException implements Exception { + const QueryaThemeManifestParseException(this.message); + final String message; +} +``` + +Fields: + +- `schema: String` +- `id: String` +- `name: String` +- `type: QueryaThemeType` +- `shadcnColors: Map` +- `editorColors: Map` +- `tokenColors: List` +- optional metadata fields. + +Add `QueryaThemeManifest.fromJsonString(String raw)`. + +Use existing: + +- `stripJsonc` +- `TokenColorRule` / existing token color parser path where possible. + +### Performance Notes + +- Do not create `Color`, `QueryaTheme`, or `ThemeData` here. +- Keep maps unmodifiable. +- Parsing should be pure and synchronous for a single file; async belongs in services. + +### Acceptance Criteria + +- Valid dark/light fixtures parse. +- JSONC fixture parses. +- Missing required fields throw `QueryaThemeManifestParseException`. +- Unknown fields are ignored. +- Returned maps are immutable or safely copied. + +### Tests + +Cover: + +- valid full dark; +- valid full light; +- minimal manifest; +- JSONC comments/trailing commas; +- missing `id`; +- invalid `type`; +- empty `shadcn_colors` / `editor_colors` behavior according to docs. + +--- + +## TP-04 — Add Querya theme color parser wrapper + +**Labels:** `theme`, `parser`, `tests` +**Epic:** A +**Depends on:** TP-03 + +### Goal + +Support Querya custom HEX formats without duplicating incompatible color parsing logic. + +### Files + +- `lib/core/theme/parser/color_parser.dart` +- `test/core/theme/parser/color_parser_test.dart` + +### Implementation + +Add: + +```dart +Color parseQueryaThemeColor(String raw) +``` + +Behavior: + +- trim whitespace; +- accept `#RRGGBB`; +- accept `RRGGBB`; +- accept `#AARRGGBB`; +- accept `AARRGGBB`; +- delegate to `parseVsCodeColor` where possible; +- throw `FormatException` for invalid input. + +If current `parseVsCodeColor` already supports all formats, implement this wrapper as normalization + delegate. + +### Acceptance Criteria + +- Wrapper exists and is used by custom theme factory. +- No second unrelated parser implementation. +- Error messages mention the invalid value. + +### Tests + +Cases: + +- `#1E1E1E` +- `1E1E1E` +- `#FF1E1E1E` +- `FF1E1E1E` +- lowercase hex +- invalid length +- invalid characters +- empty string + +--- + +## TP-05 — Map `shadcn_colors` to `ColorScheme` + +**Labels:** `theme`, `parser` +**Epic:** A +**Depends on:** TP-04 + +### Goal + +Convert custom `shadcn_colors` into `shadcn_flutter.ColorScheme` with fallback values. + +### Files + +- `lib/core/theme/parser/querya_theme_color_scheme.dart` +- `test/core/theme/parser/querya_theme_color_scheme_test.dart` + +### Implementation + +Add pure function: + +```dart +ColorScheme colorSchemeFromQueryaThemeColors({ + required Map colors, + required QueryaTheme fallback, +}); +``` + +Map these keys: + +- `background` +- `foreground` +- `card` +- `cardForeground` +- `popover` +- `popoverForeground` +- `primary` +- `primaryForeground` +- `secondary` +- `secondaryForeground` +- `muted` +- `mutedForeground` +- `accent` +- `accentForeground` +- `destructive` +- `destructiveForeground` +- `border` +- `input` +- `ring` +- `chart1` +- `chart2` +- `chart3` +- `chart4` +- `chart5` + +Fallback: + +- missing key -> fallback `colorScheme` value; +- invalid optional color -> fallback value; +- debug log invalid optional key if useful. + +### Acceptance Criteria + +- Function is pure. +- Missing optional keys preserve fallback. +- Full fixture maps distinct expected values. +- Brightness comes from fallback theme, not from colors map. + +### Tests + +- full map uses custom values; +- missing keys use fallback; +- invalid optional color uses fallback; +- chart colors fallback correctly. + +--- + +## TP-06 — Map `editor_colors` to `QueryaEditorTheme` + +**Labels:** `theme`, `parser` +**Epic:** A +**Depends on:** TP-04 + +### Goal + +Convert custom editor color tokens into `QueryaEditorTheme`. + +### Files + +- `lib/core/theme/parser/querya_editor_theme_from_manifest.dart` +- `test/core/theme/parser/querya_editor_theme_from_manifest_test.dart` + +### Implementation + +Add: + +```dart +QueryaEditorTheme editorThemeFromQueryaColors({ + required Map colors, + required QueryaEditorTheme fallback, +}); +``` + +Support keys matching current `QueryaEditorTheme` fields. At minimum: + +- `background` +- `foreground` +- `selection` +- `lineNumber` +- `bracketMatch` +- `widgetBorder` + +If `QueryaEditorTheme` has additional fields, include them explicitly. + +Fallback: + +- missing/invalid key -> fallback field. + +### Acceptance Criteria + +- Full fixture changes editor background/foreground/selection. +- Minimal fixture falls back for missing fields. +- Invalid optional color does not crash. + +### Tests + +- full custom values; +- fallback behavior; +- invalid optional value. + +--- + +## TP-07 — Map `editor_colors` to `QueryaWorkbenchTheme` + +**Labels:** `theme`, `parser` +**Epic:** A +**Depends on:** TP-04 + +### Goal + +Convert workbench-related custom tokens into `QueryaWorkbenchTheme`. + +### Files + +- `lib/core/theme/parser/querya_workbench_theme_from_manifest.dart` +- `test/core/theme/parser/querya_workbench_theme_from_manifest_test.dart` + +### Implementation + +Add: + +```dart +QueryaWorkbenchTheme workbenchThemeFromQueryaColors({ + required Map colors, + required QueryaWorkbenchTheme fallback, +}); +``` + +Support keys: + +- `sidebarBackground` +- `canvas` +- `surface` +- `editorBackground` +- `mutedForeground` +- `accent` +- `onAccent` +- `borderSubtle` +- `destructive` +- `gitModified` +- `gitUntracked` + +If the docs use `background`, map it deliberately: + +- `background` -> `canvas` and/or `editorBackground` only if explicit in docs. + +### Acceptance Criteria + +- Mapping is documented in code comments or docs table. +- Missing keys use fallback. +- Invalid optional colors use fallback. + +### Tests + +- full custom workbench mapping; +- minimal fallback; +- invalid optional value. + +--- + +## TP-08 — Build `QueryaTheme` from custom manifest + +**Labels:** `theme`, `parser` +**Epic:** A +**Depends on:** TP-05, TP-06, TP-07 + +### Goal + +Provide one factory that converts `QueryaThemeManifest` into the existing app-level `QueryaTheme`. + +### Files + +- `lib/core/theme/parser/querya_theme_from_manifest.dart` +- `test/core/theme/parser/querya_theme_from_manifest_test.dart` + +### Implementation + +Add: + +```dart +QueryaTheme queryaThemeFromManifest(QueryaThemeManifest manifest) +``` + +Algorithm: + +1. Pick fallback: + - dark -> `QueryaTheme.darkDefault` + - light -> `QueryaTheme.lightDefault` +2. Build `ColorScheme` from `shadcn_colors`. +3. Build `QueryaEditorTheme` from `editor_colors`. +4. Build `QueryaWorkbenchTheme` from `editor_colors`. +5. Return `fallback.copyWith(...)`. +6. Preserve `tokenColors`. + +### Acceptance Criteria + +- Full dark fixture creates dark `QueryaTheme`. +- Full light fixture creates light `QueryaTheme`. +- `tokenColors` are preserved. +- No `ThemeData` is created. + +### Tests + +- dark brightness; +- light brightness; +- shadcn color mapping; +- editor/workbench mapping; +- token colors preserved; +- minimal fixture fallback. + +--- + +## TP-09 — Add typed theme load result + +**Labels:** `theme`, `parser`, `error-handling` +**Epic:** A +**Depends on:** TP-08 + +### Goal + +Introduce result types for loading/parsing themes so UI and startup can handle failures without exceptions leaking. + +### Files + +- `lib/core/theme/theme_load_result.dart` + +### Implementation + +Add sealed result: + +```dart +sealed class ThemeLoadResult { + const ThemeLoadResult(); +} + +class ThemeLoadSuccess extends ThemeLoadResult { + const ThemeLoadSuccess({ + required this.definition, + required this.theme, + }); +} + +class ThemeLoadFailure extends ThemeLoadResult { + const ThemeLoadFailure({ + required this.definition, + required this.message, + this.error, + }); +} +``` + +Use this result in later registry APIs. + +### Acceptance Criteria + +- Result can represent success/failure without throwing. +- Failure keeps enough data to show user-facing error and debug logs. + +### Tests + +No direct tests required unless lint coverage demands it. Later registry tests will cover usage. + +--- + +## TP-10 — Add `ThemeDefinition` + +**Labels:** `theme`, `registry` +**Epic:** B +**Depends on:** TP-03 + +### Goal + +Represent lightweight theme metadata for lists/pickers without full parsing. + +### Files + +- `lib/core/theme/theme_definition.dart` +- `test/core/theme/theme_definition_test.dart` + +### Implementation + +Add: + +```dart +enum ThemeSource { builtin, imported, filesystem, legacyImported } +enum ThemeFormat { queryaCustom, vscode } + +class ThemeDefinition { + const ThemeDefinition({ + required this.id, + required this.name, + required this.source, + required this.format, + required this.isDark, + this.path, + this.lastModified, + this.contentHash, + }); +} +``` + +Add helpers: + +- `bool get isFileBacked` +- `String get stableCacheKey` + +### Acceptance Criteria + +- `ThemeDefinition` is immutable. +- `stableCacheKey` changes when `contentHash` changes. +- Does not depend on Flutter widgets. + +### Tests + +- file-backed vs builtin; +- cache key includes id/hash/source; +- equality if implemented. + +--- + +## TP-11 — Add theme paths helper + +**Labels:** `theme`, `filesystem` +**Epic:** B +**Depends on:** TP-10 + +### Goal + +Centralize app theme directories and avoid path logic scattered across services. + +### Files + +- `lib/core/theme/theme_paths.dart` +- `test/core/theme/theme_paths_test.dart` if path provider can be faked easily. + +### Implementation + +Add: + +```dart +abstract final class ThemePaths { + static Future userThemesDirectory(); + static Future importedThemesDirectory(); +} +``` + +Rules: + +- Primary user dir: app support directory + `themes`. +- Imported dir: app support directory + `themes/imported`. +- Optionally expose `legacyDotQueryaThemesDirectory()` for later `~/.querya/themes`. + +### Acceptance Criteria + +- Directories are not created by path getter unless method name says `ensure`. +- Separate `ensureUserThemesDirectory()` can create it. + +### Tests + +- If existing test support fakes path provider, assert paths. +- Otherwise cover through registry tests. + +--- + +## TP-12 — Implement filesystem theme scan + +**Labels:** `theme`, `filesystem`, `performance` +**Epic:** B +**Depends on:** TP-10, TP-11 + +### Goal + +Scan app support theme folder and return lightweight `ThemeDefinition` objects. + +### Files + +- `lib/core/theme/theme_registry_service.dart` +- `test/core/theme/theme_registry_service_test.dart` + +### Implementation + +Add: + +```dart +class ThemeRegistryService { + Future> loadThemeDefinitions(); +} +``` + +For `.json` and `.jsonc` files: + +1. Read file async. +2. Detect format: + - if root `schema == querya.theme.v1` -> custom; + - otherwise try VS Code manifest. +3. Extract only metadata: + - id + - name + - type/isDark + - format + - path + - lastModified + - contentHash +4. Skip broken files from list or return a disabled/error definition. Prefer disabled/error definition if UI should show it later. + +### Performance Notes + +- Do not construct `QueryaTheme`. +- Do not construct `ThemeData`. +- Hash file content once during scan. +- Async file IO only. + +### Acceptance Criteria + +- Valid custom files appear. +- Valid VS Code files appear. +- Broken file does not crash scan. +- Only `.json` / `.jsonc` are considered. + +### Tests + +- temp dir with 2 valid themes and 1 broken; +- stable ordering by name; +- content hash changes when file changes. + +--- + +## TP-13 — Load selected theme by definition + +**Labels:** `theme`, `registry` +**Epic:** B +**Depends on:** TP-12, TP-09 + +### Goal + +Given a `ThemeDefinition`, parse the full theme and return `ThemeLoadResult`. + +### Files + +- `lib/core/theme/theme_registry_service.dart` +- `test/core/theme/theme_registry_service_test.dart` + +### Implementation + +Add: + +```dart +Future loadTheme(ThemeDefinition definition) +``` + +Behavior: + +- `ThemeFormat.queryaCustom` -> `QueryaThemeManifest.fromJsonString` -> `queryaThemeFromManifest`. +- `ThemeFormat.vscode` -> existing `VsCodeThemeManifest` -> existing `queryaThemeFromVsCode`. +- failure -> `ThemeLoadFailure`. +- missing file -> `ThemeLoadFailure`. + +### Acceptance Criteria + +- Custom definition loads to `QueryaTheme`. +- VS Code definition still loads. +- Missing/deleted file returns failure. +- No app crash on parse failure. + +### Tests + +- custom success; +- VS Code success using existing fixture; +- deleted file failure; +- invalid file failure. + +--- + +## TP-14 — Add LRU cache for parsed themes + +**Labels:** `theme`, `performance`, `registry` +**Epic:** B +**Depends on:** TP-13 + +### Goal + +Avoid repeated file reads and parsing when switching between themes. + +### Files + +- `lib/core/theme/theme_registry_service.dart` +- `test/core/theme/theme_registry_cache_test.dart` + +### Implementation + +Inside `ThemeRegistryService`: + +- cache `QueryaTheme` by `definition.stableCacheKey`; +- max entries: 12 or 20; +- on cache hit, return cached theme; +- on content hash change, key changes naturally; +- expose `clearCache()` for tests/reset. + +If current project has no LRU helper, implement tiny private LRU using `LinkedHashMap`. + +### Acceptance Criteria + +- Loading same definition twice parses once. +- Loading changed file parses again. +- Cache evicts oldest entry after limit. + +### Tests + +- fake parser counter or temp file mutation; +- cache hit; +- cache invalidation by hash; +- eviction. + +--- + +## TP-15 — Persist selected theme id/path in AppSettings + +**Labels:** `theme`, `storage` +**Epic:** B +**Depends on:** TP-10 + +### Goal + +Persist selected registry theme across restarts without storing heavy objects. + +### Files + +- `lib/core/storage/app_settings.dart` +- `test/core/storage/app_settings_test.dart` + +### Implementation + +Add keys: + +- `theme_selected_id` +- `theme_selected_source` +- `theme_selected_path` + +Add methods: + +```dart +Future getSelectedThemeId(); +Future setSelectedThemeId(String? id); +Future getSelectedThemeSource(); +Future setSelectedThemeSource(String? source); +Future getSelectedThemePath(); +Future setSelectedThemePath(String? path); +``` + +Keep existing preset/imported settings unchanged. + +### Acceptance Criteria + +- Settings roundtrip. +- Clearing selected theme works. +- No SQL workspace revision bump unless existing theme settings already do that intentionally. + +### Tests + +- id/source/path roundtrip; +- clear values; +- existing theme preset tests still pass. + +--- + +## TP-16 — Migrate legacy imported theme into registry + +**Labels:** `theme`, `migration`, `compatibility` +**Epic:** B +**Depends on:** TP-12, TP-15 + +### Goal + +Users with existing imported VS Code themes should keep them after registry lands. + +### Files + +- `lib/core/theme/theme_registry_service.dart` +- `lib/core/theme/theme_import_service.dart` +- `lib/core/theme/theme_controller.dart` +- tests as needed. + +### Implementation + +During registry load: + +- check existing persisted import path/name/colors; +- if found, add a `ThemeDefinition`: + - `id: legacy-imported` or `imported`; + - `source: legacyImported`; + - `format: vscode`; + - `path: stored import file`; + - `name: importedThemeName ?? "Imported theme"`. + +Do not delete old settings. + +### Acceptance Criteria + +- Existing `QueryaThemePreset.imported` still applies. +- Legacy imported theme appears in new picker. +- Missing legacy file falls back gracefully. + +### Tests + +- fake old imported path -> registry definition exists; +- selected legacy imported theme loads; +- missing old file does not crash. + +--- + +## TP-17 — Integrate registry into ThemeController load + +**Labels:** `theme`, `controller` +**Epic:** B +**Depends on:** TP-13, TP-15, TP-16 + +### Goal + +Make `ThemeController` aware of registry themes while preserving existing presets. + +### Files + +- `lib/core/theme/theme_controller.dart` +- `lib/core/theme/querya_theme_preset.dart` +- tests for theme controller. + +### Implementation + +Add state: + +- `_availableThemes: List` +- `_selectedThemeId: String?` +- `_selectedThemePath: String?` +- `_selectedThemeLoadError: String?` + +Add getters: + +- `availableThemes` +- `selectedThemeId` +- `selectedThemeLoadError` + +Add methods: + +```dart +Future loadAvailableThemes(); +Future setThemeById(String id); +Future previewThemeById(String id); +``` + +Behavior: + +- `load()` first loads existing mode/preset. +- Then load registry definitions async. +- If stored selected id exists, try load it. +- On failure, apply Querya Dark fallback but keep error visible. + +### Performance Notes + +- Do not call `notifyListeners()` for every discovered file. +- Batch registry load and notify once. +- `previewThemeById` must not mutate active app theme. + +### Acceptance Criteria + +- Existing `setPreset()` behavior still works. +- New `setThemeById()` applies registry theme. +- Broken selected theme falls back to Querya Dark. +- `previewThemeById()` returns theme/result without notifying app listeners. + +### Tests + +- old presets still pass; +- select by id persists setting; +- preview does not change `activeTheme`; +- broken selected id fallback. + +--- + +## TP-18 — Add `ThemePickerButton` widget shell + +**Labels:** `theme`, `settings`, `frontend` +**Epic:** C +**Depends on:** TP-10 + +### Goal + +Introduce a dedicated picker UI for many themes instead of overloading a small dropdown. + +### Files + +- `lib/features/settings/theme_picker_button.dart` +- `test/features/settings/theme_picker_button_test.dart` + +### Implementation + +Create widget: + +```dart +class ThemePickerButton extends StatelessWidget { + const ThemePickerButton({ + required this.themes, + required this.selectedThemeId, + required this.onSelected, + this.isLoading = false, + }); +} +``` + +Use: + +- `MenuAnchor`; +- fixed/max popup height 300-360px; +- `Scrollbar`; +- `ListView.builder`; +- row shows: + - name; + - source badge; + - dark/light icon or label. + +### Acceptance Criteria + +- Opens menu with 50+ fake themes without overflow. +- Uses builder list, not `Column(children: themes.map(...))`. +- Does not parse or apply theme during build. + +### Tests + +- pump with 60 definitions; +- open menu; +- visible rows render; +- no exception/overflow in test logs if test harness supports it; +- tap row triggers `onSelected(id)`. + +--- + +## TP-19 — Add search/filter to ThemePickerButton + +**Labels:** `theme`, `settings`, `frontend` +**Epic:** C +**Depends on:** TP-18 + +### Goal + +Make 50+ themes easy to navigate. + +### Files + +- `lib/features/settings/theme_picker_button.dart` +- `test/features/settings/theme_picker_button_test.dart` + +### Implementation + +Inside popup: + +- small search input at top; +- local `TextEditingController`; +- filter by lowercase `name`, `id`, `source`; +- debounce not strictly required for 50 items, but avoid parsing/building themes; +- dispose controller. + +### Acceptance Criteria + +- Typing filters list. +- Empty result shows small message. +- Search does not call `ThemeController.setThemeById`. + +### Tests + +- filter by theme name; +- filter no results; +- clear input restores list. + +--- + +## TP-20 — Add safe preview card without applying theme on hover + +**Labels:** `theme`, `settings`, `performance` +**Epic:** C +**Depends on:** TP-18, TP-17 + +### Goal + +Optional visual preview for hovered/selected theme without rebuilding the whole app. + +### Files + +- `lib/features/settings/theme_preview_card.dart` +- `lib/features/settings/theme_picker_button.dart` +- tests as needed. + +### Implementation + +Add `ThemePreviewCard`: + +- accepts `QueryaTheme` or lightweight preview colors; +- displays: + - background; + - surface; + - primary/accent; + - sample text; + - editor background strip. + +In picker: + +- hover selects preview target id locally; +- debounce 100-150ms before calling `previewThemeById`; +- preview result stored in local state only; +- never call `setThemeById` on hover. + +### Acceptance Criteria + +- Hovering row does not change app theme. +- Preview card updates after debounce. +- Broken preview shows non-blocking error in card. + +### Tests + +- hover/callback does not call `onSelected`; +- preview future resolves and card updates; +- broken preview shows fallback/error. + +--- + +## TP-21 — Wire ThemePickerButton into Preferences + +**Labels:** `theme`, `settings`, `frontend` +**Epic:** C +**Depends on:** TP-17, TP-18 + +### Goal + +Replace or extend current Color preset dropdown with registry-backed theme selection. + +### Files + +- `lib/features/settings/preferences_appearance_section.dart` +- `lib/features/settings/theme_picker_button.dart` + +### Implementation + +In Appearance: + +- keep `Theme mode`; +- replace `Color preset` row with `Theme` row using `ThemePickerButton`; +- include existing Querya Dark/Light as built-in definitions; +- show current imported/registry selected theme; +- call `ThemeController.setThemeById(id)` on select; +- keep old `Import theme…` and `Reset appearance` buttons. + +### Compatibility + +- If registry unavailable/empty, fallback to current preset dropdown behavior or show Querya Dark/Light only. + +### Acceptance Criteria + +- Querya Dark/Light selectable. +- Legacy imported theme selectable if present. +- Selecting registry theme applies immediately. +- Existing reset returns to Querya Dark. + +### Tests + +- widget shows built-in themes; +- selecting theme calls controller hook or fake callback; +- reset remains visible; +- import button remains visible. + +--- + +## TP-22 — Add refresh themes action in Preferences + +**Labels:** `theme`, `settings`, `filesystem` +**Epic:** C +**Depends on:** TP-17, TP-21 + +### Goal + +Let users refresh filesystem themes without restarting the app. + +### Files + +- `lib/features/settings/preferences_appearance_section.dart` +- `lib/core/theme/theme_controller.dart` + +### Implementation + +Add button near import/reset: + +- `Refresh themes` +- calls `ThemeController.loadAvailableThemes()`; +- shows small loading state; +- preserves active theme if still available; +- if active theme changed on disk, optionally reload when selected again, not immediately. + +### Acceptance Criteria + +- Refresh updates list. +- Broken files do not break Preferences. +- Loading state does not block whole dialog. + +### Tests + +- fake controller list changes after refresh; +- button disabled while refreshing. + +--- + +## TP-23 — Import custom themes into user themes directory + +**Labels:** `theme`, `filesystem`, `settings` +**Epic:** D +**Depends on:** TP-12, TP-21 + +### Goal + +Make `Import theme…` add themes to registry instead of only overwriting one `imported.json`. + +### Files + +- `lib/core/theme/theme_registry_service.dart` +- `lib/core/theme/theme_import_service.dart` +- `lib/features/settings/preferences_appearance_section.dart` + +### Implementation + +Add: + +```dart +Future importThemeFile(String sourcePath) +``` + +Behavior: + +- detect Querya custom vs VS Code; +- validate; +- copy into app support themes directory; +- filename should be stable and safe: + - `${id}.json` for custom; + - slugified name for VS Code; +- avoid overwrite: + - if same id/hash exists, reuse; + - if same id different hash, append suffix or replace only after explicit policy; +- return new `ThemeDefinition`. + +### Acceptance Criteria + +- Importing custom theme adds it to picker. +- Importing VS Code theme still works. +- Multiple imported themes can coexist. +- Old single imported flow still works until fully migrated. + +### Tests + +- import custom; +- import VS Code; +- duplicate import same hash; +- duplicate id different content. + +--- + +## TP-24 — Add built-in theme assets + +**Labels:** `theme`, `assets`, `docs` +**Epic:** D +**Depends on:** TP-12 + +### Goal + +Ship built-in sample themes in release builds, not only as repository files. + +### Files + +- `assets/themes/` +- `pubspec.yaml` +- `lib/core/theme/theme_registry_service.dart` +- tests as feasible. + +### Implementation + +Move/copy curated themes to: + +- `assets/themes/cyberpunk-neon.json` +- any other approved built-in themes. + +Update `pubspec.yaml`: + +```yaml +flutter: + assets: + - assets/themes/ +``` + +Registry: + +- load built-in asset manifest; +- create `ThemeDefinition(source: ThemeSource.builtin)`; +- load full theme from asset when selected. + +### Acceptance Criteria + +- Built-in themes show in picker in release/profile builds. +- App does not depend on repo `themes/samples/` path at runtime. +- Existing `themes/samples/` can remain for docs/manual testing. + +### Tests + +- Asset loading if test environment supports bundle. +- Otherwise unit-test parsing with same file content. + +--- + +## TP-25 — Add user theme folder docs and open-folder affordance + +**Labels:** `theme`, `docs`, `settings` +**Epic:** D +**Depends on:** TP-11, TP-21 + +### Goal + +Make filesystem themes discoverable. + +### Files + +- `docs/theme-custom-json.md` +- `docs/theme-import.md` +- `lib/features/settings/preferences_appearance_section.dart` + +### Implementation + +Docs: + +- show actual app support path behavior; +- mention accepted extensions; +- mention refresh/restart. + +UI: + +- show hint text: + - "Themes are loaded from app support themes folder." +- optional button: + - `Open themes folder` + - can be follow-up if cross-platform opening helper does not exist. + +### Acceptance Criteria + +- User can understand where to put downloaded themes. +- UI does not promise watcher/live reload if not implemented. + +### Tests + +Docs only unless adding button. + +--- + +## TP-26 — Sync custom window chrome with active theme + +**Labels:** `theme`, `frontend`, `bitsdojo` +**Epic:** E +**Depends on:** TP-17 + +### Goal + +Ensure title bar / window controls follow custom theme background/canvas. + +### Files + +- `lib/main.dart` +- `lib/features/main_screen/main_screen.dart` +- any title bar/window button widgets. + +### Implementation + +Find where `bitsdojo_window` title area and window controls are styled. + +Use: + +- `QueryaThemeScope.of(context).workbench.canvas` +- `QueryaThemeScope.of(context).workbench.surface` +- `QueryaThemeScope.of(context).workbench.mutedForeground` + +Avoid: + +- direct singleton reads inside deep widgets when inherited theme is available; +- app-wide notify on hover. + +### Acceptance Criteria + +- Switching theme updates title bar background. +- Window buttons remain readable. +- Hover states use theme tokens. + +### Tests + +- Widget test if title bar is testable. +- Otherwise manual smoke checklist in PR body: + - dark; + - light; + - custom dark; + - custom light. + +--- + +## TP-27 — Startup fallback for missing/broken selected theme + +**Labels:** `theme`, `error-handling`, `stability` +**Epic:** E +**Depends on:** TP-17 + +### Goal + +Prevent broken custom themes from breaking app startup. + +### Files + +- `lib/core/theme/theme_controller.dart` +- tests for controller. + +### Implementation + +On `ThemeController.load()`: + +1. Read selected theme id/path. +2. Try registry load. +3. If failure: + - set active theme to Querya Dark; + - keep `selectedThemeLoadError`; + - do not crash; + - do not delete user setting automatically. +4. Preferences can show: + - "Selected theme failed to load. Using Querya Dark." + +### Acceptance Criteria + +- Missing selected file starts app with Querya Dark. +- Invalid selected file starts app with Querya Dark. +- Error visible in Preferences. +- User can choose another theme and clear error. + +### Tests + +- missing file; +- invalid file; +- subsequent valid selection clears error. + +--- + +## TP-28 — Performance test: 50+ themes in picker + +**Labels:** `theme`, `performance`, `tests` +**Epic:** E +**Depends on:** TP-18, TP-21 + +### Goal + +Prevent regression where many themes make Preferences slow or overflow. + +### Files + +- `test/features/settings/theme_picker_button_test.dart` +- maybe `test/features/settings/preferences_appearance_section_test.dart` + +### Implementation + +Create 60 fake `ThemeDefinition` objects. + +Test: + +- picker opens; +- only visible subset is built if measurable; +- no overflow exception; +- scroll to bottom works; +- select last item works. + +If exact build count is hard to assert, assert behavior and no exceptions. + +### Acceptance Criteria + +- Test fails if picker uses unbounded `Column` and overflows. +- Test passes with `ListView.builder`. + +--- + +## TP-29 — End-to-end theme import test + +**Labels:** `theme`, `tests`, `integration` +**Epic:** E +**Depends on:** TP-21, TP-23 + +### Goal + +Cover the full import/select path with a fake filesystem theme. + +### Files + +- `test/features/settings/theme_import_flow_test.dart` + +### Implementation + +Use fake/temp app support path if project test support allows it. + +Flow: + +1. Put custom JSON in temp source. +2. Import through service/controller. +3. Registry list includes it. +4. Select it. +5. `ThemeController.activeTheme` changes expected token. +6. Restart-like reload preserves selection. + +### Acceptance Criteria + +- Custom theme can be imported, selected, and restored. +- Test does not depend on real user home directory. + +--- + +## TP-30 — Release docs and QA checklist for custom themes + +**Labels:** `theme`, `docs`, `qa` +**Epic:** E +**Depends on:** TP-01 through TP-29 + +### Goal + +Prepare the feature for release and manual verification. + +### Files + +- `docs/theme-custom-json.md` +- `docs/theme-import.md` +- `docs/release-checklist.md` +- `CHANGELOG.md` when release branch is prepared. + +### Implementation + +Add QA checklist: + +- import valid custom dark; +- import valid custom light; +- import VS Code JSONC; +- select among 50+ fake themes or test pack; +- restart app and verify selected theme persists; +- delete selected theme file and restart; +- verify fallback + Preferences error; +- verify title bar/window controls colors; +- verify SQL/JSON highlighting still uses tokenColors. + +### Acceptance Criteria + +- Release checklist includes custom theme scenarios. +- Docs include troubleshooting for invalid colors/missing fields. +- CHANGELOG entry can be written from completed issues. + +--- + +## Optional follow-up issues + +These are intentionally out of the first implementation pass. + +### TP-F1 — File watcher for user themes folder + +Use a filesystem watcher to auto-refresh themes after files are added/removed. Keep as follow-up because watchers differ by OS and can introduce lifecycle bugs. + +### TP-F2 — Theme marketplace metadata + +Support metadata fields like preview image, tags, homepage, license. Useful only after custom theme format is stable. + +### TP-F3 — Visual theme editor + +Allow editing theme colors in Preferences and export to `querya.theme.v1`. This is larger than parser/import support. + +### TP-F4 — Remote theme install + +Install theme from URL. Requires network, trust/security decisions, and probably signature/checksum policy. + +## Master checklist + +- [ ] TP-01 docs schema +- [ ] TP-02 fixtures +- [ ] TP-03 manifest model +- [ ] TP-04 color parser wrapper +- [ ] TP-05 shadcn color scheme mapping +- [ ] TP-06 editor theme mapping +- [ ] TP-07 workbench theme mapping +- [ ] TP-08 QueryaTheme factory +- [ ] TP-09 load result types +- [ ] TP-10 ThemeDefinition +- [ ] TP-11 theme paths +- [ ] TP-12 filesystem scan +- [ ] TP-13 load selected definition +- [ ] TP-14 parsed theme cache +- [ ] TP-15 AppSettings selected theme +- [ ] TP-16 legacy imported migration +- [ ] TP-17 ThemeController registry integration +- [ ] TP-18 ThemePickerButton shell +- [ ] TP-19 picker search/filter +- [ ] TP-20 safe preview card +- [ ] TP-21 Preferences integration +- [ ] TP-22 refresh themes action +- [ ] TP-23 multi-theme import +- [ ] TP-24 built-in theme assets +- [ ] TP-25 user theme folder docs +- [ ] TP-26 window chrome sync +- [ ] TP-27 startup fallback +- [ ] TP-28 50+ themes performance test +- [ ] TP-29 end-to-end import test +- [ ] TP-30 release QA docs From 54bb88ab4f310fef1c0731f9d474e89190945d14 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 23:54:34 +0300 Subject: [PATCH 04/72] docs: add marketplace extensions technical specification Describe the extensions manager UI and marketplace integration architecture. --- docs/market-tech.md | 103 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/market-tech.md diff --git a/docs/market-tech.md b/docs/market-tech.md new file mode 100644 index 00000000..2b1ceb18 --- /dev/null +++ b/docs/market-tech.md @@ -0,0 +1,103 @@ +Это потрясающая новость! Разработка собственного маркетплейса параллельно с клиентом — это переход от создания просто "инструмента" к созданию полноценной экосистемы (как у VS Code или Obsidian). Это невероятно мощный драйвер для роста сообщества и получения звезд на GitHub. + +Чтобы Querya Desktop оставалась легковесной, функционал маркетплейса должен быть реализован архитектурно грамотно: ядро ничего не знает о логике плагинов, оно лишь предоставляет интерфейс (API) для их загрузки и применения. + +Вот подробное техническое задание (ТЗ) на создание менеджера расширений и задел для интеграции с твоим будущим маркетом. +ТЗ 3: Встроенный Менеджер Расширений и Интеграция с Маркетплейсом + +Цель: Создать в интерфейсе Querya Desktop выделенный раздел для управления дополнениями (темами, UI-твиками, коннекторами) и заложить сетевую/файловую архитектуру для связи с внешним API маркетплейса. +1. UI/UX: Раздел «Extensions» (В стиле VS Code) + +В интерфейсе приложения (например, в левом боковом меню) появляется новая иконка (🧩 Пазл). + +Структура раздела: + + Левая панель (Навигация и Поиск): + + Строка поиска (с debounce-задержкой, чтобы не спамить API твоего маркета). + + Вкладки-фильтры: Installed (Установленные), Explore (Поиск по маркету), Updates (Доступные обновления). + + Центральная панель (Список): + + Карточки расширений с использованием компонентов shadcn_flutter. + + На карточке: Иконка, Название, Автор, Рейтинг (⭐), Бейдж типа (Theme, Plugin, Driver) и кнопка Install / Uninstall. + + Правая панель (Детали - Markdown View): + + При клике на карточку справа открывается подробное описание (парсится из README расширения), скриншоты и Changelog. + +2. Архитектура: Задел под Маркетплейс (Сетевой слой) + +В директории lib/core/ необходимо создать новый модуль market/, который будет отвечать за связь с твоим бэкендом. + +Ожидаемые контракты (Интерфейсы для будущего API): +Мобильный/десктопный клиент должен общаться с маркетом через четкие модели данных. Тебе нужно заложить класс ExtensionManifest, который клиент будет ожидать от бэкенда: +Dart + +class ExtensionManifest { + final String id; // e.g., 'reei.cyberpunk-theme' + final String name; // 'Cyberpunk 2077 Theme' + final String type; // 'theme', 'sql-formatter', 'visualizer' + final String version; // '1.0.2' + final String downloadUrl; // Ссылка на .zip или .json в твоем хранилище + final String sha256Checksum; // КРИТИЧНО: Хэш для проверки целостности +} + +Абстракция клиента (MarketplaceClient): +Сделай интерфейс, чтобы сейчас его можно было замокать (Mock), а потом просто подставить реальный HTTP-клиент: + + Future> fetchTrending() + + Future> search(String query) + + Future downloadExtension(String downloadUrl) + +3. Файловая система и Безопасность (Локальный слой) + +Querya Desktop — это клиент базы данных, поэтому безопасность (особенно при скачивании сторонних файлов) — приоритет №1. + + Директории: При старте приложение должно проверять и создавать папки в домашней директории пользователя: + + Linux/macOS: ~/.querya/extensions/themes/ и ~/.querya/extensions/plugins/ + + Windows: %APPDATA%\Querya\extensions\ + + Процесс установки (Флоу): + + Пользователь жмет Install. + + Приложение скачивает файл во временную папку. + + Сверяет sha256 скачанного файла с тем, что отдал API маркета. + + Распаковывает в нужную папку внутри ~/.querya/extensions/. + + Обновляет локальную базу данных SQLite (таблица installed_extensions). + + Изоляция (Sandboxing): На первом этапе (для тем) это просто JSON файлы, они безопасны. Но в ТЗ нужно указать, что исполняемые плагины в будущем должны загружаться как изолированные модули (например, через Dart Isolates или WASM), чтобы плагин не мог украсть креды от БД из ОС. + +4. Стейт-менеджмент (Управление состояниями) + +Для бесшовного опыта нужно создать ExtensionProvider (или использовать Bloc/Riverpod — в зависимости от того, что у вас в lib/core/). + +Отслеживаемые состояния: + + isMarketReachable: Проверка, доступен ли сервер маркета (если нет — показываем только вкладку Installed с заглушкой "Marketplace offline"). + + downloadProgress: Мапа Map для отображения прогресс-баров загрузки на кнопках Install. + + requireRestart: Флаг. Некоторым темам (или сложным плагинам) может потребоваться перезапуск приложения или сброс кэша редактора. Если флаг true, показываем всплывающий Toast (через shadcn_flutter). + +Маркетинговый совет для GitHub (Как использовать маркетплейс для звезд): + +Когда ты сделаешь этот раздел, добавь в README.md красивый бейдж: +[🔌 Querya Extension Market: Live] + +И напиши блок: + + Build your own tools for Querya + Querya Desktop features a built-in Marketplace. Don't like our UI? Download a new theme. Need a specific data visualizer? Write a plugin and publish it to the Querya Market in 5 minutes. + +Как тебе такой план? Если концепция ясна, мы можем углубиться в то, как именно ThemeParser (из предыдущего ТЗ) будет автоматически подхватывать свежескачанные JSON-файлы из папки ~/.querya/extensions/themes/ без перезагрузки приложения! \ No newline at end of file From f33b39207c34b51e772c5fad571627e9eb75f04e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 23:54:34 +0300 Subject: [PATCH 05/72] docs: index theme parser and marketplace planning docs Link new planning documents from the documentation index. --- docs/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/README.md b/docs/README.md index 2d38f3ea..8d3112d6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,10 @@ Index of Querya Desktop documentation, grouped by audience. ## Planning - [Roadmap](roadmap.md) — current direction and follow-ups. +- [Custom theme parser requirements](scheme-parcer.md) — JSON theme format and scaling spec. +- [Theme parser implementation plan](theme-parser-implementation-tasks.md) — task breakdown and architecture. +- [Theme parser GitHub issues](theme-parser-github-issues.md) — issue templates for epic #96–#125. +- [Marketplace extensions spec](market-tech.md) — extensions manager and marketplace integration. ## Archive From c94023213858f765bcd7e28228144269e638016f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 23:59:07 +0300 Subject: [PATCH 06/72] docs(theme): add querya.theme.v1 custom JSON schema Document required fields, color formats, key mappings, fallback rules, and examples for the native Querya theme format. Closes #96. --- docs/theme-custom-json.md | 276 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 docs/theme-custom-json.md diff --git a/docs/theme-custom-json.md b/docs/theme-custom-json.md new file mode 100644 index 00000000..c2a09f49 --- /dev/null +++ b/docs/theme-custom-json.md @@ -0,0 +1,276 @@ +# Querya custom theme JSON (`querya.theme.v1`) + +Querya supports two theme file formats: + +| Format | Root marker | Import | Docs | +|--------|-------------|--------|------| +| **Querya custom** | `"schema": "querya.theme.v1"` | Planned (theme registry) | this document | +| **VS Code** | `"colors"` object (no `schema`) | **Supported today** | [theme-import.md](theme-import.md) | + +VS Code JSON/JSONC import remains fully supported. The custom format is a first-class +Querya schema with explicit `shadcn_colors` and `editor_colors` sections instead of +VS Code workbench key names. + +## Purpose + +- Ship themes that map directly to Querya runtime models (`ColorScheme`, + `QueryaEditorTheme`, `QueryaWorkbenchTheme`) without VS Code key indirection. +- Keep theme list scans lightweight: read metadata and color maps as strings; build + `QueryaTheme` only when a theme is selected. +- Scale to 50+ installed themes with predictable fallback behavior. + +Implementation (planned): `lib/core/theme/parser/querya_theme_manifest.dart` and +registry services described in +[theme-parser-implementation-tasks.md](theme-parser-implementation-tasks.md). + +## Root object + +### Required fields + +| Field | Type | Description | +|-------|------|-------------| +| `schema` | string | Must be exactly `querya.theme.v1`. | +| `id` | string | Stable machine id (slug). Used for filenames and settings. Lowercase letters, digits, hyphens recommended. | +| `name` | string | Human-readable label shown in Preferences. | +| `type` | string | `"dark"` or `"light"`. Selects fallback preset (`QueryaTheme.darkDefault` / `QueryaTheme.lightDefault`). | +| `shadcn_colors` | object | String map → shadcn `ColorScheme` tokens. May be `{}`; missing keys fall back to the preset. | +| `editor_colors` | object | String map → editor and workbench tokens. May be `{}`; missing keys fall back to the preset. | + +### Optional fields + +| Field | Type | Description | +|-------|------|-------------| +| `tokenColors` | array | VS Code–compatible syntax rules (same shape as VS Code themes). Applied to SQL/JSON highlighting. | +| `description` | string | Short summary for theme picker / marketplace. | +| `author` | string | Author or org name. | +| `version` | string | Theme package version (informational). | + +Unknown root fields are ignored. In debug builds the parser may log skipped keys. + +## Color string formats + +All color values are hex strings. The parser reuses `parseVsCodeColor` (via a thin +Querya wrapper) and accepts: + +| Format | Example | Notes | +|--------|---------|-------| +| `#RRGGBB` | `"#22D3EE"` | Most common | +| `RRGGBB` | `"22D3EE"` | `#` optional | +| `#RRGGBBAA` | `"#FF22D3EE"` | Alpha last (VS Code style) | +| `RRGGBBAA` | `"FF22D3EE"` | `#` optional | +| `#RGB` | `"#F0A"` | Expanded to `#FF00AA` | +| `#RGBA` | `"#F0A8"` | Expanded to `#FF00AA88` | + +Invalid optional color values are **skipped** for that key; the fallback preset value +is used instead. Empty strings are treated as invalid. + +## `shadcn_colors` keys + +Maps to `shadcn_flutter.ColorScheme` (see `QueryaTheme.colorScheme`). + +| Key | Role | +|-----|------| +| `background` | App / page background | +| `foreground` | Primary text | +| `card` | Card surface | +| `cardForeground` | Text on cards | +| `popover` | Popover / dropdown surface | +| `popoverForeground` | Text on popovers | +| `primary` | Primary actions | +| `primaryForeground` | Text on primary | +| `secondary` | Secondary surfaces | +| `secondaryForeground` | Text on secondary | +| `muted` | Muted surfaces | +| `mutedForeground` | Muted labels | +| `accent` | Hover / accent fills | +| `accentForeground` | Text on accent | +| `destructive` | Destructive actions | +| `destructiveForeground` | Text on destructive | +| `border` | Borders | +| `input` | Input borders / fills | +| `ring` | Focus ring | +| `chart1` … `chart5` | Chart palette | + +Brightness comes from `type`, not from individual colors. + +## `editor_colors` keys + +One map feeds both `QueryaEditorTheme` and `QueryaWorkbenchTheme`. + +### Editor (syntax surface) + +| Key | Target | +|-----|--------| +| `background` | Editor background | +| `foreground` | Default text | +| `lineHighlight` | Current line highlight | +| `selection` | Selection background | +| `lineNumber` | Gutter numbers | +| `bracketMatch` | Matching bracket highlight | +| `widgetBorder` | Editor chrome border | +| `comment` | Comment token (fallback when no `tokenColors` match) | +| `keyword` | Keyword token | +| `string` | String token | +| `number` | Numeric token | +| `operator` | Operator token | +| `function` | Function token | +| `type` | Type name token | + +### Workbench (chrome) + +| Key | Target | +|-----|--------| +| `canvas` | Main app canvas (title bar, status areas) | +| `surface` | Raised panels, tabs | +| `sidebarBackground` | Explorer / sidebar | +| `editorBackground` | Editor pane chrome (may differ from syntax `background`) | +| `mutedForeground` | Secondary labels | +| `accent` | Brand / focus accent | +| `onAccent` | Text/icons on accent | +| `borderSubtle` | Subtle dividers | +| `destructive` | Error / delete emphasis | +| `success` | Success state | +| `warning` | Warning state | +| `gitModified` | Git modified decoration | +| `gitUntracked` | Git untracked decoration | + +If `background` appears without `canvas`, the parser does **not** auto-map it unless +explicitly documented in a future schema revision. Prefer `canvas` and +`editorBackground`. + +## `tokenColors` + +Same structure as VS Code themes: array of objects with `scope` (string or array), +optional `name`, and `settings.foreground` / `settings.background` / `settings.fontStyle`. + +Querya applies these through `TokenStyleResolver` → SQL/JSON highlighters. See +[theme-import.md](theme-import.md) for behavior notes. + +## Fallback and error handling + +| Situation | Behavior | +|-----------|----------| +| Missing optional color key | Use value from `QueryaTheme.darkDefault` or `QueryaTheme.lightDefault` (based on `type`). | +| Invalid optional color | Skip key; use fallback value. | +| Missing required root field (`schema`, `id`, `name`, `type`, `shadcn_colors`, `editor_colors`) | Parsing fails; theme is not loaded. | +| Wrong `schema` value | Parsing fails. | +| Invalid `type` | Parsing fails. | +| Broken file at startup (selected theme) | App starts with **Querya Dark**; error surfaced in Preferences (planned). User setting is not deleted. | +| Broken file in directory scan | Skipped or shown as disabled in picker (planned); scan does not crash the app. | + +## JSONC + +Comments and trailing commas are allowed in `.jsonc` files. The shared preprocessor +`stripJsonc` runs before `jsonDecode` (same as VS Code import). + +## Minimal example + +Only required fields; all colors come from Querya Dark defaults: + +```json +{ + "schema": "querya.theme.v1", + "id": "querya-dark-clone", + "name": "Querya Dark (minimal)", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {} +} +``` + +## Full example (dark) + +```json +{ + "schema": "querya.theme.v1", + "id": "cyberpunk-neon", + "name": "Cyberpunk Neon", + "type": "dark", + "description": "Neon cyberpunk preset for Querya workbench and SQL editor.", + "author": "QueryaHub", + "version": "1.0.0", + "shadcn_colors": { + "background": "#09090B", + "foreground": "#F8FAFC", + "card": "#111113", + "cardForeground": "#F8FAFC", + "popover": "#111113", + "popoverForeground": "#F8FAFC", + "primary": "#00F5FF", + "primaryForeground": "#020617", + "secondary": "#18181B", + "secondaryForeground": "#F8FAFC", + "muted": "#18181B", + "mutedForeground": "#94A3B8", + "accent": "#FF2A6D", + "accentForeground": "#F8FAFC", + "destructive": "#EF4444", + "destructiveForeground": "#F8FAFC", + "border": "#27272A", + "input": "#27272A", + "ring": "#00F5FF", + "chart1": "#00F5FF", + "chart2": "#FF2A6D", + "chart3": "#FCEE09", + "chart4": "#BD00FF", + "chart5": "#39FF14" + }, + "editor_colors": { + "background": "#0A0A14", + "foreground": "#E8F4FF", + "selection": "#FF2A6D44", + "lineNumber": "#4A3F7A", + "bracketMatch": "#00F5FF33", + "widgetBorder": "#00F5FF66", + "canvas": "#050508", + "surface": "#14102A", + "sidebarBackground": "#0C0820", + "editorBackground": "#0A0A14", + "mutedForeground": "#8B7CF8", + "accent": "#00F5FF", + "onAccent": "#020617", + "borderSubtle": "#27272A", + "destructive": "#EF4444", + "gitModified": "#FCEE09", + "gitUntracked": "#39FF14" + }, + "tokenColors": [ + { + "name": "Comments", + "scope": ["comment", "comment.line"], + "settings": { "foreground": "#5C4D8A", "fontStyle": "italic" } + }, + { + "name": "Keywords", + "scope": ["keyword", "keyword.control"], + "settings": { "foreground": "#FF2A6D", "fontStyle": "bold" } + }, + { + "name": "Strings", + "scope": ["string"], + "settings": { "foreground": "#FCEE09" } + } + ] +} +``` + +## VS Code format vs Querya custom + +| | VS Code JSON/JSONC | Querya custom | +|--|-------------------|---------------| +| Detection | No `schema`; has `colors` | `"schema": "querya.theme.v1"` | +| UI colors | VS Code keys (`editor.background`, `sideBar.background`, …) | `shadcn_colors` + `editor_colors` Querya keys | +| Stable id | File name only | Required `id` field | +| Import today | **Yes** — Preferences → Import theme | Planned via theme registry | +| Syntax tokens | `tokenColors` | `tokenColors` (same) | + +To convert a VS Code theme manually, map keys using +[theme-import.md](theme-import.md) and place workbench values into `editor_colors`; +derive shadcn tokens from your palette or leave `{}` to use preset defaults. + +## Related docs + +- [Theme import (VS Code)](theme-import.md) +- [Theme system overview](theme.md) +- [Implementation plan](theme-parser-implementation-tasks.md) +- Sample VS Code themes: `themes/samples/cyberpunk-neon.json` From 6ade631c1529f395dc5115c7a640c337e72210a6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 23:59:07 +0300 Subject: [PATCH 07/72] docs(theme): link custom theme JSON from import guide and index Point VS Code import docs and the docs index to theme-custom-json.md. --- docs/README.md | 1 + docs/theme-import.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/README.md b/docs/README.md index 8d3112d6..2eecb4c7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ Index of Querya Desktop documentation, grouped by audience. - [Architecture](architecture.md) — `lib/` layout and module responsibilities. - [Theme system](theme.md) — runtime theming and VS Code theme tokens. - [Theme import](theme-import.md) — supported `colors` keys and merge behavior. +- [Custom theme JSON](theme-custom-json.md) — `querya.theme.v1` schema and fallback rules. - [Performance baseline](perf-baseline.md) — per-milestone DevTools checklist. ## For release managers diff --git a/docs/theme-import.md b/docs/theme-import.md index 0ccacb70..6403ef83 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -3,6 +3,10 @@ Querya can apply a **subset** of VS Code theme JSON / JSONC `colors` to `QueryaWorkbenchTheme`, `QueryaEditorTheme`, and the shadcn `ColorScheme`. +For the native Querya custom format (`querya.theme.v1`), see +**[theme-custom-json.md](theme-custom-json.md)**. Both formats will coexist; VS Code +import remains supported. + Imported `tokenColors` are persisted with the theme file and applied to SQL/JSON syntax highlighting via `TokenStyleResolver` → `HighlighterTheme` (issue #46). From 64b651c828c48fc53d42d859dde1546f74883d4a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 23:59:58 +0300 Subject: [PATCH 08/72] test(theme): add querya.theme.v1 parser fixtures Add dark, light, minimal, invalid, and JSONC theme files for upcoming manifest and factory tests. Closes #97. --- test/fixtures/themes/querya_custom_dark.json | 81 +++++++++++++++++++ .../themes/querya_custom_invalid_color.json | 14 ++++ .../querya_custom_invalid_missing_id.json | 7 ++ .../fixtures/themes/querya_custom_jsonc.jsonc | 22 +++++ test/fixtures/themes/querya_custom_light.json | 81 +++++++++++++++++++ .../themes/querya_custom_minimal.json | 12 +++ 6 files changed, 217 insertions(+) create mode 100644 test/fixtures/themes/querya_custom_dark.json create mode 100644 test/fixtures/themes/querya_custom_invalid_color.json create mode 100644 test/fixtures/themes/querya_custom_invalid_missing_id.json create mode 100644 test/fixtures/themes/querya_custom_jsonc.jsonc create mode 100644 test/fixtures/themes/querya_custom_light.json create mode 100644 test/fixtures/themes/querya_custom_minimal.json diff --git a/test/fixtures/themes/querya_custom_dark.json b/test/fixtures/themes/querya_custom_dark.json new file mode 100644 index 00000000..ce08d673 --- /dev/null +++ b/test/fixtures/themes/querya_custom_dark.json @@ -0,0 +1,81 @@ +{ + "schema": "querya.theme.v1", + "id": "fixture-custom-dark", + "name": "Fixture Custom Dark", + "type": "dark", + "description": "Full dark custom theme fixture for parser tests.", + "author": "QueryaHub", + "version": "1.0.0", + "shadcn_colors": { + "background": "#101014", + "foreground": "#E2E8F0", + "card": "#18181F", + "cardForeground": "#E2E8F0", + "popover": "#18181F", + "popoverForeground": "#E2E8F0", + "primary": "#38BDF8", + "primaryForeground": "#020617", + "secondary": "#1F2937", + "secondaryForeground": "#E2E8F0", + "muted": "#1F2937", + "mutedForeground": "#94A3B8", + "accent": "#6366F1", + "accentForeground": "#F8FAFC", + "destructive": "#F87171", + "destructiveForeground": "#F8FAFC", + "border": "#334155", + "input": "#334155", + "ring": "#38BDF8", + "chart1": "#38BDF8", + "chart2": "#6366F1", + "chart3": "#F472B6", + "chart4": "#A78BFA", + "chart5": "#34D399" + }, + "editor_colors": { + "background": "#0F1117", + "foreground": "#E2E8F0", + "lineHighlight": "#1A1D27", + "selection": "#264F78", + "lineNumber": "#64748B", + "bracketMatch": "#38BDF833", + "widgetBorder": "#38BDF866", + "comment": "#6A9955", + "keyword": "#569CD6", + "string": "#CE9178", + "number": "#B5CEA8", + "operator": "#D4D4D4", + "function": "#DCDCAA", + "type": "#4EC9B0", + "canvas": "#09090B", + "surface": "#111827", + "sidebarBackground": "#0B0F19", + "editorBackground": "#0F1117", + "mutedForeground": "#94A3B8", + "accent": "#38BDF8", + "onAccent": "#020617", + "borderSubtle": "#334155", + "destructive": "#F87171", + "success": "#34D399", + "warning": "#FBBF24", + "gitModified": "#FBBF24", + "gitUntracked": "#34D399" + }, + "tokenColors": [ + { + "name": "Comments", + "scope": ["comment", "comment.line"], + "settings": { "foreground": "#6A9955", "fontStyle": "italic" } + }, + { + "name": "Keywords", + "scope": ["keyword", "keyword.control"], + "settings": { "foreground": "#569CD6", "fontStyle": "bold" } + }, + { + "name": "Strings", + "scope": ["string"], + "settings": { "foreground": "#CE9178" } + } + ] +} diff --git a/test/fixtures/themes/querya_custom_invalid_color.json b/test/fixtures/themes/querya_custom_invalid_color.json new file mode 100644 index 00000000..d48c6bfa --- /dev/null +++ b/test/fixtures/themes/querya_custom_invalid_color.json @@ -0,0 +1,14 @@ +{ + "schema": "querya.theme.v1", + "id": "fixture-invalid-color", + "name": "Fixture Invalid Color", + "type": "dark", + "shadcn_colors": { + "primary": "not-a-color", + "background": "#101014" + }, + "editor_colors": { + "background": "#0F1117", + "selection": "ZZZZZZ" + } +} diff --git a/test/fixtures/themes/querya_custom_invalid_missing_id.json b/test/fixtures/themes/querya_custom_invalid_missing_id.json new file mode 100644 index 00000000..ab4eb120 --- /dev/null +++ b/test/fixtures/themes/querya_custom_invalid_missing_id.json @@ -0,0 +1,7 @@ +{ + "schema": "querya.theme.v1", + "name": "Missing Id Fixture", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {} +} diff --git a/test/fixtures/themes/querya_custom_jsonc.jsonc b/test/fixtures/themes/querya_custom_jsonc.jsonc new file mode 100644 index 00000000..d708efc6 --- /dev/null +++ b/test/fixtures/themes/querya_custom_jsonc.jsonc @@ -0,0 +1,22 @@ +{ + // JSONC fixture: comments and trailing commas are stripped before parse. + "schema": "querya.theme.v1", + "id": "fixture-custom-jsonc", + "name": "Fixture Custom JSONC", + "type": "light", + "shadcn_colors": { + "background": "#FAFAFA", + "primary": "#0EA5E9", + }, + "editor_colors": { + "background": "#FFFFFF", + "foreground": "#111827", + }, + "tokenColors": [ + { + "name": "Comments", + "scope": ["comment"], + "settings": { "foreground": "#6B7280" }, + }, + ], +} diff --git a/test/fixtures/themes/querya_custom_light.json b/test/fixtures/themes/querya_custom_light.json new file mode 100644 index 00000000..974da5de --- /dev/null +++ b/test/fixtures/themes/querya_custom_light.json @@ -0,0 +1,81 @@ +{ + "schema": "querya.theme.v1", + "id": "fixture-custom-light", + "name": "Fixture Custom Light", + "type": "light", + "description": "Full light custom theme fixture for parser tests.", + "author": "QueryaHub", + "version": "1.0.0", + "shadcn_colors": { + "background": "#F8FAFC", + "foreground": "#0F172A", + "card": "#FFFFFF", + "cardForeground": "#0F172A", + "popover": "#FFFFFF", + "popoverForeground": "#0F172A", + "primary": "#0284C7", + "primaryForeground": "#F8FAFC", + "secondary": "#E2E8F0", + "secondaryForeground": "#0F172A", + "muted": "#E2E8F0", + "mutedForeground": "#64748B", + "accent": "#CBD5E1", + "accentForeground": "#0F172A", + "destructive": "#DC2626", + "destructiveForeground": "#F8FAFC", + "border": "#CBD5E1", + "input": "#CBD5E1", + "ring": "#0284C7", + "chart1": "#0284C7", + "chart2": "#0891B2", + "chart3": "#EA580C", + "chart4": "#7C3AED", + "chart5": "#DB2777" + }, + "editor_colors": { + "background": "#FFFFFF", + "foreground": "#1E293B", + "lineHighlight": "#F1F5F9", + "selection": "#ADD6FF", + "lineNumber": "#64748B", + "bracketMatch": "#0284C733", + "widgetBorder": "#0284C766", + "comment": "#008000", + "keyword": "#0000FF", + "string": "#A31515", + "number": "#098658", + "operator": "#1E293B", + "function": "#795E26", + "type": "#267F99", + "canvas": "#F8FAFC", + "surface": "#FFFFFF", + "sidebarBackground": "#F1F5F9", + "editorBackground": "#FFFFFF", + "mutedForeground": "#64748B", + "accent": "#0284C7", + "onAccent": "#F8FAFC", + "borderSubtle": "#CBD5E1", + "destructive": "#DC2626", + "success": "#16A34A", + "warning": "#D97706", + "gitModified": "#D97706", + "gitUntracked": "#16A34A" + }, + "tokenColors": [ + { + "name": "Comments", + "scope": ["comment", "comment.line"], + "settings": { "foreground": "#008000", "fontStyle": "italic" } + }, + { + "name": "Keywords", + "scope": ["keyword", "keyword.control"], + "settings": { "foreground": "#0000FF", "fontStyle": "bold" } + }, + { + "name": "Strings", + "scope": ["string"], + "settings": { "foreground": "#A31515" } + } + ] +} diff --git a/test/fixtures/themes/querya_custom_minimal.json b/test/fixtures/themes/querya_custom_minimal.json new file mode 100644 index 00000000..1bd15fec --- /dev/null +++ b/test/fixtures/themes/querya_custom_minimal.json @@ -0,0 +1,12 @@ +{ + "schema": "querya.theme.v1", + "id": "fixture-custom-minimal", + "name": "Fixture Custom Minimal", + "type": "dark", + "shadcn_colors": { + "primary": "#FF00AA" + }, + "editor_colors": { + "background": "#010203" + } +} From 777fa44b17cba3f10836a80522dd5aa31ef53e31 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:04:56 +0300 Subject: [PATCH 09/72] feat(theme): add QueryaThemeManifest model Parse querya.theme.v1 JSON/JSONC into an immutable manifest with shadcn/editor color maps and tokenColors, without building Flutter Color objects yet. --- .../theme/parser/querya_theme_manifest.dart | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 lib/core/theme/parser/querya_theme_manifest.dart diff --git a/lib/core/theme/parser/querya_theme_manifest.dart b/lib/core/theme/parser/querya_theme_manifest.dart new file mode 100644 index 00000000..1e4c1480 --- /dev/null +++ b/lib/core/theme/parser/querya_theme_manifest.dart @@ -0,0 +1,196 @@ +import 'dart:convert'; + +import 'jsonc_preprocessor.dart'; +import 'vscode_theme_manifest.dart'; + +const queryaThemeSchemaV1 = 'querya.theme.v1'; + +enum QueryaThemeType { + dark, + light, +} + +/// Parsed Querya custom theme manifest (`querya.theme.v1`). +class QueryaThemeManifest { + const QueryaThemeManifest({ + required this.schema, + required this.id, + required this.name, + required this.type, + required this.shadcnColors, + required this.editorColors, + this.tokenColors = const [], + this.description, + this.author, + this.version, + }); + + final String schema; + final String id; + final String name; + final QueryaThemeType type; + final Map shadcnColors; + final Map editorColors; + final List tokenColors; + final String? description; + final String? author; + final String? version; + + bool get isDark => type == QueryaThemeType.dark; + bool get isLight => type == QueryaThemeType.light; + + factory QueryaThemeManifest.fromJsonString(String source) { + final cleaned = stripJsonc(source); + final dynamic decoded; + try { + decoded = jsonDecode(cleaned); + } on FormatException catch (e) { + throw QueryaThemeManifestParseException( + 'Invalid JSON after JSONC strip: ${e.message}', + ); + } + if (decoded is! Map) { + throw QueryaThemeManifestParseException('Theme root must be a JSON object'); + } + return QueryaThemeManifest.fromJson(decoded); + } + + factory QueryaThemeManifest.fromJson(Map json) { + final schema = _requiredString(json, 'schema'); + if (schema != queryaThemeSchemaV1) { + throw QueryaThemeManifestParseException( + 'Unsupported schema "$schema"; expected "$queryaThemeSchemaV1"', + ); + } + + final id = _requiredString(json, 'id'); + final name = _requiredString(json, 'name'); + final type = _parseType(_requiredString(json, 'type')); + final shadcnColors = _parseColorMap(json['shadcn_colors'], 'shadcn_colors'); + final editorColors = _parseColorMap(json['editor_colors'], 'editor_colors'); + + final tokenColorsRaw = json['tokenColors']; + final rules = []; + if (tokenColorsRaw is List) { + for (final item in tokenColorsRaw) { + if (item is Map) { + final rule = TokenColorRule.tryParse(item); + if (rule != null) rules.add(rule); + } + } + } + + return QueryaThemeManifest( + schema: schema, + id: id, + name: name, + type: type, + shadcnColors: shadcnColors, + editorColors: editorColors, + tokenColors: List.unmodifiable(rules), + description: _optionalString(json['description']), + author: _optionalString(json['author']), + version: _optionalString(json['version']), + ); + } + + static String _requiredString(Map json, String key) { + if (!json.containsKey(key)) { + throw QueryaThemeManifestParseException('Missing required field "$key"'); + } + final value = json[key]; + if (value is! String || value.trim().isEmpty) { + throw QueryaThemeManifestParseException('Invalid or empty "$key"'); + } + return value.trim(); + } + + static String? _optionalString(Object? value) { + if (value is! String) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + static QueryaThemeType _parseType(String raw) { + switch (raw.toLowerCase()) { + case 'dark': + return QueryaThemeType.dark; + case 'light': + return QueryaThemeType.light; + default: + throw QueryaThemeManifestParseException('Invalid type "$raw"; expected dark or light'); + } + } + + static Map _parseColorMap(Object? raw, String fieldName) { + if (raw == null) { + throw QueryaThemeManifestParseException('Missing required field "$fieldName"'); + } + if (raw is! Map) { + throw QueryaThemeManifestParseException('"$fieldName" must be a JSON object'); + } + + final colors = {}; + for (final entry in raw.entries) { + final key = entry.key?.toString(); + final value = entry.value?.toString(); + if (key != null && key.isNotEmpty && value != null && value.isNotEmpty) { + colors[key] = value; + } + } + return Map.unmodifiable(colors); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QueryaThemeManifest && + schema == other.schema && + id == other.id && + name == other.name && + type == other.type && + _mapEquals(shadcnColors, other.shadcnColors) && + _mapEquals(editorColors, other.editorColors) && + _listEquals(tokenColors, other.tokenColors) && + description == other.description && + author == other.author && + version == other.version; + + @override + int get hashCode => Object.hash( + schema, + id, + name, + type, + Object.hashAll(shadcnColors.entries), + Object.hashAll(editorColors.entries), + Object.hashAll(tokenColors), + description, + author, + version, + ); + + static bool _mapEquals(Map a, Map b) { + if (a.length != b.length) return false; + for (final entry in a.entries) { + if (b[entry.key] != entry.value) return false; + } + return true; + } + + static bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +class QueryaThemeManifestParseException implements Exception { + const QueryaThemeManifestParseException(this.message); + final String message; + + @override + String toString() => 'QueryaThemeManifestParseException: $message'; +} From 7b78bd3880f17a4844d2db806ec2e62ca6ba7cc4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:04:56 +0300 Subject: [PATCH 10/72] test(theme): add QueryaThemeManifest parser tests Cover valid fixtures, JSONC, empty color maps, validation errors, and unmodifiable maps. Closes #98. --- .../parser/querya_theme_manifest_test.dart | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 test/core/theme/parser/querya_theme_manifest_test.dart diff --git a/test/core/theme/parser/querya_theme_manifest_test.dart b/test/core/theme/parser/querya_theme_manifest_test.dart new file mode 100644 index 00000000..832b303b --- /dev/null +++ b/test/core/theme/parser/querya_theme_manifest_test.dart @@ -0,0 +1,200 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; + +void main() { + group('QueryaThemeManifest', () { + test('parses full dark fixture', () { + final raw = + File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + expect(manifest.schema, queryaThemeSchemaV1); + expect(manifest.id, 'fixture-custom-dark'); + expect(manifest.name, 'Fixture Custom Dark'); + expect(manifest.type, QueryaThemeType.dark); + expect(manifest.isDark, isTrue); + expect(manifest.shadcnColors['primary'], '#38BDF8'); + expect(manifest.editorColors['background'], '#0F1117'); + expect(manifest.tokenColors.length, 3); + expect(manifest.description, isNotNull); + expect(manifest.author, 'QueryaHub'); + expect(manifest.version, '1.0.0'); + }); + + test('parses full light fixture', () { + final raw = + File('test/fixtures/themes/querya_custom_light.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + expect(manifest.type, QueryaThemeType.light); + expect(manifest.isLight, isTrue); + expect(manifest.shadcnColors['background'], '#F8FAFC'); + expect(manifest.editorColors['foreground'], '#1E293B'); + expect(manifest.tokenColors.length, 3); + expect( + manifest.tokenColors.last.scopes, + ['string'], + ); + }); + + test('parses minimal fixture with sparse colors', () { + final raw = + File('test/fixtures/themes/querya_custom_minimal.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + expect(manifest.id, 'fixture-custom-minimal'); + expect(manifest.shadcnColors, {'primary': '#FF00AA'}); + expect(manifest.editorColors, {'background': '#010203'}); + expect(manifest.tokenColors, isEmpty); + expect(manifest.description, isNull); + }); + + test('parses JSONC fixture with comments and trailing commas', () { + final raw = + File('test/fixtures/themes/querya_custom_jsonc.jsonc').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + expect(manifest.id, 'fixture-custom-jsonc'); + expect(manifest.type, QueryaThemeType.light); + expect(manifest.shadcnColors['primary'], '#0EA5E9'); + expect(manifest.editorColors['foreground'], '#111827'); + expect(manifest.tokenColors.single.foreground, '#6B7280'); + }); + + test('accepts empty shadcn_colors and editor_colors objects', () { + const src = ''' +{ + "schema": "querya.theme.v1", + "id": "empty-maps", + "name": "Empty Maps", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {} +} +'''; + final manifest = QueryaThemeManifest.fromJsonString(src); + + expect(manifest.shadcnColors, isEmpty); + expect(manifest.editorColors, isEmpty); + }); + + test('returns unmodifiable color maps', () { + final raw = + File('test/fixtures/themes/querya_custom_minimal.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + expect( + () => manifest.shadcnColors['new'] = '#000000', + throwsA(isA()), + ); + expect( + () => manifest.editorColors['new'] = '#000000', + throwsA(isA()), + ); + }); + + test('ignores unknown root fields', () { + const src = ''' +{ + "schema": "querya.theme.v1", + "id": "with-unknown", + "name": "Unknown Fields", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {}, + "futureField": true +} +'''; + final manifest = QueryaThemeManifest.fromJsonString(src); + expect(manifest.id, 'with-unknown'); + }); + + test('throws when id is missing', () { + final raw = File('test/fixtures/themes/querya_custom_invalid_missing_id.json') + .readAsStringSync(); + + expect( + () => QueryaThemeManifest.fromJsonString(raw), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('id'), + ), + ), + ); + }); + + test('throws on invalid type', () { + const src = ''' +{ + "schema": "querya.theme.v1", + "id": "bad-type", + "name": "Bad Type", + "type": "neon", + "shadcn_colors": {}, + "editor_colors": {} +} +'''; + expect( + () => QueryaThemeManifest.fromJsonString(src), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid type'), + ), + ), + ); + }); + + test('throws on unsupported schema', () { + const src = ''' +{ + "schema": "querya.theme.v2", + "id": "future", + "name": "Future", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {} +} +'''; + expect( + () => QueryaThemeManifest.fromJsonString(src), + throwsA(isA()), + ); + }); + + test('throws on invalid JSON', () { + expect( + () => QueryaThemeManifest.fromJsonString('{ not json }'), + throwsA(isA()), + ); + }); + + test('reuses TokenColorRule parsing from VS Code themes', () { + const src = ''' +{ + "schema": "querya.theme.v1", + "id": "tokens", + "name": "Tokens", + "type": "dark", + "shadcn_colors": {}, + "editor_colors": {}, + "tokenColors": [ + { + "scope": ["keyword", "storage.type"], + "settings": { "foreground": "#569CD6", "fontStyle": "italic" } + } + ] +} +'''; + final manifest = QueryaThemeManifest.fromJsonString(src); + expect(manifest.tokenColors.single, isA()); + expect(manifest.tokenColors.single.scopes, ['keyword', 'storage.type']); + }); + }); +} From 60581ec6f271d14d1a8722dbb512fe8d1ee8d8d1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:08:56 +0300 Subject: [PATCH 11/72] fix(theme): satisfy prefer_const_constructors in manifest parser Use const for the static QueryaThemeManifestParseException message. --- lib/core/theme/parser/querya_theme_manifest.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/core/theme/parser/querya_theme_manifest.dart b/lib/core/theme/parser/querya_theme_manifest.dart index 1e4c1480..becf6bc0 100644 --- a/lib/core/theme/parser/querya_theme_manifest.dart +++ b/lib/core/theme/parser/querya_theme_manifest.dart @@ -50,7 +50,9 @@ class QueryaThemeManifest { ); } if (decoded is! Map) { - throw QueryaThemeManifestParseException('Theme root must be a JSON object'); + throw const QueryaThemeManifestParseException( + 'Theme root must be a JSON object', + ); } return QueryaThemeManifest.fromJson(decoded); } From 211a3b19e1cf6af40d6cd7cab57a5a3d2d158fab Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:11:01 +0300 Subject: [PATCH 12/72] feat(theme): add parseQueryaThemeColor wrapper Normalize Querya custom hex strings and delegate 3/4/6-digit values to parseVsCodeColor; parse 8-digit AARRGGBB alpha-first colors directly. --- lib/core/theme/parser/color_parser.dart | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/lib/core/theme/parser/color_parser.dart b/lib/core/theme/parser/color_parser.dart index b1c7cdc4..38aa0c9d 100644 --- a/lib/core/theme/parser/color_parser.dart +++ b/lib/core/theme/parser/color_parser.dart @@ -33,6 +33,41 @@ Color parseVsCodeColor(String input) { throw FormatException('Unsupported color format: $input'); } +/// Parses Querya custom theme hex strings into Flutter [Color]. +/// +/// Accepts `#RRGGBB`, `RRGGBB`, `#AARRGGBB`, and `AARRGGBB`. Shorthand `#RGB` / +/// `#RGBA` and 6-digit values delegate to [parseVsCodeColor]. Eight-digit values +/// use alpha-first `AARRGGBB` (Querya custom), not VS Code `RRGGBBAA`. +Color parseQueryaThemeColor(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + throw FormatException('Invalid Querya theme color: $raw'); + } + + var s = trimmed; + if (s.startsWith('#')) { + s = s.substring(1); + } + + if (s.length == 8) { + try { + final aa = int.parse(s.substring(0, 2), radix: 16); + final rr = int.parse(s.substring(2, 4), radix: 16); + final gg = int.parse(s.substring(4, 6), radix: 16); + final bb = int.parse(s.substring(6, 8), radix: 16); + return Color.fromARGB(aa, rr, gg, bb); + } on FormatException { + throw FormatException('Invalid Querya theme color: $raw'); + } + } + + try { + return parseVsCodeColor(trimmed.startsWith('#') ? trimmed : '#$s'); + } on FormatException { + throw FormatException('Invalid Querya theme color: $raw'); + } +} + /// Encodes a [Color] as a VS Code hex string (`#RRGGBB` or `#RRGGBBAA`). String formatVsCodeColor(Color color) { String channel(double component) => From 88183ed72604d6648a7ee7046eb360e2f2bd53d4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:11:01 +0300 Subject: [PATCH 13/72] test(theme): cover parseQueryaThemeColor formats and errors Closes #99. --- test/core/theme/parser/color_parser_test.dart | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/test/core/theme/parser/color_parser_test.dart b/test/core/theme/parser/color_parser_test.dart index f928edbc..d7b256f8 100644 --- a/test/core/theme/parser/color_parser_test.dart +++ b/test/core/theme/parser/color_parser_test.dart @@ -2,7 +2,7 @@ import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/theme/parser/color_parser.dart' - show formatVsCodeColor, parseVsCodeColor; + show formatVsCodeColor, parseQueryaThemeColor, parseVsCodeColor; void main() { group('parseVsCodeColor', () { @@ -34,4 +34,70 @@ void main() { expect(parseVsCodeColor(formatVsCodeColor(c)), c); }); }); + + group('parseQueryaThemeColor', () { + test('parses hash-prefixed RRGGBB', () { + expect(parseQueryaThemeColor('#1E1E1E'), const Color(0xFF1E1E1E)); + }); + + test('parses bare RRGGBB', () { + expect(parseQueryaThemeColor('1E1E1E'), const Color(0xFF1E1E1E)); + }); + + test('parses hash-prefixed AARRGGBB', () { + expect(parseQueryaThemeColor('#801E1E1E'), const Color(0x801E1E1E)); + }); + + test('parses bare AARRGGBB', () { + expect(parseQueryaThemeColor('FF1E1E1E'), const Color(0xFF1E1E1E)); + }); + + test('accepts lowercase hex', () { + expect(parseQueryaThemeColor('#ff1e1e1e'), const Color(0xFF1E1E1E)); + expect(parseQueryaThemeColor('1e1e1e'), const Color(0xFF1E1E1E)); + }); + + test('delegates shorthand RGB to parseVsCodeColor', () { + expect(parseQueryaThemeColor('#abc'), const Color(0xFFAABBCC)); + }); + + test('invalid length mentions value', () { + expect( + () => parseQueryaThemeColor('12345'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Invalid Querya theme color: 12345', + ), + ), + ); + }); + + test('invalid characters mention value', () { + expect( + () => parseQueryaThemeColor('GGHHII'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Invalid Querya theme color: GGHHII', + ), + ), + ); + }); + + test('empty string mentions value', () { + expect( + () => parseQueryaThemeColor(' '), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Invalid Querya theme color: ', + ), + ), + ); + }); + }); } From aad0a06f135a6dd43c8deeb0985a29cdb5dc8ad1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:12:56 +0300 Subject: [PATCH 14/72] feat(theme): map shadcn_colors to ColorScheme Add colorSchemeFromQueryaThemeColors with parseQueryaThemeColor parsing, fallback to QueryaTheme.colorScheme, and debug logs for unknown/invalid keys. --- .../parser/querya_theme_color_scheme.dart | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 lib/core/theme/parser/querya_theme_color_scheme.dart diff --git a/lib/core/theme/parser/querya_theme_color_scheme.dart b/lib/core/theme/parser/querya_theme_color_scheme.dart new file mode 100644 index 00000000..334f028c --- /dev/null +++ b/lib/core/theme/parser/querya_theme_color_scheme.dart @@ -0,0 +1,93 @@ +import 'package:flutter/foundation.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../querya_theme.dart'; +import 'color_parser.dart'; + +const _knownShadcnColorKeys = { + 'background', + 'foreground', + 'card', + 'cardForeground', + 'popover', + 'popoverForeground', + 'primary', + 'primaryForeground', + 'secondary', + 'secondaryForeground', + 'muted', + 'mutedForeground', + 'accent', + 'accentForeground', + 'destructive', + 'destructiveForeground', + 'border', + 'input', + 'ring', + 'chart1', + 'chart2', + 'chart3', + 'chart4', + 'chart5', +}; + +/// Builds a shadcn [ColorScheme] from Querya custom `shadcn_colors`. +/// +/// Missing keys and invalid optional colors fall back to [fallback.colorScheme]. +/// [ColorScheme.brightness] always comes from [fallback], not from [colors]. +ColorScheme colorSchemeFromQueryaThemeColors({ + required Map colors, + required QueryaTheme fallback, +}) { + final base = fallback.colorScheme; + + if (kDebugMode) { + for (final key in colors.keys) { + if (!_knownShadcnColorKeys.contains(key)) { + debugPrint('Querya theme: ignored shadcn_colors key "$key"'); + } + } + } + + Color pick(String key, Color defaultValue) { + final raw = colors[key]; + if (raw == null) return defaultValue; + try { + return parseQueryaThemeColor(raw); + } on FormatException { + if (kDebugMode) { + debugPrint('Querya theme: invalid shadcn_colors."$key": $raw'); + } + return defaultValue; + } + } + + return ColorScheme( + brightness: base.brightness, + background: pick('background', base.background), + foreground: pick('foreground', base.foreground), + card: pick('card', base.card), + cardForeground: pick('cardForeground', base.cardForeground), + popover: pick('popover', base.popover), + popoverForeground: pick('popoverForeground', base.popoverForeground), + primary: pick('primary', base.primary), + primaryForeground: pick('primaryForeground', base.primaryForeground), + secondary: pick('secondary', base.secondary), + secondaryForeground: pick('secondaryForeground', base.secondaryForeground), + muted: pick('muted', base.muted), + mutedForeground: pick('mutedForeground', base.mutedForeground), + accent: pick('accent', base.accent), + accentForeground: pick('accentForeground', base.accentForeground), + destructive: pick('destructive', base.destructive), + destructiveForeground: + pick('destructiveForeground', base.destructiveForeground), + border: pick('border', base.border), + input: pick('input', base.input), + ring: pick('ring', base.ring), + chart1: pick('chart1', base.chart1), + chart2: pick('chart2', base.chart2), + chart3: pick('chart3', base.chart3), + chart4: pick('chart4', base.chart4), + chart5: pick('chart5', base.chart5), + ); +} From b3c269fa0fead4406f7e96f1b77752815982bb41 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:12:56 +0300 Subject: [PATCH 15/72] test(theme): cover shadcn_colors ColorScheme mapping Closes #100. --- .../querya_theme_color_scheme_test.dart | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 test/core/theme/parser/querya_theme_color_scheme_test.dart diff --git a/test/core/theme/parser/querya_theme_color_scheme_test.dart b/test/core/theme/parser/querya_theme_color_scheme_test.dart new file mode 100644 index 00000000..49b0af75 --- /dev/null +++ b/test/core/theme/parser/querya_theme_color_scheme_test.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_color_scheme.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + group('colorSchemeFromQueryaThemeColors', () { + test('full fixture maps custom shadcn values', () { + final raw = + File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + final scheme = colorSchemeFromQueryaThemeColors( + colors: manifest.shadcnColors, + fallback: QueryaTheme.darkDefault, + ); + + expect(scheme.brightness, Brightness.dark); + expect(scheme.background, parseQueryaThemeColor('#101014')); + expect(scheme.foreground, parseQueryaThemeColor('#E2E8F0')); + expect(scheme.primary, parseQueryaThemeColor('#38BDF8')); + expect(scheme.accent, parseQueryaThemeColor('#6366F1')); + expect(scheme.chart3, parseQueryaThemeColor('#F472B6')); + expect(scheme.chart5, parseQueryaThemeColor('#34D399')); + }); + + test('missing keys preserve fallback colorScheme values', () { + final scheme = colorSchemeFromQueryaThemeColors( + colors: const {'primary': '#FF00AA'}, + fallback: QueryaTheme.darkDefault, + ); + final fallback = QueryaTheme.darkDefault.colorScheme; + + expect(scheme.primary, parseQueryaThemeColor('#FF00AA')); + expect(scheme.background, fallback.background); + expect(scheme.foreground, fallback.foreground); + expect(scheme.border, fallback.border); + expect(scheme.chart1, fallback.chart1); + }); + + test('invalid optional color uses fallback value', () { + final fallback = QueryaTheme.darkDefault.colorScheme; + final scheme = colorSchemeFromQueryaThemeColors( + colors: const { + 'primary': 'not-a-color', + 'background': '#101014', + }, + fallback: QueryaTheme.darkDefault, + ); + + expect(scheme.primary, fallback.primary); + expect(scheme.background, parseQueryaThemeColor('#101014')); + }); + + test('chart colors fall back when omitted', () { + final fallback = QueryaTheme.lightDefault.colorScheme; + final scheme = colorSchemeFromQueryaThemeColors( + colors: const {'background': '#FFFFFF'}, + fallback: QueryaTheme.lightDefault, + ); + + expect(scheme.background, parseQueryaThemeColor('#FFFFFF')); + expect(scheme.chart1, fallback.chart1); + expect(scheme.chart2, fallback.chart2); + expect(scheme.chart3, fallback.chart3); + expect(scheme.chart4, fallback.chart4); + expect(scheme.chart5, fallback.chart5); + }); + + test('brightness comes from fallback theme, not colors map', () { + final scheme = colorSchemeFromQueryaThemeColors( + colors: const { + 'background': '#101014', + 'foreground': '#E2E8F0', + }, + fallback: QueryaTheme.lightDefault, + ); + + expect(scheme.brightness, Brightness.light); + expect(scheme.background, parseQueryaThemeColor('#101014')); + }); + }); +} From 34e8368a1b3089d21e55346b2c421e254f541c14 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:16:51 +0300 Subject: [PATCH 16/72] fix(theme): silence deprecated destructiveForeground lint Shadcn still exposes the legacy field; keep mapping querya.theme.v1 key with targeted ignore comments for read and constructor use. --- lib/core/theme/parser/querya_theme_color_scheme.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/core/theme/parser/querya_theme_color_scheme.dart b/lib/core/theme/parser/querya_theme_color_scheme.dart index 334f028c..543c2cf3 100644 --- a/lib/core/theme/parser/querya_theme_color_scheme.dart +++ b/lib/core/theme/parser/querya_theme_color_scheme.dart @@ -62,6 +62,12 @@ ColorScheme colorSchemeFromQueryaThemeColors({ } } + final destructive = pick('destructive', base.destructive); + // querya.theme.v1 still maps this key; shadcn marks the ColorScheme field legacy. + // ignore: deprecated_member_use + final destructiveForeground = + pick('destructiveForeground', base.destructiveForeground); + return ColorScheme( brightness: base.brightness, background: pick('background', base.background), @@ -78,9 +84,9 @@ ColorScheme colorSchemeFromQueryaThemeColors({ mutedForeground: pick('mutedForeground', base.mutedForeground), accent: pick('accent', base.accent), accentForeground: pick('accentForeground', base.accentForeground), - destructive: pick('destructive', base.destructive), - destructiveForeground: - pick('destructiveForeground', base.destructiveForeground), + destructive: destructive, + // ignore: deprecated_member_use + destructiveForeground: destructiveForeground, border: pick('border', base.border), input: pick('input', base.input), ring: pick('ring', base.ring), From dc8dd89084a3f8bf5a7a59774750acb4511b02f3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:22:00 +0300 Subject: [PATCH 17/72] fix(theme): place deprecated_member_use ignore on correct line Dart ignore comments only apply to the immediately following line. --- lib/core/theme/parser/querya_theme_color_scheme.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/core/theme/parser/querya_theme_color_scheme.dart b/lib/core/theme/parser/querya_theme_color_scheme.dart index 543c2cf3..026f191c 100644 --- a/lib/core/theme/parser/querya_theme_color_scheme.dart +++ b/lib/core/theme/parser/querya_theme_color_scheme.dart @@ -65,8 +65,7 @@ ColorScheme colorSchemeFromQueryaThemeColors({ final destructive = pick('destructive', base.destructive); // querya.theme.v1 still maps this key; shadcn marks the ColorScheme field legacy. // ignore: deprecated_member_use - final destructiveForeground = - pick('destructiveForeground', base.destructiveForeground); + final destructiveForeground = pick('destructiveForeground', base.destructiveForeground); return ColorScheme( brightness: base.brightness, From f46b336e04f8d7a2e737a6241d0e00ba6aa9d6aa Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:23:55 +0300 Subject: [PATCH 18/72] feat(theme): map editor_colors to QueryaEditorTheme Add editorThemeFromQueryaColors with parseQueryaThemeColor parsing and fallback to the preset editor theme; ignore workbench keys for TP-07. --- .../querya_editor_theme_from_manifest.dart | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 lib/core/theme/parser/querya_editor_theme_from_manifest.dart diff --git a/lib/core/theme/parser/querya_editor_theme_from_manifest.dart b/lib/core/theme/parser/querya_editor_theme_from_manifest.dart new file mode 100644 index 00000000..c91e13c0 --- /dev/null +++ b/lib/core/theme/parser/querya_editor_theme_from_manifest.dart @@ -0,0 +1,83 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +import '../querya_editor_theme.dart'; +import 'color_parser.dart'; + +const _knownEditorColorKeys = { + 'background', + 'foreground', + 'lineHighlight', + 'selection', + 'lineNumber', + 'bracketMatch', + 'widgetBorder', + 'comment', + 'keyword', + 'string', + 'number', + 'operator', + 'function', + 'type', +}; + +/// Builds [QueryaEditorTheme] from Querya custom `editor_colors`. +/// +/// Missing keys and invalid optional colors fall back to [fallback]. +/// Workbench-related keys in the same map are ignored here (handled separately). +QueryaEditorTheme editorThemeFromQueryaColors({ + required Map colors, + required QueryaEditorTheme fallback, +}) { + if (kDebugMode) { + for (final key in colors.keys) { + if (!_knownEditorColorKeys.contains(key)) { + debugPrint('Querya theme: ignored editor_colors key "$key"'); + } + } + } + + Color pick(String key, Color defaultValue) { + final raw = colors[key]; + if (raw == null) return defaultValue; + try { + return parseQueryaThemeColor(raw); + } on FormatException { + if (kDebugMode) { + debugPrint('Querya theme: invalid editor_colors."$key": $raw'); + } + return defaultValue; + } + } + + Color? pickOptional(String key, Color? defaultValue) { + final raw = colors[key]; + if (raw == null) return defaultValue; + try { + return parseQueryaThemeColor(raw); + } on FormatException { + if (kDebugMode) { + debugPrint('Querya theme: invalid editor_colors."$key": $raw'); + } + return defaultValue; + } + } + + return fallback.copyWith( + background: pick('background', fallback.background), + foreground: pick('foreground', fallback.foreground), + lineHighlight: pick('lineHighlight', fallback.lineHighlight), + selection: pick('selection', fallback.selection), + lineNumber: pick('lineNumber', fallback.lineNumber), + bracketMatch: pick('bracketMatch', fallback.bracketMatch), + widgetBorder: pickOptional('widgetBorder', fallback.widgetBorder), + comment: pick('comment', fallback.comment), + keyword: pick('keyword', fallback.keyword), + string: pick('string', fallback.string), + number: pick('number', fallback.number), + operator: pick('operator', fallback.operator), + function: pick('function', fallback.function), + type: pick('type', fallback.type), + ); +} From 1920e2b60e1de671e5e2bd8e118433877ed0ab97 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:23:55 +0300 Subject: [PATCH 19/72] test(theme): cover editor_colors QueryaEditorTheme mapping Closes #101. --- ...uerya_editor_theme_from_manifest_test.dart | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 test/core/theme/parser/querya_editor_theme_from_manifest_test.dart diff --git a/test/core/theme/parser/querya_editor_theme_from_manifest_test.dart b/test/core/theme/parser/querya_editor_theme_from_manifest_test.dart new file mode 100644 index 00000000..acee07e5 --- /dev/null +++ b/test/core/theme/parser/querya_editor_theme_from_manifest_test.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/querya_editor_theme_from_manifest.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; + +void main() { + group('editorThemeFromQueryaColors', () { + test('full fixture maps custom editor values', () { + final raw = + File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + final editor = editorThemeFromQueryaColors( + colors: manifest.editorColors, + fallback: QueryaTheme.darkDefault.editor, + ); + + expect(editor.background, parseQueryaThemeColor('#0F1117')); + expect(editor.foreground, parseQueryaThemeColor('#E2E8F0')); + expect(editor.selection, parseQueryaThemeColor('#264F78')); + expect(editor.lineNumber, parseQueryaThemeColor('#64748B')); + expect(editor.widgetBorder, parseQueryaThemeColor('#38BDF866')); + expect(editor.keyword, parseQueryaThemeColor('#569CD6')); + }); + + test('minimal fixture falls back for missing editor fields', () { + final raw = File('test/fixtures/themes/querya_custom_minimal.json') + .readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + final fallback = QueryaTheme.darkDefault.editor; + final editor = editorThemeFromQueryaColors( + colors: manifest.editorColors, + fallback: fallback, + ); + + expect(editor.background, parseQueryaThemeColor('#010203')); + expect(editor.foreground, fallback.foreground); + expect(editor.selection, fallback.selection); + expect(editor.comment, fallback.comment); + expect(editor.widgetBorder, fallback.widgetBorder); + }); + + test('invalid optional color uses fallback value', () { + final fallback = QueryaTheme.darkDefault.editor; + final editor = editorThemeFromQueryaColors( + colors: const { + 'background': '#0F1117', + 'selection': 'bad-color', + 'keyword': 'ZZZZZZ', + }, + fallback: fallback, + ); + + expect(editor.background, parseQueryaThemeColor('#0F1117')); + expect(editor.selection, fallback.selection); + expect(editor.keyword, fallback.keyword); + }); + }); +} From 2ceeeb8afacc9f79954a388befefed5c57586924 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:26:34 +0300 Subject: [PATCH 20/72] feat(theme): map editor_colors to QueryaWorkbenchTheme Add workbenchThemeFromQueryaColors for chrome/git tokens in editor_colors; ignore editor syntax keys and do not auto-map background to canvas. --- .../querya_workbench_theme_from_manifest.dart | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 lib/core/theme/parser/querya_workbench_theme_from_manifest.dart diff --git a/lib/core/theme/parser/querya_workbench_theme_from_manifest.dart b/lib/core/theme/parser/querya_workbench_theme_from_manifest.dart new file mode 100644 index 00000000..d1f99b92 --- /dev/null +++ b/lib/core/theme/parser/querya_workbench_theme_from_manifest.dart @@ -0,0 +1,71 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +import '../querya_workbench_theme.dart'; +import 'color_parser.dart'; + +/// Workbench keys accepted in Querya custom `editor_colors`. +/// +/// Editor syntax/surface keys are mapped separately by [editorThemeFromQueryaColors]. +/// The generic `background` key is intentionally not mapped to canvas/editorBackground. +const _knownWorkbenchColorKeys = { + 'canvas', + 'surface', + 'sidebarBackground', + 'editorBackground', + 'mutedForeground', + 'accent', + 'onAccent', + 'borderSubtle', + 'destructive', + 'success', + 'warning', + 'gitModified', + 'gitUntracked', +}; + +/// Builds [QueryaWorkbenchTheme] from Querya custom `editor_colors`. +/// +/// Missing keys and invalid optional colors fall back to [fallback]. +QueryaWorkbenchTheme workbenchThemeFromQueryaColors({ + required Map colors, + required QueryaWorkbenchTheme fallback, +}) { + if (kDebugMode) { + for (final key in colors.keys) { + if (!_knownWorkbenchColorKeys.contains(key)) { + debugPrint('Querya theme: ignored editor_colors workbench key "$key"'); + } + } + } + + Color pick(String key, Color defaultValue) { + final raw = colors[key]; + if (raw == null) return defaultValue; + try { + return parseQueryaThemeColor(raw); + } on FormatException { + if (kDebugMode) { + debugPrint('Querya theme: invalid editor_colors."$key": $raw'); + } + return defaultValue; + } + } + + return fallback.copyWith( + canvas: pick('canvas', fallback.canvas), + surface: pick('surface', fallback.surface), + sidebarBackground: pick('sidebarBackground', fallback.sidebarBackground), + editorBackground: pick('editorBackground', fallback.editorBackground), + mutedForeground: pick('mutedForeground', fallback.mutedForeground), + accent: pick('accent', fallback.accent), + onAccent: pick('onAccent', fallback.onAccent), + borderSubtle: pick('borderSubtle', fallback.borderSubtle), + destructive: pick('destructive', fallback.destructive), + success: pick('success', fallback.success), + warning: pick('warning', fallback.warning), + gitModified: pick('gitModified', fallback.gitModified), + gitUntracked: pick('gitUntracked', fallback.gitUntracked), + ); +} From 2c7e6865c19ded36b88753a6a3708478b841cd23 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:26:34 +0300 Subject: [PATCH 21/72] test(theme): cover editor_colors QueryaWorkbenchTheme mapping Closes #102. --- ...ya_workbench_theme_from_manifest_test.dart | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 test/core/theme/parser/querya_workbench_theme_from_manifest_test.dart diff --git a/test/core/theme/parser/querya_workbench_theme_from_manifest_test.dart b/test/core/theme/parser/querya_workbench_theme_from_manifest_test.dart new file mode 100644 index 00000000..8b740293 --- /dev/null +++ b/test/core/theme/parser/querya_workbench_theme_from_manifest_test.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/parser/querya_workbench_theme_from_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; + +void main() { + group('workbenchThemeFromQueryaColors', () { + test('full fixture maps custom workbench values', () { + final raw = + File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + final workbench = workbenchThemeFromQueryaColors( + colors: manifest.editorColors, + fallback: QueryaTheme.darkDefault.workbench, + ); + + expect(workbench.canvas, parseQueryaThemeColor('#09090B')); + expect(workbench.surface, parseQueryaThemeColor('#111827')); + expect(workbench.sidebarBackground, parseQueryaThemeColor('#0B0F19')); + expect(workbench.editorBackground, parseQueryaThemeColor('#0F1117')); + expect(workbench.accent, parseQueryaThemeColor('#38BDF8')); + expect(workbench.gitModified, parseQueryaThemeColor('#FBBF24')); + expect(workbench.gitUntracked, parseQueryaThemeColor('#34D399')); + }); + + test('minimal fixture falls back for missing workbench fields', () { + final raw = File('test/fixtures/themes/querya_custom_minimal.json') + .readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + final fallback = QueryaTheme.darkDefault.workbench; + final workbench = workbenchThemeFromQueryaColors( + colors: manifest.editorColors, + fallback: fallback, + ); + + expect(workbench.canvas, fallback.canvas); + expect(workbench.surface, fallback.surface); + expect(workbench.accent, fallback.accent); + expect(workbench.gitModified, fallback.gitModified); + }); + + test('invalid optional color uses fallback value', () { + final fallback = QueryaTheme.darkDefault.workbench; + final workbench = workbenchThemeFromQueryaColors( + colors: const { + 'canvas': '#09090B', + 'accent': 'not-a-color', + 'gitModified': 'ZZZZZZ', + }, + fallback: fallback, + ); + + expect(workbench.canvas, parseQueryaThemeColor('#09090B')); + expect(workbench.accent, fallback.accent); + expect(workbench.gitModified, fallback.gitModified); + }); + + test('does not map editor background key to workbench canvas', () { + final fallback = QueryaTheme.darkDefault.workbench; + final workbench = workbenchThemeFromQueryaColors( + colors: const {'background': '#010203'}, + fallback: fallback, + ); + + expect(workbench.canvas, fallback.canvas); + expect(workbench.editorBackground, fallback.editorBackground); + }); + }); +} From 99b2de526956a8a5cf31c03e38e5e3aec1a72201 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:28:43 +0300 Subject: [PATCH 22/72] feat(theme): build QueryaTheme from custom manifest Wire shadcn/editor/workbench mappers into queryaThemeFromManifest, apply tokenColors to the editor, and preserve manifest syntax rules on QueryaTheme. --- .../parser/querya_theme_from_manifest.dart | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 lib/core/theme/parser/querya_theme_from_manifest.dart diff --git a/lib/core/theme/parser/querya_theme_from_manifest.dart b/lib/core/theme/parser/querya_theme_from_manifest.dart new file mode 100644 index 00000000..7142b5a3 --- /dev/null +++ b/lib/core/theme/parser/querya_theme_from_manifest.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../querya_theme.dart'; +import 'apply_token_colors_to_editor.dart'; +import 'querya_editor_theme_from_manifest.dart'; +import 'querya_theme_color_scheme.dart'; +import 'querya_theme_manifest.dart'; +import 'querya_workbench_theme_from_manifest.dart'; + +/// Builds a [QueryaTheme] from a parsed Querya custom theme manifest. +QueryaTheme queryaThemeFromManifest(QueryaThemeManifest manifest) { + final fallback = + manifest.isLight ? QueryaTheme.lightDefault : QueryaTheme.darkDefault; + final brightness = + manifest.isLight ? Brightness.light : Brightness.dark; + + var editor = editorThemeFromQueryaColors( + colors: manifest.editorColors, + fallback: fallback.editor, + ); + final workbench = workbenchThemeFromQueryaColors( + colors: manifest.editorColors, + fallback: fallback.workbench, + ); + + if (manifest.editorColors.containsKey('editorBackground') && + editor.background != workbench.editorBackground) { + editor = editor.copyWith(background: workbench.editorBackground); + } + + final tokenColors = manifest.tokenColors; + if (tokenColors.isNotEmpty) { + editor = applyTokenColorsToEditor(editor, tokenColors); + } + + final colorScheme = colorSchemeFromQueryaThemeColors( + colors: manifest.shadcnColors, + fallback: fallback, + ); + + return fallback.copyWith( + workbench: workbench, + editor: editor, + brightness: brightness, + colorScheme: colorScheme, + tokenColors: tokenColors, + ); +} From 6777eac56e9d28cedc3a67593ef9f89e9a235c2d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:28:43 +0300 Subject: [PATCH 23/72] test(theme): cover QueryaTheme factory from custom manifest Closes #103. --- .../querya_theme_from_manifest_test.dart | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 test/core/theme/parser/querya_theme_from_manifest_test.dart diff --git a/test/core/theme/parser/querya_theme_from_manifest_test.dart b/test/core/theme/parser/querya_theme_from_manifest_test.dart new file mode 100644 index 00000000..095413e9 --- /dev/null +++ b/test/core/theme/parser/querya_theme_from_manifest_test.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/color_parser.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_from_manifest.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; + +void main() { + group('queryaThemeFromManifest', () { + test('full dark fixture builds dark QueryaTheme', () { + final raw = + File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + final theme = queryaThemeFromManifest(manifest); + + expect(theme.brightness, Brightness.dark); + expect(theme.colorScheme.primary, parseQueryaThemeColor('#38BDF8')); + expect(theme.workbench.canvas, parseQueryaThemeColor('#09090B')); + expect(theme.editor.background, parseQueryaThemeColor('#0F1117')); + expect(theme.editor.selection, parseQueryaThemeColor('#264F78')); + }); + + test('full light fixture builds light QueryaTheme', () { + final raw = + File('test/fixtures/themes/querya_custom_light.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + final theme = queryaThemeFromManifest(manifest); + + expect(theme.brightness, Brightness.light); + expect(theme.colorScheme.background, parseQueryaThemeColor('#F8FAFC')); + expect(theme.workbench.surface, parseQueryaThemeColor('#FFFFFF')); + expect(theme.editor.foreground, parseQueryaThemeColor('#1E293B')); + }); + + test('preserves tokenColors from manifest', () { + final raw = + File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + final theme = queryaThemeFromManifest(manifest); + + expect(theme.tokenColors.length, manifest.tokenColors.length); + expect(theme.tokenColors.first.scopes, ['comment', 'comment.line']); + }); + + test('minimal fixture falls back to preset defaults', () { + final raw = File('test/fixtures/themes/querya_custom_minimal.json') + .readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + final theme = queryaThemeFromManifest(manifest); + final fallback = QueryaTheme.darkDefault; + + expect(theme.brightness, Brightness.dark); + expect(theme.colorScheme.primary, parseQueryaThemeColor('#FF00AA')); + expect(theme.editor.background, parseQueryaThemeColor('#010203')); + expect(theme.editor.foreground, fallback.editor.foreground); + expect(theme.workbench.canvas, fallback.workbench.canvas); + expect(theme.tokenColors, isEmpty); + }); + + test('does not create ThemeData', () { + final raw = + File('test/fixtures/themes/querya_custom_dark.json').readAsStringSync(); + final manifest = QueryaThemeManifest.fromJsonString(raw); + + expect(queryaThemeFromManifest(manifest), isA()); + }); + }); +} From d1cff84eccb1f27b33d3fb0aecf3002008f8fc42 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:30:35 +0300 Subject: [PATCH 24/72] fix(theme): use const fallback in manifest factory test Satisfy prefer_const_declarations analyzer hint. --- test/core/theme/parser/querya_theme_from_manifest_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/core/theme/parser/querya_theme_from_manifest_test.dart b/test/core/theme/parser/querya_theme_from_manifest_test.dart index 095413e9..e6e1c814 100644 --- a/test/core/theme/parser/querya_theme_from_manifest_test.dart +++ b/test/core/theme/parser/querya_theme_from_manifest_test.dart @@ -49,7 +49,7 @@ void main() { .readAsStringSync(); final manifest = QueryaThemeManifest.fromJsonString(raw); final theme = queryaThemeFromManifest(manifest); - final fallback = QueryaTheme.darkDefault; + const fallback = QueryaTheme.darkDefault; expect(theme.brightness, Brightness.dark); expect(theme.colorScheme.primary, parseQueryaThemeColor('#FF00AA')); From b18aaca8a5a56a79e67909d555bbf129fcca2fd9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:33:09 +0300 Subject: [PATCH 25/72] feat(theme): add ThemeDefinition metadata model Lightweight theme descriptor required by ThemeLoadResult and upcoming registry APIs (#105 will add dedicated tests). --- lib/core/theme/theme_definition.dart | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 lib/core/theme/theme_definition.dart diff --git a/lib/core/theme/theme_definition.dart b/lib/core/theme/theme_definition.dart new file mode 100644 index 00000000..57fd507a --- /dev/null +++ b/lib/core/theme/theme_definition.dart @@ -0,0 +1,42 @@ +enum ThemeSource { + builtin, + imported, + filesystem, + legacyImported, +} + +enum ThemeFormat { + queryaCustom, + vscode, +} + +/// Lightweight theme metadata for registry lists and load results. +class ThemeDefinition { + const ThemeDefinition({ + required this.id, + required this.name, + required this.source, + required this.format, + required this.isDark, + this.path, + this.lastModified, + this.contentHash, + }); + + final String id; + final String name; + final ThemeSource source; + final ThemeFormat format; + final bool isDark; + final String? path; + final DateTime? lastModified; + final String? contentHash; + + bool get isFileBacked => + source == ThemeSource.filesystem || + source == ThemeSource.imported || + source == ThemeSource.legacyImported; + + String get stableCacheKey => + '${source.name}:$id:${contentHash ?? path ?? name}'; +} From c6954f93228417e1d181bd40079b84bcf3408876 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:33:09 +0300 Subject: [PATCH 26/72] feat(theme): add ThemeLoadResult sealed load types Introduce success/failure results for theme loading without throwing through UI/startup code paths. Closes #104. --- lib/core/theme/theme_load_result.dart | 29 +++++++++++ test/core/theme/theme_load_result_test.dart | 57 +++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 lib/core/theme/theme_load_result.dart create mode 100644 test/core/theme/theme_load_result_test.dart diff --git a/lib/core/theme/theme_load_result.dart b/lib/core/theme/theme_load_result.dart new file mode 100644 index 00000000..3a722d4c --- /dev/null +++ b/lib/core/theme/theme_load_result.dart @@ -0,0 +1,29 @@ +import 'querya_theme.dart'; +import 'theme_definition.dart'; + +/// Result of loading a [ThemeDefinition] into a runtime [QueryaTheme]. +sealed class ThemeLoadResult { + const ThemeLoadResult(); +} + +class ThemeLoadSuccess extends ThemeLoadResult { + const ThemeLoadSuccess({ + required this.definition, + required this.theme, + }); + + final ThemeDefinition definition; + final QueryaTheme theme; +} + +class ThemeLoadFailure extends ThemeLoadResult { + const ThemeLoadFailure({ + required this.definition, + required this.message, + this.error, + }); + + final ThemeDefinition definition; + final String message; + final Object? error; +} diff --git a/test/core/theme/theme_load_result_test.dart b/test/core/theme/theme_load_result_test.dart new file mode 100644 index 00000000..52e7e046 --- /dev/null +++ b/test/core/theme/theme_load_result_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/theme_definition.dart'; +import 'package:querya_desktop/core/theme/theme_load_result.dart'; + +void main() { + const definition = ThemeDefinition( + id: 'fixture-custom-dark', + name: 'Fixture Custom Dark', + source: ThemeSource.filesystem, + format: ThemeFormat.queryaCustom, + isDark: true, + path: '/tmp/fixture-custom-dark.json', + contentHash: 'abc123', + ); + + group('ThemeLoadResult', () { + test('success carries definition and theme', () { + const result = ThemeLoadSuccess( + definition: definition, + theme: QueryaTheme.darkDefault, + ); + + expect(result, isA()); + expect(result.definition.id, 'fixture-custom-dark'); + expect(result.theme.brightness, QueryaTheme.darkDefault.brightness); + }); + + test('failure carries definition, message, and optional error', () { + final error = StateError('parse failed'); + final result = ThemeLoadFailure( + definition: definition, + message: 'Invalid theme file.', + error: error, + ); + + expect(result, isA()); + expect(result.definition.path, definition.path); + expect(result.message, 'Invalid theme file.'); + expect(result.error, same(error)); + }); + + test('sealed result supports pattern matching', () { + const ThemeLoadResult result = ThemeLoadSuccess( + definition: definition, + theme: QueryaTheme.lightDefault, + ); + + final matched = switch (result) { + ThemeLoadSuccess(:final theme) => theme.brightness, + ThemeLoadFailure() => null, + }; + + expect(matched, QueryaTheme.lightDefault.brightness); + }); + }); +} From a332f75f86af51962191589dbc2ddea1d3f52dda Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:35:01 +0300 Subject: [PATCH 27/72] feat(theme): add ThemeDefinition value equality Compare full metadata for registry dedupe and cache lookups. --- lib/core/theme/theme_definition.dart | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/core/theme/theme_definition.dart b/lib/core/theme/theme_definition.dart index 57fd507a..d9f5675c 100644 --- a/lib/core/theme/theme_definition.dart +++ b/lib/core/theme/theme_definition.dart @@ -39,4 +39,29 @@ class ThemeDefinition { String get stableCacheKey => '${source.name}:$id:${contentHash ?? path ?? name}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ThemeDefinition && + id == other.id && + name == other.name && + source == other.source && + format == other.format && + isDark == other.isDark && + path == other.path && + lastModified == other.lastModified && + contentHash == other.contentHash; + + @override + int get hashCode => Object.hash( + id, + name, + source, + format, + isDark, + path, + lastModified, + contentHash, + ); } From 529f9aae082b5e49c1ce043f4022e91367a4b986 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 00:35:01 +0300 Subject: [PATCH 28/72] test(theme): cover ThemeDefinition helpers and equality Closes #105. --- test/core/theme/theme_definition_test.dart | 94 ++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 test/core/theme/theme_definition_test.dart diff --git a/test/core/theme/theme_definition_test.dart b/test/core/theme/theme_definition_test.dart new file mode 100644 index 00000000..7e15bb53 --- /dev/null +++ b/test/core/theme/theme_definition_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/theme_definition.dart'; + +void main() { + group('ThemeDefinition', () { + test('isFileBacked is true for file-backed sources', () { + expect( + _definition(source: ThemeSource.filesystem).isFileBacked, + isTrue, + ); + expect( + _definition(source: ThemeSource.imported).isFileBacked, + isTrue, + ); + expect( + _definition(source: ThemeSource.legacyImported).isFileBacked, + isTrue, + ); + }); + + test('isFileBacked is false for built-in themes', () { + expect( + _definition(source: ThemeSource.builtin).isFileBacked, + isFalse, + ); + }); + + test('stableCacheKey includes source, id, and content hash', () { + final definition = _definition( + id: 'cyberpunk-neon', + source: ThemeSource.filesystem, + contentHash: 'hash-v1', + ); + + expect( + definition.stableCacheKey, + 'filesystem:cyberpunk-neon:hash-v1', + ); + }); + + test('stableCacheKey changes when contentHash changes', () { + final base = _definition(contentHash: 'hash-v1'); + final updated = _definition(contentHash: 'hash-v2'); + + expect(base.stableCacheKey, isNot(updated.stableCacheKey)); + }); + + test('stableCacheKey falls back to path then name without hash', () { + final withPath = _definition( + contentHash: null, + path: '/data/themes/custom.json', + ); + final withNameOnly = _definition( + contentHash: null, + path: null, + name: 'Querya Dark', + ); + + expect(withPath.stableCacheKey, contains('/data/themes/custom.json')); + expect(withNameOnly.stableCacheKey, endsWith('Querya Dark')); + }); + + test('value equality compares metadata fields', () { + final a = _definition(contentHash: 'hash-v1'); + final b = _definition(contentHash: 'hash-v1'); + final c = _definition(contentHash: 'hash-v2'); + + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); +} + +ThemeDefinition _definition({ + String id = 'fixture-custom-dark', + String name = 'Fixture Custom Dark', + ThemeSource source = ThemeSource.filesystem, + ThemeFormat format = ThemeFormat.queryaCustom, + bool isDark = true, + String? path = '/tmp/fixture-custom-dark.json', + DateTime? lastModified, + String? contentHash = 'abc123', +}) { + return ThemeDefinition( + id: id, + name: name, + source: source, + format: format, + isDark: isDark, + path: path, + lastModified: lastModified, + contentHash: contentHash, + ); +} From 94bad092673a856166898c1f9d4bb7a0d556502e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:04:13 +0300 Subject: [PATCH 29/72] feat(theme): add ThemePaths directory helpers Centralize app support themes/imported paths and optional ~/.querya/themes without creating directories unless ensure* methods are called. --- lib/core/theme/theme_paths.dart | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 lib/core/theme/theme_paths.dart diff --git a/lib/core/theme/theme_paths.dart b/lib/core/theme/theme_paths.dart new file mode 100644 index 00000000..7a0f6811 --- /dev/null +++ b/lib/core/theme/theme_paths.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Centralizes theme file locations under app support and legacy paths. +abstract final class ThemePaths { + static const _themesSegment = 'themes'; + static const _importedSegment = 'imported'; + + /// App support `themes/` directory. Does not create the directory. + static Future userThemesDirectory() async { + final support = await getApplicationSupportDirectory(); + return Directory(p.join(support.path, _themesSegment)); + } + + /// App support `themes/imported/` directory. Does not create the directory. + static Future importedThemesDirectory() async { + final themes = await userThemesDirectory(); + return Directory(p.join(themes.path, _importedSegment)); + } + + /// Creates app support `themes/` if missing. + static Future ensureUserThemesDirectory() async { + final dir = await userThemesDirectory(); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + /// Creates app support `themes/imported/` if missing. + static Future ensureImportedThemesDirectory() async { + final dir = await importedThemesDirectory(); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + /// Optional legacy `~/.querya/themes`. Does not create the directory. + static Future legacyDotQueryaThemesDirectory() async { + final home = Platform.environment['HOME']; + if (home == null || home.isEmpty) return null; + return Directory(p.join(home, '.querya', _themesSegment)); + } +} From 784656cf00efe6abd86c0e4de717c0776797a9d8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:04:13 +0300 Subject: [PATCH 30/72] test(theme): cover ThemePaths resolution and ensure helpers Closes #106. --- test/core/theme/theme_paths_test.dart | 84 +++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 test/core/theme/theme_paths_test.dart diff --git a/test/core/theme/theme_paths_test.dart b/test/core/theme/theme_paths_test.dart new file mode 100644 index 00000000..8374f3b1 --- /dev/null +++ b/test/core/theme/theme_paths_test.dart @@ -0,0 +1,84 @@ +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/theme/theme_paths.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_theme_paths_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + }); + + tearDownAll(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('ThemePaths', () { + test('userThemesDirectory resolves under app support', () async { + final dir = await ThemePaths.userThemesDirectory(); + + expect(dir.path, p.join(tempDir.path, 'themes')); + }); + + test('importedThemesDirectory resolves under user themes', () async { + final dir = await ThemePaths.importedThemesDirectory(); + + expect(dir.path, p.join(tempDir.path, 'themes', 'imported')); + }); + + test('path getters do not create directories', () async { + await ThemePaths.userThemesDirectory(); + await ThemePaths.importedThemesDirectory(); + + expect(await Directory(p.join(tempDir.path, 'themes')).exists(), isFalse); + expect( + await Directory(p.join(tempDir.path, 'themes', 'imported')).exists(), + isFalse, + ); + }); + + test('ensureUserThemesDirectory creates themes folder', () async { + final dir = await ThemePaths.ensureUserThemesDirectory(); + + expect(dir.path, p.join(tempDir.path, 'themes')); + expect(await dir.exists(), isTrue); + }); + + test('ensureImportedThemesDirectory creates imported folder', () async { + final dir = await ThemePaths.ensureImportedThemesDirectory(); + + expect(dir.path, p.join(tempDir.path, 'themes', 'imported')); + expect(await dir.exists(), isTrue); + }); + + test('legacyDotQueryaThemesDirectory resolves ~/.querya/themes', () async { + final home = Platform.environment['HOME']; + if (home == null || home.isEmpty) { + expect(await ThemePaths.legacyDotQueryaThemesDirectory(), isNull); + return; + } + + final dir = await ThemePaths.legacyDotQueryaThemesDirectory(); + + expect(dir, isNotNull); + expect(dir!.path, p.join(home, '.querya', 'themes')); + expect(await dir.exists(), isFalse); + }); + }); +} From 045f88d1b2ebca5b1716ac5cbbebf2112304056e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:10:24 +0300 Subject: [PATCH 31/72] feat(theme): scan filesystem themes into ThemeDefinition list Add ThemeRegistryService with async metadata-only scan for custom and VS Code JSON/JSONC files under app support themes directories. --- lib/core/theme/theme_registry_service.dart | 188 +++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 lib/core/theme/theme_registry_service.dart diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart new file mode 100644 index 00000000..2330c14b --- /dev/null +++ b/lib/core/theme/theme_registry_service.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +import 'parser/jsonc_preprocessor.dart'; +import 'parser/querya_theme_manifest.dart'; +import 'theme_definition.dart'; +import 'theme_paths.dart'; + +/// Scans theme directories and exposes lightweight [ThemeDefinition] metadata. +class ThemeRegistryService { + ThemeRegistryService({ + Future Function()? userThemesDirectory, + Future Function()? importedThemesDirectory, + }) : _userThemesDirectory = + userThemesDirectory ?? ThemePaths.userThemesDirectory, + _importedThemesDirectory = + importedThemesDirectory ?? ThemePaths.importedThemesDirectory; + + final Future Function() _userThemesDirectory; + final Future Function() _importedThemesDirectory; + + Future> loadThemeDefinitions() async { + final definitions = []; + + await _scanDirectory( + await _userThemesDirectory(), + ThemeSource.filesystem, + definitions, + ); + await _scanDirectory( + await _importedThemesDirectory(), + ThemeSource.imported, + definitions, + ); + + definitions.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + return List.unmodifiable(definitions); + } + + Future _scanDirectory( + Directory directory, + ThemeSource source, + List out, + ) async { + if (!await directory.exists()) return; + + await for (final entity in directory.list(followLinks: false)) { + if (entity is Directory) { + if (p.basename(entity.path) == 'imported') continue; + continue; + } + if (entity is! File) continue; + + final ext = p.extension(entity.path).toLowerCase(); + if (ext != '.json' && ext != '.jsonc') continue; + + final definition = await _definitionFromFile(entity, source); + if (definition != null) { + out.add(definition); + } + } + } + + Future _definitionFromFile( + File file, + ThemeSource source, + ) async { + try { + final stat = await file.stat(); + final raw = await file.readAsString(); + final hash = _contentHash(raw); + final json = _decodeRoot(raw); + if (json == null) { + _logScanError(file.path, 'Invalid JSON'); + return null; + } + + final schema = json['schema']?.toString(); + if (schema == queryaThemeSchemaV1) { + return _customDefinition( + json: json, + file: file, + source: source, + lastModified: stat.modified, + contentHash: hash, + ); + } + + return _vscodeDefinition( + json: json, + file: file, + source: source, + lastModified: stat.modified, + contentHash: hash, + ); + } on Object catch (e) { + _logScanError(file.path, e); + return null; + } + } + + ThemeDefinition? _customDefinition({ + required Map json, + required File file, + required ThemeSource source, + required DateTime lastModified, + required String contentHash, + }) { + 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'); + return null; + } + if (type != 'dark' && type != 'light') { + _logScanError(file.path, 'Invalid custom theme type "$type"'); + return null; + } + + return ThemeDefinition( + id: id, + name: name, + source: source, + format: ThemeFormat.queryaCustom, + isDark: type == 'dark', + path: file.path, + lastModified: lastModified, + contentHash: contentHash, + ); + } + + ThemeDefinition? _vscodeDefinition({ + required Map json, + required File file, + required ThemeSource source, + required DateTime lastModified, + required String contentHash, + }) { + final fileId = p.basenameWithoutExtension(file.path); + final rawName = json['name']?.toString().trim(); + final name = rawName != null && rawName.isNotEmpty ? rawName : fileId; + final type = json['type']?.toString().trim().toLowerCase(); + + return ThemeDefinition( + id: fileId, + name: name, + source: source, + format: ThemeFormat.vscode, + isDark: type == 'dark', + path: file.path, + lastModified: lastModified, + contentHash: contentHash, + ); + } + + Map? _decodeRoot(String raw) { + try { + final decoded = jsonDecode(stripJsonc(raw)); + if (decoded is Map) return decoded; + } on FormatException { + return null; + } + return null; + } + + static String _contentHash(String content) { + final bytes = utf8.encode(content); + var hash = 0x811c9dc5; + for (final b in bytes) { + hash ^= b; + hash = (hash * 0x01000193) & 0xffffffff; + } + return hash.toRadixString(16).padLeft(8, '0'); + } + + void _logScanError(String path, Object error) { + if (kDebugMode) { + debugPrint('ThemeRegistryService: skipped $path ($error)'); + } + } +} From a8cd744d1206578c4ef7420423e3d137ff340e01 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:10:24 +0300 Subject: [PATCH 32/72] test(theme): cover ThemeRegistryService filesystem scan Closes #107. --- .../theme/theme_registry_service_test.dart | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 test/core/theme/theme_registry_service_test.dart diff --git a/test/core/theme/theme_registry_service_test.dart b/test/core/theme/theme_registry_service_test.dart new file mode 100644 index 00000000..b94ffdcf --- /dev/null +++ b/test/core/theme/theme_registry_service_test.dart @@ -0,0 +1,158 @@ +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/theme/theme_definition.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 _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 = await Directory.systemTemp.createTemp('querya_theme_registry_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, + ); + }); + + tearDown(() async { + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + }); + + tearDownAll(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('ThemeRegistryService.loadThemeDefinitions', () { + test('includes valid custom and VS Code themes, skips broken file', () async { + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + await _copyFixture( + 'dark_subset.json', + File(p.join(themesDir.path, 'dark_subset.json')), + ); + await _copyFixture( + 'querya_custom_invalid_missing_id.json', + File(p.join(themesDir.path, 'broken.json')), + ); + + final definitions = await registry.loadThemeDefinitions(); + + expect(definitions, hasLength(2)); + expect( + definitions.map((d) => d.format).toSet(), + equals({ThemeFormat.queryaCustom, ThemeFormat.vscode}), + ); + expect( + definitions.singleWhere((d) => d.format == ThemeFormat.queryaCustom).id, + 'fixture-custom-dark', + ); + expect( + definitions.singleWhere((d) => d.format == ThemeFormat.vscode).name, + 'Fixture Dark Subset', + ); + }); + + test('sorts definitions by name case-insensitively', () 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": {} +} +'''); + await File(p.join(themesDir.path, 'a-theme.json')).writeAsString(''' +{ + "schema": "querya.theme.v1", + "id": "a-theme", + "name": "alpha theme", + "type": "light", + "shadcn_colors": {}, + "editor_colors": {} +} +'''); + + final definitions = await registry.loadThemeDefinitions(); + + expect(definitions.map((d) => d.name), ['alpha theme', 'Zebra Theme']); + }); + + test('content hash changes when file content changes', () async { + final themeFile = File(p.join(themesDir.path, 'hash-theme.json')); + await _copyFixture('querya_custom_minimal.json', themeFile); + + final before = await registry.loadThemeDefinitions(); + expect(before, hasLength(1)); + final originalHash = before.single.contentHash; + + final raw = await themeFile.readAsString(); + await themeFile.writeAsString(raw.replaceFirst('#FF00AA', '#00FFAA')); + + final after = await registry.loadThemeDefinitions(); + expect(after, hasLength(1)); + expect(after.single.contentHash, isNot(originalHash)); + }); + + test('scans imported directory with imported source', () async { + await _copyFixture( + 'querya_custom_light.json', + File(p.join(importedDir.path, 'querya_custom_light.json')), + ); + + final definitions = await registry.loadThemeDefinitions(); + + expect(definitions, hasLength(1)); + expect(definitions.single.source, ThemeSource.imported); + expect(definitions.single.id, 'fixture-custom-light'); + }); + + test('ignores non-json theme extensions', () async { + await File(p.join(themesDir.path, 'notes.txt')).writeAsString('not a theme'); + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + + final definitions = await registry.loadThemeDefinitions(); + + expect(definitions, hasLength(1)); + }); + }); +} From 7aeb0b0342209b1bd6c0a23be586f9102b135265 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:14:01 +0300 Subject: [PATCH 33/72] feat(theme): load ThemeDefinition into ThemeLoadResult Wire custom and VS Code parsers through ThemeRegistryService.loadTheme with controlled failures for missing, unreadable, or invalid theme files. --- lib/core/theme/theme_registry_service.dart | 72 ++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart index 2330c14b..219fef16 100644 --- a/lib/core/theme/theme_registry_service.dart +++ b/lib/core/theme/theme_registry_service.dart @@ -5,8 +5,13 @@ import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'parser/jsonc_preprocessor.dart'; +import 'parser/querya_theme_from_manifest.dart'; +import 'parser/querya_theme_from_vscode.dart'; import 'parser/querya_theme_manifest.dart'; +import 'parser/vscode_theme_manifest.dart'; +import 'querya_theme.dart'; import 'theme_definition.dart'; +import 'theme_load_result.dart'; import 'theme_paths.dart'; /// Scans theme directories and exposes lightweight [ThemeDefinition] metadata. @@ -42,6 +47,73 @@ class ThemeRegistryService { return List.unmodifiable(definitions); } + /// Parses a scanned [definition] into a runtime [QueryaTheme]. + Future loadTheme(ThemeDefinition definition) async { + final path = definition.path; + if (path == null || path.isEmpty) { + return ThemeLoadFailure( + definition: definition, + message: 'Theme file path is missing.', + ); + } + + final file = File(path); + if (!await file.exists()) { + return ThemeLoadFailure( + definition: definition, + message: 'Theme file not found.', + ); + } + + try { + final raw = await file.readAsString(); + final theme = switch (definition.format) { + ThemeFormat.queryaCustom => _loadCustomTheme(raw), + ThemeFormat.vscode => _loadVsCodeTheme(raw), + }; + return ThemeLoadSuccess(definition: definition, theme: theme); + } on QueryaThemeManifestParseException catch (e) { + return ThemeLoadFailure( + definition: definition, + message: e.message, + error: e, + ); + } on VsCodeThemeParseException catch (e) { + return ThemeLoadFailure( + definition: definition, + message: e.message, + error: e, + ); + } on IOException catch (e) { + return ThemeLoadFailure( + definition: definition, + message: 'Failed to read theme file.', + error: e, + ); + } on Object catch (e) { + return ThemeLoadFailure( + definition: definition, + message: 'Failed to load theme.', + error: e, + ); + } + } + + QueryaTheme _loadCustomTheme(String raw) { + final manifest = QueryaThemeManifest.fromJsonString(raw); + return queryaThemeFromManifest(manifest); + } + + QueryaTheme _loadVsCodeTheme(String raw) { + final manifest = VsCodeThemeManifest.fromJsonString(raw); + if (manifest.colors.isEmpty) { + throw VsCodeThemeParseException( + 'Theme file has no "colors" section to import.', + ); + } + return buildQueryaThemeFromVsCodeManifest(manifest); + } + Future _scanDirectory( Directory directory, ThemeSource source, From dba3e3794ba2e237b41d1ee5e1da6d299ecca5a7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:14:01 +0300 Subject: [PATCH 34/72] test(theme): cover ThemeRegistryService.loadTheme paths Closes #108. --- .../theme/theme_registry_service_test.dart | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/test/core/theme/theme_registry_service_test.dart b/test/core/theme/theme_registry_service_test.dart index b94ffdcf..ae305dfd 100644 --- a/test/core/theme/theme_registry_service_test.dart +++ b/test/core/theme/theme_registry_service_test.dart @@ -1,9 +1,12 @@ 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/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 { @@ -155,4 +158,70 @@ void main() { expect(definitions, hasLength(1)); }); }); + + group('ThemeRegistryService.loadTheme', () { + test('loads custom theme successfully', () async { + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + + final definition = (await registry.loadThemeDefinitions()).single; + final result = await registry.loadTheme(definition); + + expect(result, isA()); + final success = result as ThemeLoadSuccess; + expect(success.definition, definition); + expect(success.theme.brightness, Brightness.dark); + expect(success.theme.colorScheme.primary, parseQueryaThemeColor('#38BDF8')); + }); + + test('loads VS Code theme successfully', () async { + await _copyFixture( + 'dark_subset.json', + File(p.join(themesDir.path, 'dark_subset.json')), + ); + + final definition = (await registry.loadThemeDefinitions()).single; + final result = await registry.loadTheme(definition); + + expect(result, isA()); + final success = result as ThemeLoadSuccess; + expect(success.theme.editor.background, parseQueryaThemeColor('#1e1e1e')); + }); + + test('returns failure for deleted file', () async { + const definition = ThemeDefinition( + id: 'missing-theme', + name: 'Missing Theme', + source: ThemeSource.filesystem, + format: ThemeFormat.queryaCustom, + isDark: true, + path: '/no/such/theme.json', + ); + + final result = await registry.loadTheme(definition); + + expect(result, isA()); + expect((result as ThemeLoadFailure).message, 'Theme file not found.'); + }); + + test('returns failure for invalid custom file', () async { + final file = File(p.join(themesDir.path, 'broken.json')); + await _copyFixture('querya_custom_invalid_missing_id.json', file); + final definition = ThemeDefinition( + id: 'broken', + name: 'Broken', + source: ThemeSource.filesystem, + format: ThemeFormat.queryaCustom, + isDark: true, + path: file.path, + ); + + final result = await registry.loadTheme(definition); + + expect(result, isA()); + expect((result as ThemeLoadFailure).message, contains('id')); + }); + }); } From b4b2476a4fa02394bbb79f1564d30b60f91e0d70 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:17:15 +0300 Subject: [PATCH 35/72] feat(theme): add LRU cache to ThemeRegistryService.loadTheme Cache parsed QueryaTheme by stableCacheKey with a 16-entry LRU, clearCache for tests, and themeParseCount to verify cache hits. --- lib/core/theme/theme_registry_service.dart | 51 +++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart index 219fef16..090ada5d 100644 --- a/lib/core/theme/theme_registry_service.dart +++ b/lib/core/theme/theme_registry_service.dart @@ -19,13 +19,29 @@ class ThemeRegistryService { ThemeRegistryService({ Future Function()? userThemesDirectory, Future Function()? importedThemesDirectory, + int maxCacheEntries = 16, }) : _userThemesDirectory = userThemesDirectory ?? ThemePaths.userThemesDirectory, _importedThemesDirectory = - importedThemesDirectory ?? ThemePaths.importedThemesDirectory; + importedThemesDirectory ?? ThemePaths.importedThemesDirectory, + _themeCache = _ThemeLruCache(maxEntries: maxCacheEntries); + + static const defaultMaxCacheEntries = 16; final Future Function() _userThemesDirectory; final Future Function() _importedThemesDirectory; + final _ThemeLruCache _themeCache; + int _themeParseCount = 0; + + /// Number of cache misses that performed a full theme parse. + @visibleForTesting + int get themeParseCount => _themeParseCount; + + /// Clears parsed theme cache and parse counter. + void clearCache() { + _themeCache.clear(); + _themeParseCount = 0; + } Future> loadThemeDefinitions() async { final definitions = []; @@ -65,12 +81,20 @@ class ThemeRegistryService { ); } + final cacheKey = definition.stableCacheKey; + final cachedTheme = _themeCache.get(cacheKey); + if (cachedTheme != null) { + return ThemeLoadSuccess(definition: definition, theme: cachedTheme); + } + try { final raw = await file.readAsString(); final theme = switch (definition.format) { ThemeFormat.queryaCustom => _loadCustomTheme(raw), ThemeFormat.vscode => _loadVsCodeTheme(raw), }; + _themeCache.put(cacheKey, theme); + _themeParseCount++; return ThemeLoadSuccess(definition: definition, theme: theme); } on QueryaThemeManifestParseException catch (e) { return ThemeLoadFailure( @@ -258,3 +282,28 @@ class ThemeRegistryService { } } } + +class _ThemeLruCache { + _ThemeLruCache({required this.maxEntries}); + + final int maxEntries; + final _entries = {}; + + QueryaTheme? get(String key) { + final value = _entries.remove(key); + if (value == null) return null; + _entries[key] = value; + return value; + } + + void put(String key, QueryaTheme theme) { + _entries.remove(key); + _entries[key] = theme; + while (_entries.length > maxEntries) { + final oldest = _entries.keys.first; + _entries.remove(oldest); + } + } + + void clear() => _entries.clear(); +} From b479d7bf57e1e47e3e54d4c4c6e47571089ffeb1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:17:15 +0300 Subject: [PATCH 36/72] test(theme): cover ThemeRegistryService LRU cache behavior Closes #109. --- .../core/theme/theme_registry_cache_test.dart | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 test/core/theme/theme_registry_cache_test.dart diff --git a/test/core/theme/theme_registry_cache_test.dart b/test/core/theme/theme_registry_cache_test.dart new file mode 100644 index 00000000..9e379d49 --- /dev/null +++ b/test/core/theme/theme_registry_cache_test.dart @@ -0,0 +1,142 @@ +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/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 _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 = await Directory.systemTemp.createTemp('querya_theme_cache_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, + ); + }); + + tearDown(() async { + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + }); + + tearDownAll(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('ThemeRegistryService cache', () { + test('loads same definition twice with a single parse', () async { + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + + final definition = (await registry.loadThemeDefinitions()).single; + + final first = await registry.loadTheme(definition); + final second = await registry.loadTheme(definition); + + expect(first, isA()); + expect(second, isA()); + expect(registry.themeParseCount, 1); + }); + + test('re-parses when content hash changes', () async { + final themeFile = File(p.join(themesDir.path, 'hash-theme.json')); + await _copyFixture('querya_custom_minimal.json', themeFile); + + final before = (await registry.loadThemeDefinitions()).single; + await registry.loadTheme(before); + expect(registry.themeParseCount, 1); + + final raw = await themeFile.readAsString(); + await themeFile.writeAsString(raw.replaceFirst('#FF00AA', '#00FFAA')); + + final after = (await registry.loadThemeDefinitions()).single; + expect(after.contentHash, isNot(before.contentHash)); + + await registry.loadTheme(after); + expect(registry.themeParseCount, 2); + }); + + test('evicts oldest entry after cache limit', () async { + final limitedRegistry = ThemeRegistryService( + maxCacheEntries: 2, + userThemesDirectory: () async => themesDir, + importedThemesDirectory: () async => importedDir, + ); + + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + await _copyFixture( + 'querya_custom_light.json', + File(p.join(themesDir.path, 'querya_custom_light.json')), + ); + await _copyFixture( + 'querya_custom_minimal.json', + File(p.join(themesDir.path, 'querya_custom_minimal.json')), + ); + + final definitions = await limitedRegistry.loadThemeDefinitions(); + expect(definitions, hasLength(3)); + + for (final definition in definitions) { + await limitedRegistry.loadTheme(definition); + } + expect(limitedRegistry.themeParseCount, 3); + + await limitedRegistry.loadTheme(definitions.first); + expect(limitedRegistry.themeParseCount, 4); + }); + + test('clearCache forces re-parse on next load', () async { + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + + final definition = (await registry.loadThemeDefinitions()).single; + await registry.loadTheme(definition); + await registry.loadTheme(definition); + expect(registry.themeParseCount, 1); + + registry.clearCache(); + await registry.loadTheme(definition); + await registry.loadTheme(definition); + expect(registry.themeParseCount, 1); + }); + }); +} From 384c4ec92ef5228d50f1f688f777658bfcc0c937 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:19:35 +0300 Subject: [PATCH 37/72] feat(theme): persist selected registry theme in AppSettings Store theme_selected_id/source/path for registry-backed selection without changing existing preset or legacy import settings. --- lib/core/storage/app_settings.dart | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 9530b38d..b62c1d74 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -106,6 +106,9 @@ abstract final class AppSettingsKeys { static const themeImportPath = 'theme_import_path'; static const themeImportName = 'theme_import_name'; static const themeImportedColorsJson = 'theme_imported_colors_json'; + static const themeSelectedId = 'theme_selected_id'; + static const themeSelectedSource = 'theme_selected_source'; + static const themeSelectedPath = 'theme_selected_path'; static const themeAnimationEnabled = 'theme_animation_enabled'; static const uiScale = 'ui_scale'; } @@ -327,6 +330,68 @@ class AppSettings { AppSettingsRevision.bump(); } + Future getSelectedThemeId() async { + final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.themeSelectedId); + if (v == null || v.isEmpty) return null; + return v; + } + + Future setSelectedThemeId(String? id) async { + if (id == null || id.isEmpty) { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeSelectedId); + } else { + await LocalDb.instance.setAppSetting(AppSettingsKeys.themeSelectedId, id); + } + AppSettingsRevision.bump(); + } + + Future getSelectedThemeSource() async { + final v = + await LocalDb.instance.getAppSetting(AppSettingsKeys.themeSelectedSource); + if (v == null || v.isEmpty) return null; + return v; + } + + Future setSelectedThemeSource(String? source) async { + if (source == null || source.isEmpty) { + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.themeSelectedSource, + ); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.themeSelectedSource, + source, + ); + } + AppSettingsRevision.bump(); + } + + Future getSelectedThemePath() async { + final v = + await LocalDb.instance.getAppSetting(AppSettingsKeys.themeSelectedPath); + if (v == null || v.isEmpty) return null; + return v; + } + + Future setSelectedThemePath(String? path) async { + if (path == null || path.isEmpty) { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeSelectedPath); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.themeSelectedPath, + path, + ); + } + AppSettingsRevision.bump(); + } + + Future clearSelectedThemeRegistry() async { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeSelectedId); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeSelectedSource); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeSelectedPath); + AppSettingsRevision.bump(); + } + Future> getThemeImportedColors() async { final v = await LocalDb.instance.getAppSetting( AppSettingsKeys.themeImportedColorsJson, @@ -453,6 +518,9 @@ class AppSettings { await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themePreset); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeOverridesJson); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeAnimationEnabled); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeSelectedId); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeSelectedSource); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeSelectedPath); await deleteThemeImportKeys(); AppSettingsRevision.bump(); } From e7182012415835683800438e8c1b05a45551a83e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:19:35 +0300 Subject: [PATCH 38/72] test(storage): cover selected theme registry AppSettings roundtrip Closes #110. --- test/core/storage/app_settings_test.dart | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 62e6b05d..9f4fa6e1 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -227,6 +227,55 @@ void main() { await AppSettings.instance.clearThemeColorOverrides(); expect(await AppSettings.instance.getThemeColorOverrides(), isEmpty); }); + + test('selected theme registry id/source/path roundtrip', () async { + expect(await AppSettings.instance.getSelectedThemeId(), isNull); + expect(await AppSettings.instance.getSelectedThemeSource(), isNull); + expect(await AppSettings.instance.getSelectedThemePath(), isNull); + + await AppSettings.instance.setSelectedThemeId('fixture-custom-dark'); + await AppSettings.instance.setSelectedThemeSource('filesystem'); + await AppSettings.instance.setSelectedThemePath( + '/data/themes/fixture-custom-dark.json', + ); + + expect( + await AppSettings.instance.getSelectedThemeId(), + 'fixture-custom-dark', + ); + expect( + await AppSettings.instance.getSelectedThemeSource(), + 'filesystem', + ); + expect( + await AppSettings.instance.getSelectedThemePath(), + '/data/themes/fixture-custom-dark.json', + ); + }); + + test('clearSelectedThemeRegistry clears registry selection', () async { + await AppSettings.instance.setSelectedThemeId('fixture-custom-dark'); + await AppSettings.instance.setSelectedThemeSource('imported'); + await AppSettings.instance.setSelectedThemePath('/tmp/theme.json'); + + await AppSettings.instance.clearSelectedThemeRegistry(); + + expect(await AppSettings.instance.getSelectedThemeId(), isNull); + expect(await AppSettings.instance.getSelectedThemeSource(), isNull); + expect(await AppSettings.instance.getSelectedThemePath(), isNull); + }); + + test('clearThemeSettings clears selected registry theme', () async { + await AppSettings.instance.setSelectedThemeId('fixture-custom-light'); + await AppSettings.instance.setSelectedThemeSource('filesystem'); + await AppSettings.instance.setSelectedThemePath('/tmp/light.json'); + + await AppSettings.instance.clearThemeSettings(); + + expect(await AppSettings.instance.getSelectedThemeId(), isNull); + expect(await AppSettings.instance.getSelectedThemeSource(), isNull); + expect(await AppSettings.instance.getSelectedThemePath(), isNull); + }); }); group('ui scale', () { From ec41c54bb6d81ee2e27830c3a084ba30a1b6ec38 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:22:17 +0300 Subject: [PATCH 39/72] feat(theme): migrate legacy imported theme into registry scan Expose persisted import metadata as ThemeSource.legacyImported and skip duplicate filesystem scan of themes/imported.json. --- lib/core/theme/theme_import_service.dart | 8 +- lib/core/theme/theme_registry_service.dart | 85 ++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/lib/core/theme/theme_import_service.dart b/lib/core/theme/theme_import_service.dart index 7a40e5d0..e47ece44 100644 --- a/lib/core/theme/theme_import_service.dart +++ b/lib/core/theme/theme_import_service.dart @@ -33,7 +33,11 @@ class ThemeImportFailure extends ThemeImportResult { /// Parses and persists an imported VS Code theme under app support. abstract final class ThemeImportService { - static const String _storedFileName = 'imported.json'; + static const String legacyImportedThemeId = 'imported'; + static const String storedFileName = 'imported.json'; + + /// Path to the persisted legacy import copy under app support. + static Future persistedImportFile() => _storedThemeFile(); /// Reads [sourcePath], parses JSON/JSONC, copies to app data, returns colors. static Future importFromPath(String sourcePath) async { @@ -109,6 +113,6 @@ abstract final class ThemeImportService { static Future _storedThemeFile() async { final support = await getApplicationSupportDirectory(); - return File(p.join(support.path, 'themes', _storedFileName)); + return File(p.join(support.path, 'themes', storedFileName)); } } diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart index 090ada5d..eb8154f6 100644 --- a/lib/core/theme/theme_registry_service.dart +++ b/lib/core/theme/theme_registry_service.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; +import '../storage/app_settings.dart'; import 'parser/jsonc_preprocessor.dart'; import 'parser/querya_theme_from_manifest.dart'; import 'parser/querya_theme_from_vscode.dart'; @@ -11,6 +12,7 @@ import 'parser/querya_theme_manifest.dart'; import 'parser/vscode_theme_manifest.dart'; import 'querya_theme.dart'; import 'theme_definition.dart'; +import 'theme_import_service.dart'; import 'theme_load_result.dart'; import 'theme_paths.dart'; @@ -57,6 +59,16 @@ class ThemeRegistryService { definitions, ); + final legacy = await _legacyImportedDefinition(); + if (legacy != null) { + definitions.removeWhere( + (definition) => + definition.path == legacy.path && + definition.source != ThemeSource.legacyImported, + ); + definitions.add(legacy); + } + definitions.sort( (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), ); @@ -138,6 +150,75 @@ class ThemeRegistryService { return buildQueryaThemeFromVsCodeManifest(manifest); } + Future _legacyImportedDefinition() async { + try { + final settings = AppSettings.instance; + final importedColors = await settings.getThemeImportedColors(); + final importName = await settings.getThemeImportName(); + final importPath = await settings.getThemeImportPath(); + final storedFile = await ThemeImportService.persistedImportFile(); + final hasStoredFile = await storedFile.exists(); + + final hasLegacyData = importedColors.isNotEmpty || + (importName != null && importName.isNotEmpty) || + (importPath != null && importPath.isNotEmpty) || + hasStoredFile; + if (!hasLegacyData) return null; + + final path = _firstNonEmpty([ + importPath, + if (hasStoredFile) storedFile.path, + storedFile.path, + ]); + if (path == null) return null; + + final name = (importName != null && importName.isNotEmpty) + ? importName + : 'Imported theme'; + + var isDark = true; + DateTime? lastModified; + String? contentHash; + + final file = File(path); + if (await file.exists()) { + try { + final stat = await file.stat(); + lastModified = stat.modified; + final raw = await file.readAsString(); + contentHash = _contentHash(raw); + final manifest = VsCodeThemeManifest.fromJsonString(raw); + isDark = manifest.isDark || !manifest.isLight; + } on Object catch (e) { + _logScanError(path, e); + } + } + + return ThemeDefinition( + id: ThemeImportService.legacyImportedThemeId, + name: name, + source: ThemeSource.legacyImported, + format: ThemeFormat.vscode, + isDark: isDark, + path: path, + lastModified: lastModified, + contentHash: contentHash, + ); + } on Object catch (e) { + if (kDebugMode) { + debugPrint('ThemeRegistryService: legacy import skipped ($e)'); + } + return null; + } + } + + String? _firstNonEmpty(List values) { + for (final value in values) { + if (value != null && value.isNotEmpty) return value; + } + return null; + } + Future _scanDirectory( Directory directory, ThemeSource source, @@ -152,6 +233,10 @@ class ThemeRegistryService { } if (entity is! File) continue; + if (p.basename(entity.path) == ThemeImportService.storedFileName) { + continue; + } + final ext = p.extension(entity.path).toLowerCase(); if (ext != '.json' && ext != '.jsonc') continue; From 0f86f4054a5143194731ffaecb95ce5b48a9f2fb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:22:17 +0300 Subject: [PATCH 40/72] test(theme): cover legacy imported theme registry migration Closes #111. --- .../theme_registry_legacy_import_test.dart | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 test/core/theme/theme_registry_legacy_import_test.dart diff --git a/test/core/theme/theme_registry_legacy_import_test.dart b/test/core/theme/theme_registry_legacy_import_test.dart new file mode 100644 index 00000000..318b4d52 --- /dev/null +++ b/test/core/theme/theme_registry_legacy_import_test.dart @@ -0,0 +1,152 @@ +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/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.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_definition.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'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late Directory themesDir; + late Directory importedDir; + late ThemeRegistryService registry; + + setUpAll(() async { + tempDir = + await Directory.systemTemp.createTemp('querya_theme_legacy_import_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + 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, + ); + }); + + tearDown(() async { + await AppSettings.instance.clearThemeSettings(); + await ThemeImportService.deletePersistedImport(); + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + await ThemeController.instance.load(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('ThemeRegistryService legacy imported migration', () { + test('exposes legacy imported theme from persisted import settings', () async { + final fixture = File('test/fixtures/themes/dark_subset.json'); + final importResult = await ThemeImportService.importFromPath(fixture.path); + expect(importResult, isA()); + final success = importResult as ThemeImportSuccess; + + await AppSettings.instance.setThemeImportedColors(success.colors); + await AppSettings.instance.setThemeImportName(success.name); + await AppSettings.instance.setThemeImportPath(success.storedPath); + await AppSettings.instance.setThemePreset(QueryaThemePreset.imported); + + final definitions = await registry.loadThemeDefinitions(); + final legacy = definitions.singleWhere( + (definition) => definition.source == ThemeSource.legacyImported, + ); + + expect(legacy.id, ThemeImportService.legacyImportedThemeId); + expect(legacy.name, 'Fixture Dark Subset'); + expect(legacy.format, ThemeFormat.vscode); + expect(legacy.path, success.storedPath); + expect( + definitions.where((definition) => definition.id == 'imported'), + hasLength(1), + ); + }); + + test('loads legacy imported theme definition', () async { + final fixture = File('test/fixtures/themes/dark_subset.json'); + final importResult = await ThemeImportService.importFromPath(fixture.path); + final success = importResult as ThemeImportSuccess; + + await AppSettings.instance.setThemeImportedColors(success.colors); + await AppSettings.instance.setThemeImportName(success.name); + await AppSettings.instance.setThemeImportPath(success.storedPath); + + final legacy = (await registry.loadThemeDefinitions()).singleWhere( + (definition) => definition.source == ThemeSource.legacyImported, + ); + final result = await registry.loadTheme(legacy); + + expect(result, isA()); + expect( + (result as ThemeLoadSuccess).theme.workbench.editorBackground, + const Color(0xFF1E1E1E), + ); + }); + + test('missing legacy file does not crash scan or load', () async { + await AppSettings.instance.setThemeImportedColors({ + 'editor.background': '#1e1e1e', + }); + await AppSettings.instance.setThemeImportName('Missing Legacy'); + await AppSettings.instance.setThemeImportPath( + p.join(themesDir.path, 'missing-imported.json'), + ); + + final definitions = await registry.loadThemeDefinitions(); + final legacy = definitions.singleWhere( + (definition) => definition.source == ThemeSource.legacyImported, + ); + + expect(legacy.name, 'Missing Legacy'); + final result = await registry.loadTheme(legacy); + expect(result, isA()); + expect((result as ThemeLoadFailure).message, 'Theme file not found.'); + }); + + test('QueryaThemePreset.imported still applies via ThemeController', () async { + final controller = ThemeController.instance; + final fixture = File('test/fixtures/themes/dark_subset.json'); + final result = await controller.importThemeFromFile(fixture.path); + + expect(result, isA()); + expect(controller.preset, QueryaThemePreset.imported); + expect(controller.hasImportedTheme, isTrue); + + final definitions = await registry.loadThemeDefinitions(); + expect( + definitions.any( + (definition) => definition.source == ThemeSource.legacyImported, + ), + isTrue, + ); + }); + }); +} From 64c653e2a344b57e206ea3e2c939e6300364475d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:26:11 +0300 Subject: [PATCH 41/72] 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 42/72] 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 43/72] 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'; From abb1432cb983ec8df3b784d7563c9cb1882e6f47 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:32:18 +0300 Subject: [PATCH 44/72] feat(settings): add ThemePickerButton for registry theme lists MenuAnchor popup with scrollable ListView.builder rows showing name, source badge, and dark/light label without parsing themes during build. --- .../settings/theme_picker_button.dart | 405 ++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 lib/features/settings/theme_picker_button.dart diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart new file mode 100644 index 00000000..62860e71 --- /dev/null +++ b/lib/features/settings/theme_picker_button.dart @@ -0,0 +1,405 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/theme/theme_definition.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Dedicated theme picker for large registry lists (50+ themes). +class ThemePickerButton extends material.StatefulWidget { + const ThemePickerButton({ + super.key, + required this.themes, + required this.selectedThemeId, + required this.onSelected, + this.isLoading = false, + this.expandToParent = false, + this.width, + }); + + final List themes; + final String? selectedThemeId; + final material.ValueChanged onSelected; + final bool isLoading; + final bool expandToParent; + final double? width; + + static const double menuMaxHeight = 320; + + @override + material.State createState() => _ThemePickerButtonState(); +} + +class _ThemePickerButtonState extends material.State { + final material.MenuController _controller = material.MenuController(); + final material.ScrollController _scrollController = material.ScrollController(); + bool _triggerHovered = false; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + bool get _enabled => !widget.isLoading; + + String get _triggerLabel { + if (widget.isLoading) return 'Loading themes…'; + if (widget.selectedThemeId == null) return 'Select theme…'; + for (final theme in widget.themes) { + if (theme.id == widget.selectedThemeId) return theme.name; + } + return widget.selectedThemeId!; + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final fieldWidth = widget.expandToParent + ? null + : (widget.width != null ? context.scaled(widget.width!) : null); + final menuWidth = fieldWidth ?? context.scaled(280); + final menuHeight = context.scaled(ThemePickerButton.menuMaxHeight); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + + final anchor = material.MenuAnchor( + controller: _controller, + crossAxisUnconstrained: false, + alignmentOffset: material.Offset( + 0, + context.scaled(QueryaDropdownTokens.menuAlignmentOffset.dy), + ), + consumeOutsideTap: true, + style: material.MenuStyle( + backgroundColor: material.WidgetStatePropertyAll(cs.popover), + surfaceTintColor: material.WidgetStatePropertyAll(cs.popover), + elevation: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuElevation, + ), + shadowColor: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuShadowColor, + ), + maximumSize: material.WidgetStatePropertyAll( + material.Size(menuWidth, menuHeight), + ), + minimumSize: material.WidgetStatePropertyAll( + material.Size(menuWidth, 0), + ), + padding: material.WidgetStatePropertyAll(material.EdgeInsets.zero), + shape: material.WidgetStatePropertyAll( + material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(radius), + side: material.BorderSide(color: cs.border), + ), + ), + ), + menuChildren: [ + material.SizedBox( + width: menuWidth, + height: menuHeight, + child: material.Scrollbar( + controller: _scrollController, + thumbVisibility: widget.themes.length > 8, + child: material.ListView.builder( + controller: _scrollController, + primary: false, + padding: QueryaDropdownTokens.menuPadding, + itemCount: widget.themes.length, + itemBuilder: (context, index) { + final theme = widget.themes[index]; + return _ThemePickerRow( + definition: theme, + selected: theme.id == widget.selectedThemeId, + colorScheme: cs, + onSelected: () { + widget.onSelected(theme.id); + _controller.close(); + }, + ); + }, + ), + ), + ), + ], + builder: (context, controller, child) { + final trigger = _buildTrigger( + context: context, + controller: controller, + cs: cs, + fieldWidth: fieldWidth, + ); + if (widget.expandToParent) { + return material.SizedBox(width: double.infinity, child: trigger); + } + if (fieldWidth != null) { + return material.SizedBox(width: fieldWidth, child: trigger); + } + return trigger; + }, + ); + + return anchor; + } + + material.Widget _buildTrigger({ + required material.BuildContext context, + required material.MenuController controller, + required ColorScheme cs, + required double? fieldWidth, + }) { + final borderColor = _enabled + ? (_triggerHovered ? cs.ring : cs.border) + : cs.border.withValues(alpha: 0.4); + final triggerHeight = QueryaDropdownTokens.scaledTriggerHeight(context); + final chevronGap = context.scaled(QueryaDropdownTokens.triggerChevronGap); + final chevronSize = context.scaled(QueryaDropdownTokens.triggerChevronSize); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + + final triggerBody = material.MouseRegion( + cursor: _enabled + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + onEnter: _enabled ? (_) => setState(() => _triggerHovered = true) : null, + onExit: _enabled ? (_) => setState(() => _triggerHovered = false) : null, + child: material.AnimatedContainer( + duration: const Duration( + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), + curve: material.Curves.easeOut, + height: triggerHeight, + padding: QueryaDropdownTokens.scaledTriggerPadding(context), + decoration: material.BoxDecoration( + color: _triggerHovered + ? cs.muted.withValues(alpha: 0.28) + : cs.muted.withValues(alpha: 0.14), + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: borderColor), + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + mainAxisSize: (widget.expandToParent || fieldWidth != null) + ? material.MainAxisSize.max + : material.MainAxisSize.min, + children: [ + material.Expanded( + child: material.Text( + _triggerLabel, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: QueryaDropdownTokens.triggerTextStyle( + context, + _enabled ? cs.popoverForeground : cs.mutedForeground, + ), + ), + ), + material.SizedBox(width: chevronGap), + material.Icon( + material.Icons.keyboard_arrow_down_rounded, + size: chevronSize, + color: _enabled + ? cs.mutedForeground + : cs.mutedForeground.withValues(alpha: 0.5), + ), + ], + ), + ), + ); + + return material.Material( + type: material.MaterialType.transparency, + child: material.InkWell( + onTap: _enabled + ? () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + } + : null, + borderRadius: material.BorderRadius.circular(radius), + child: triggerBody, + ), + ); + } +} + +class _ThemePickerRow extends material.StatefulWidget { + const _ThemePickerRow({ + required this.definition, + required this.selected, + required this.colorScheme, + required this.onSelected, + }); + + final ThemeDefinition definition; + final bool selected; + final ColorScheme colorScheme; + final material.VoidCallback onSelected; + + @override + material.State<_ThemePickerRow> createState() => _ThemePickerRowState(); +} + +class _ThemePickerRowState extends material.State<_ThemePickerRow> { + bool _hovered = false; + + @override + material.Widget build(material.BuildContext context) { + final cs = widget.colorScheme; + final bg = _hovered + ? cs.accent.withValues(alpha: 0.14) + : widget.selected + ? cs.muted.withValues(alpha: 0.32) + : material.Colors.transparent; + final itemHeight = QueryaDropdownTokens.scaledMenuItemHeight(context); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); + + return material.MouseRegion( + cursor: material.SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: material.Material( + type: material.MaterialType.transparency, + child: material.InkWell( + onTap: widget.onSelected, + borderRadius: material.BorderRadius.circular(radius), + child: material.AnimatedContainer( + duration: const Duration( + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), + curve: material.Curves.easeOut, + constraints: material.BoxConstraints(minHeight: itemHeight), + padding: material.EdgeInsets.symmetric( + horizontal: + context.scaled(QueryaDropdownTokens.menuItemPadding.horizontal), + vertical: + context.scaled(QueryaDropdownTokens.menuItemPadding.vertical), + ), + decoration: material.BoxDecoration( + color: bg, + borderRadius: material.BorderRadius.circular(radius), + ), + child: material.Row( + children: [ + material.SizedBox( + width: slot, + child: widget.selected + ? material.Icon( + material.Icons.check_rounded, + size: context.scaled( + QueryaDropdownTokens.selectedCheckSize, + ), + color: cs.primary, + ) + : null, + ), + material.SizedBox(width: context.scaled(6)), + material.Expanded( + child: material.Text( + widget.definition.name, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: QueryaDropdownTokens.menuItemTextStyle( + context, + cs.popoverForeground, + selected: widget.selected, + ), + ), + ), + material.SizedBox(width: context.scaled(6)), + _SourceBadge( + label: _sourceBadgeLabel(widget.definition.source), + colorScheme: cs, + ), + material.SizedBox(width: context.scaled(6)), + _BrightnessLabel(isDark: widget.definition.isDark, colorScheme: cs), + ], + ), + ), + ), + ), + ); + } +} + +class _SourceBadge extends material.StatelessWidget { + const _SourceBadge({ + required this.label, + required this.colorScheme, + }); + + final String label; + final ColorScheme colorScheme; + + @override + material.Widget build(material.BuildContext context) { + final cs = colorScheme; + return material.Container( + padding: material.EdgeInsets.symmetric( + horizontal: context.scaled(6), + vertical: context.scaled(2), + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.45), + borderRadius: material.BorderRadius.circular( + context.scaled(QueryaDropdownTokens.menuBorderRadius), + ), + border: material.Border.all(color: cs.border.withValues(alpha: 0.6)), + ), + child: material.Text( + label, + style: material.TextStyle( + fontSize: context.scaled(11), + height: 1.1, + color: cs.mutedForeground, + fontWeight: material.FontWeight.w500, + ), + ), + ); + } +} + +class _BrightnessLabel extends material.StatelessWidget { + const _BrightnessLabel({ + required this.isDark, + required this.colorScheme, + }); + + final bool isDark; + final ColorScheme colorScheme; + + @override + material.Widget build(material.BuildContext context) { + final cs = colorScheme; + return material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + isDark + ? material.Icons.dark_mode_outlined + : material.Icons.light_mode_outlined, + size: context.scaled(14), + color: cs.mutedForeground, + ), + material.SizedBox(width: context.scaled(4)), + material.Text( + isDark ? 'Dark' : 'Light', + style: material.TextStyle( + fontSize: context.scaled(11), + color: cs.mutedForeground, + ), + ), + ], + ); + } +} + +String _sourceBadgeLabel(ThemeSource source) { + return switch (source) { + ThemeSource.builtin => 'Built-in', + ThemeSource.imported => 'Imported', + ThemeSource.filesystem => 'File', + ThemeSource.legacyImported => 'Imported', + }; +} From 176464df8139252350830588dae23b2611cd897a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:32:18 +0300 Subject: [PATCH 45/72] test(settings): cover ThemePickerButton menu and selection Closes #113. --- .../settings/theme_picker_button_test.dart | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 test/features/settings/theme_picker_button_test.dart diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart new file mode 100644 index 00000000..7cfcab57 --- /dev/null +++ b/test/features/settings/theme_picker_button_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/theme_definition.dart'; +import 'package:querya_desktop/features/settings/theme_picker_button.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +List _fakeThemes(int count) { + return List.generate( + count, + (index) => ThemeDefinition( + id: 'theme-$index', + name: 'Theme ${index.toString().padLeft(2, '0')}', + source: ThemeSource.values[index % ThemeSource.values.length], + format: index.isEven ? ThemeFormat.queryaCustom : ThemeFormat.vscode, + isDark: index.isOdd, + ), + ); +} + +void main() { + group('ThemePickerButton', () { + testWidgets('builds MenuAnchor trigger for many themes', (tester) async { + final themes = _fakeThemes(60); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'theme-5', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(material.MenuAnchor), findsOneWidget); + expect(find.text('Theme 05'), findsOneWidget); + expect(find.byType(material.ListView), findsNothing); + }); + + testWidgets('opens scrollable menu and renders visible rows', (tester) async { + final themes = _fakeThemes(60); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Theme 00')); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsOneWidget); + expect(find.byType(material.Scrollbar), findsWidgets); + expect(find.text('Theme 00'), findsWidgets); + expect(find.text('Theme 01'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('tap row triggers onSelected with theme id', (tester) async { + final themes = _fakeThemes(60); + String? picked; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'theme-0', + onSelected: (id) => picked = id, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Theme 00')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Theme 01')); + await tester.pumpAndSettle(); + + expect(picked, 'theme-1'); + }); + + testWidgets('shows source badge and brightness label in open menu', + (tester) async { + final themes = const [ + ThemeDefinition( + id: 'builtin-dark', + name: 'Querya Dark', + source: ThemeSource.builtin, + format: ThemeFormat.queryaCustom, + isDark: true, + ), + ThemeDefinition( + id: 'file-light', + name: 'Sunrise', + source: ThemeSource.filesystem, + format: ThemeFormat.vscode, + isDark: false, + ), + ]; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'builtin-dark', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Querya Dark')); + await tester.pumpAndSettle(); + + expect(find.text('Built-in'), findsOneWidget); + expect(find.text('File'), findsOneWidget); + expect(find.text('Dark'), findsOneWidget); + expect(find.text('Light'), findsOneWidget); + }); + + testWidgets('loading state disables menu open', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(3), + selectedThemeId: null, + onSelected: (_) {}, + isLoading: true, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Loading themes…'), findsOneWidget); + + await tester.tap(find.text('Loading themes…')); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsNothing); + }); + }); +} From 6e135a166f151b37e90204b3f205ca5b863a56b5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:32:30 +0300 Subject: [PATCH 46/72] fix(settings): satisfy prefer_const analyzer hints in theme picker --- lib/features/settings/theme_picker_button.dart | 2 +- test/features/settings/theme_picker_button_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index 62860e71..bc6a80cc 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -84,7 +84,7 @@ class _ThemePickerButtonState extends material.State { minimumSize: material.WidgetStatePropertyAll( material.Size(menuWidth, 0), ), - padding: material.WidgetStatePropertyAll(material.EdgeInsets.zero), + padding: const material.WidgetStatePropertyAll(material.EdgeInsets.zero), shape: material.WidgetStatePropertyAll( material.RoundedRectangleBorder( borderRadius: material.BorderRadius.circular(radius), diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index 7cfcab57..2734ccb1 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -95,7 +95,7 @@ void main() { testWidgets('shows source badge and brightness label in open menu', (tester) async { - final themes = const [ + const themes = [ ThemeDefinition( id: 'builtin-dark', name: 'Querya Dark', From 7ed7869dbf877b2a588f1488de3259e8bb995aa7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:41:27 +0300 Subject: [PATCH 47/72] feat(settings): add search filter to ThemePickerButton popup Filter registry rows by name, id, and source with a local search field and empty-state message without applying themes while typing. --- .../settings/theme_picker_button.dart | 164 +++++++++++++++--- 1 file changed, 142 insertions(+), 22 deletions(-) diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index bc6a80cc..d874bc97 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -29,17 +29,61 @@ class ThemePickerButton extends material.StatefulWidget { material.State createState() => _ThemePickerButtonState(); } +/// Filters [themes] by lowercase [query] against name, id, and source labels. +List filterThemeDefinitions( + List themes, + String query, +) { + final normalized = query.trim().toLowerCase(); + if (normalized.isEmpty) return themes; + + return themes + .where( + (theme) => + theme.name.toLowerCase().contains(normalized) || + theme.id.toLowerCase().contains(normalized) || + theme.source.name.toLowerCase().contains(normalized) || + _sourceBadgeLabel(theme.source).toLowerCase().contains(normalized), + ) + .toList(growable: false); +} + class _ThemePickerButtonState extends material.State { final material.MenuController _controller = material.MenuController(); final material.ScrollController _scrollController = material.ScrollController(); + final material.TextEditingController _searchController = + material.TextEditingController(); bool _triggerHovered = false; + @override + void initState() { + super.initState(); + _searchController.addListener(_onSearchChanged); + } + @override void dispose() { + _searchController.removeListener(_onSearchChanged); + _searchController.dispose(); _scrollController.dispose(); super.dispose(); } + void _onSearchChanged() { + setState(() {}); + if (_scrollController.hasClients) { + _scrollController.jumpTo(0); + } + } + + void _clearSearch() { + if (_searchController.text.isEmpty) return; + _searchController.clear(); + } + + List get _filteredThemes => + filterThemeDefinitions(widget.themes, _searchController.text); + bool get _enabled => !widget.isLoading; String get _triggerLabel { @@ -96,28 +140,7 @@ class _ThemePickerButtonState extends material.State { material.SizedBox( width: menuWidth, height: menuHeight, - child: material.Scrollbar( - controller: _scrollController, - thumbVisibility: widget.themes.length > 8, - child: material.ListView.builder( - controller: _scrollController, - primary: false, - padding: QueryaDropdownTokens.menuPadding, - itemCount: widget.themes.length, - itemBuilder: (context, index) { - final theme = widget.themes[index]; - return _ThemePickerRow( - definition: theme, - selected: theme.id == widget.selectedThemeId, - colorScheme: cs, - onSelected: () { - widget.onSelected(theme.id); - _controller.close(); - }, - ); - }, - ), - ), + child: _buildMenuPanel(context, cs), ), ], builder: (context, controller, child) { @@ -140,6 +163,102 @@ class _ThemePickerButtonState extends material.State { return anchor; } + material.Widget _buildMenuPanel(material.BuildContext context, ColorScheme cs) { + final filteredThemes = _filteredThemes; + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + + return material.Column( + children: [ + material.Padding( + padding: material.EdgeInsets.fromLTRB( + context.scaled(8), + context.scaled(8), + context.scaled(8), + context.scaled(4), + ), + child: material.TextField( + controller: _searchController, + style: material.TextStyle( + fontSize: context.scaled(QueryaDropdownTokens.fontSize), + color: cs.popoverForeground, + ), + decoration: material.InputDecoration( + isDense: true, + hintText: 'Search themes…', + hintStyle: material.TextStyle(color: cs.mutedForeground), + prefixIcon: material.Icon( + material.Icons.search, + size: context.scaled(18), + color: cs.mutedForeground, + ), + prefixIconConstraints: material.BoxConstraints( + minWidth: context.scaled(36), + minHeight: context.scaled(32), + ), + contentPadding: material.EdgeInsets.symmetric( + horizontal: context.scaled(8), + vertical: context.scaled(8), + ), + filled: true, + fillColor: cs.muted.withValues(alpha: 0.18), + border: material.OutlineInputBorder( + borderRadius: material.BorderRadius.circular(radius), + borderSide: material.BorderSide(color: cs.border), + ), + enabledBorder: material.OutlineInputBorder( + borderRadius: material.BorderRadius.circular(radius), + borderSide: material.BorderSide(color: cs.border), + ), + focusedBorder: material.OutlineInputBorder( + borderRadius: material.BorderRadius.circular(radius), + borderSide: material.BorderSide(color: cs.ring), + ), + ), + ), + ), + material.Expanded( + child: filteredThemes.isEmpty + ? material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(context.scaled(12)), + child: material.Text( + 'No themes match your search.', + textAlign: material.TextAlign.center, + style: material.TextStyle( + fontSize: context.scaled(12), + color: cs.mutedForeground, + ), + ), + ), + ) + : material.Scrollbar( + controller: _scrollController, + thumbVisibility: filteredThemes.length > 8, + child: material.ListView.builder( + controller: _scrollController, + primary: false, + padding: QueryaDropdownTokens.menuPadding, + itemCount: filteredThemes.length, + itemBuilder: (context, index) { + final theme = filteredThemes[index]; + return _ThemePickerRow( + definition: theme, + selected: theme.id == widget.selectedThemeId, + colorScheme: cs, + onSelected: () { + widget.onSelected(theme.id); + _clearSearch(); + _controller.close(); + }, + ); + }, + ), + ), + ), + ], + ); + } + material.Widget _buildTrigger({ required material.BuildContext context, required material.MenuController controller, @@ -212,6 +331,7 @@ class _ThemePickerButtonState extends material.State { if (controller.isOpen) { controller.close(); } else { + _clearSearch(); controller.open(); } } From b7309a2763092a2dd0e3ab5c9a61cc96e56e2395 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:41:27 +0300 Subject: [PATCH 48/72] test(settings): cover ThemePickerButton search filtering Closes #114. --- .../settings/theme_picker_button_test.dart | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index 2734ccb1..0ddb30fb 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -19,6 +19,31 @@ List _fakeThemes(int count) { } void main() { + group('filterThemeDefinitions', () { + final themes = _fakeThemes(10); + + test('filters by theme name', () { + final filtered = filterThemeDefinitions(themes, 'theme 03'); + expect(filtered, hasLength(1)); + expect(filtered.single.id, 'theme-3'); + }); + + test('filters by theme id', () { + final filtered = filterThemeDefinitions(themes, 'theme-7'); + expect(filtered, hasLength(1)); + expect(filtered.single.name, 'Theme 07'); + }); + + test('filters by source label', () { + final filtered = filterThemeDefinitions(themes, 'file'); + expect(filtered, isNotEmpty); + expect( + filtered.every((theme) => theme.source == ThemeSource.filesystem), + isTrue, + ); + }); + }); + group('ThemePickerButton', () { testWidgets('builds MenuAnchor trigger for many themes', (tester) async { final themes = _fakeThemes(60); @@ -157,4 +182,113 @@ void main() { expect(find.byType(material.ListView), findsNothing); }); }); + + group('ThemePickerButton search', () { + Future openMenu(WidgetTester tester) async { + await tester.tap(find.text('Theme 00')); + await tester.pumpAndSettle(); + } + + testWidgets('filters visible rows by theme name', (tester) async { + final themes = _fakeThemes(60); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await tester.enterText(find.byType(material.TextField), 'Theme 05'); + await tester.pump(); + + expect(find.text('Theme 01'), findsNothing); + expect(find.text('Theme 59'), findsNothing); + expect( + find.descendant( + of: find.byType(material.ListView), + matching: find.text('Theme 05'), + ), + findsOneWidget, + ); + }); + + testWidgets('shows empty message when filter has no results', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(20), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await tester.enterText(find.byType(material.TextField), 'zzzz-no-match'); + await tester.pump(); + + expect(find.text('No themes match your search.'), findsOneWidget); + expect(find.byType(material.ListView), findsNothing); + }); + + testWidgets('clearing search restores full list', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(20), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await tester.enterText(find.byType(material.TextField), 'Theme 05'); + await tester.pump(); + expect(find.text('Theme 01'), findsNothing); + + await tester.enterText(find.byType(material.TextField), ''); + await tester.pump(); + + expect(find.text('Theme 01'), findsOneWidget); + expect(find.text('No themes match your search.'), findsNothing); + }); + + testWidgets('typing in search does not call onSelected', (tester) async { + var selectionCount = 0; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(20), + selectedThemeId: 'theme-0', + onSelected: (_) => selectionCount++, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await tester.enterText(find.byType(material.TextField), 'Theme 03'); + await tester.pumpAndSettle(); + + expect(selectionCount, 0); + }); + }); } From e61a8da9d2d7ca09e380f7991534f92c0e467b42 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:49:38 +0300 Subject: [PATCH 49/72] feat(settings): add ThemePreviewCard with debounced hover preview Introduce a compact preview card and optional onPreviewTheme callback on ThemePickerButton so hovering a row loads preview data locally without applying the theme app-wide. --- .../settings/theme_picker_button.dart | 101 +++++++- lib/features/settings/theme_preview_card.dart | 244 ++++++++++++++++++ 2 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 lib/features/settings/theme_preview_card.dart diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index d874bc97..7541cc4d 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -1,9 +1,16 @@ +import 'dart:async'; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/theme_definition.dart'; +import 'package:querya_desktop/features/settings/theme_preview_card.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +/// Debounce delay before requesting a hover preview load. +const Duration themePreviewDebounce = Duration(milliseconds: 120); + /// Dedicated theme picker for large registry lists (50+ themes). class ThemePickerButton extends material.StatefulWidget { const ThemePickerButton({ @@ -11,6 +18,7 @@ class ThemePickerButton extends material.StatefulWidget { required this.themes, required this.selectedThemeId, required this.onSelected, + this.onPreviewTheme, this.isLoading = false, this.expandToParent = false, this.width, @@ -19,6 +27,10 @@ class ThemePickerButton extends material.StatefulWidget { final List themes; final String? selectedThemeId; final material.ValueChanged onSelected; + + /// Loads preview data for [themeId] on hover. Must not apply the theme. + final Future Function(String themeId)? onPreviewTheme; + final bool isLoading; final bool expandToParent; final double? width; @@ -54,6 +66,13 @@ class _ThemePickerButtonState extends material.State { final material.TextEditingController _searchController = material.TextEditingController(); bool _triggerHovered = false; + String? _previewThemeId; + String? _previewThemeLabel; + QueryaTheme? _previewTheme; + String? _previewError; + bool _previewLoading = false; + Timer? _previewDebounce; + int _previewRequestSerial = 0; @override void initState() { @@ -63,12 +82,67 @@ class _ThemePickerButtonState extends material.State { @override void dispose() { + _previewDebounce?.cancel(); _searchController.removeListener(_onSearchChanged); _searchController.dispose(); _scrollController.dispose(); super.dispose(); } + void _resetPreviewState() { + _previewDebounce?.cancel(); + _previewRequestSerial++; + _previewThemeId = null; + _previewThemeLabel = null; + _previewTheme = null; + _previewError = null; + _previewLoading = false; + } + + void _schedulePreview(ThemeDefinition definition) { + if (widget.onPreviewTheme == null) return; + + _previewDebounce?.cancel(); + final targetId = definition.id; + setState(() { + _previewThemeId = targetId; + _previewThemeLabel = definition.name; + }); + + _previewDebounce = Timer(themePreviewDebounce, () { + if (!mounted || _previewThemeId != targetId) return; + setState(() { + _previewLoading = true; + _previewTheme = null; + _previewError = null; + }); + unawaited(_loadPreview(targetId)); + }); + } + + Future _loadPreview(String themeId) async { + final loader = widget.onPreviewTheme; + if (loader == null || !mounted || _previewThemeId != themeId) return; + + final requestId = ++_previewRequestSerial; + final result = await loader(themeId); + if (!mounted || requestId != _previewRequestSerial) return; + + setState(() { + _previewLoading = false; + switch (result) { + case ThemePreviewSuccess(:final theme): + _previewTheme = theme; + _previewError = null; + case ThemePreviewFailure(:final message): + _previewTheme = null; + _previewError = message; + case ThemePreviewLoading(): + _previewLoading = true; + } + }); + } + void _onSearchChanged() { setState(() {}); if (_scrollController.hasClients) { @@ -216,6 +290,21 @@ class _ThemePickerButtonState extends material.State { ), ), ), + if (widget.onPreviewTheme != null) + material.Padding( + padding: material.EdgeInsets.fromLTRB( + context.scaled(8), + context.scaled(4), + context.scaled(8), + context.scaled(4), + ), + child: ThemePreviewCard( + theme: _previewTheme, + errorMessage: _previewError, + isLoading: _previewLoading, + label: _previewThemeLabel, + ), + ), material.Expanded( child: filteredThemes.isEmpty ? material.Center( @@ -245,9 +334,13 @@ class _ThemePickerButtonState extends material.State { definition: theme, selected: theme.id == widget.selectedThemeId, colorScheme: cs, + onHover: widget.onPreviewTheme == null + ? null + : () => _schedulePreview(theme), onSelected: () { widget.onSelected(theme.id); _clearSearch(); + _resetPreviewState(); _controller.close(); }, ); @@ -332,6 +425,7 @@ class _ThemePickerButtonState extends material.State { controller.close(); } else { _clearSearch(); + _resetPreviewState(); controller.open(); } } @@ -349,12 +443,14 @@ class _ThemePickerRow extends material.StatefulWidget { required this.selected, required this.colorScheme, required this.onSelected, + this.onHover, }); final ThemeDefinition definition; final bool selected; final ColorScheme colorScheme; final material.VoidCallback onSelected; + final material.VoidCallback? onHover; @override material.State<_ThemePickerRow> createState() => _ThemePickerRowState(); @@ -377,7 +473,10 @@ class _ThemePickerRowState extends material.State<_ThemePickerRow> { return material.MouseRegion( cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), + onEnter: (_) { + setState(() => _hovered = true); + widget.onHover?.call(); + }, onExit: (_) => setState(() => _hovered = false), child: material.Material( type: material.MaterialType.transparency, diff --git a/lib/features/settings/theme_preview_card.dart b/lib/features/settings/theme_preview_card.dart new file mode 100644 index 00000000..c8d8afa7 --- /dev/null +++ b/lib/features/settings/theme_preview_card.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Result of an async theme preview load for [ThemePreviewCard]. +sealed class ThemePreviewResult { + const ThemePreviewResult(); + + const factory ThemePreviewResult.theme(QueryaTheme theme) = ThemePreviewSuccess; + const factory ThemePreviewResult.error(String message) = ThemePreviewFailure; + const factory ThemePreviewResult.loading() = ThemePreviewLoading; +} + +final class ThemePreviewSuccess extends ThemePreviewResult { + const ThemePreviewSuccess(this.theme); + final QueryaTheme theme; +} + +final class ThemePreviewFailure extends ThemePreviewResult { + const ThemePreviewFailure(this.message); + final String message; +} + +final class ThemePreviewLoading extends ThemePreviewResult { + const ThemePreviewLoading(); +} + +/// Compact visual preview for a [QueryaTheme] without applying it app-wide. +class ThemePreviewCard extends material.StatelessWidget { + const ThemePreviewCard({ + super.key, + this.theme, + this.errorMessage, + this.isLoading = false, + this.label, + }); + + final QueryaTheme? theme; + final String? errorMessage; + final bool isLoading; + final String? label; + + @override + material.Widget build(material.BuildContext context) { + final appScheme = Theme.of(context).colorScheme; + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + + if (isLoading) { + return _shell( + context: context, + radius: radius, + borderColor: appScheme.border, + child: material.Row( + children: [ + material.SizedBox( + width: context.scaled(14), + height: context.scaled(14), + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: appScheme.mutedForeground, + ), + ), + material.SizedBox(width: context.scaled(8)), + material.Text( + 'Loading preview…', + style: material.TextStyle( + fontSize: context.scaled(12), + color: appScheme.mutedForeground, + ), + ), + ], + ), + ); + } + + if (errorMessage != null) { + return _shell( + context: context, + radius: radius, + borderColor: appScheme.destructive.withValues(alpha: 0.45), + child: material.Text( + errorMessage!, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: context.scaled(12), + color: appScheme.destructive, + ), + ), + ); + } + + final previewTheme = theme; + if (previewTheme == null) { + return _shell( + context: context, + radius: radius, + borderColor: appScheme.border, + child: material.Text( + 'Hover a theme to preview.', + style: material.TextStyle( + fontSize: context.scaled(12), + color: appScheme.mutedForeground, + ), + ), + ); + } + + final scheme = previewTheme.colorScheme; + final workbench = previewTheme.workbench; + final editor = previewTheme.editor; + + return _shell( + context: context, + radius: radius, + borderColor: appScheme.border, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + if (label != null && label!.isNotEmpty) + material.Padding( + padding: material.EdgeInsets.only(bottom: context.scaled(6)), + child: material.Text( + label!, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: context.scaled(11), + fontWeight: material.FontWeight.w600, + color: appScheme.popoverForeground, + ), + ), + ), + material.Row( + children: [ + _Swatch(color: scheme.background, label: 'Bg'), + material.SizedBox(width: context.scaled(6)), + _Swatch(color: workbench.surface, label: 'Surface'), + material.SizedBox(width: context.scaled(6)), + _Swatch(color: scheme.primary, label: 'Primary'), + material.SizedBox(width: context.scaled(6)), + _Swatch(color: workbench.accent, label: 'Accent'), + material.SizedBox(width: context.scaled(8)), + material.Expanded( + child: material.Container( + padding: material.EdgeInsets.symmetric( + horizontal: context.scaled(8), + vertical: context.scaled(6), + ), + decoration: material.BoxDecoration( + color: workbench.surface, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all( + color: scheme.border.withValues(alpha: 0.7), + ), + ), + child: material.Text( + 'Sample text', + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: context.scaled(12), + color: scheme.foreground, + ), + ), + ), + ), + ], + ), + material.SizedBox(height: context.scaled(6)), + material.Container( + height: context.scaled(10), + width: double.infinity, + decoration: material.BoxDecoration( + color: editor.background, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all( + color: scheme.border.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ); + } + + material.Widget _shell({ + required material.BuildContext context, + required double radius, + required Color borderColor, + required material.Widget child, + }) { + return material.Container( + width: double.infinity, + padding: material.EdgeInsets.all(context.scaled(8)), + decoration: material.BoxDecoration( + color: Theme.of(context).colorScheme.muted.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: borderColor), + ), + child: child, + ); + } +} + +class _Swatch extends material.StatelessWidget { + const _Swatch({ + required this.color, + required this.label, + }); + + final Color color; + final String label; + + @override + material.Widget build(material.BuildContext context) { + final radius = context.scaled(4); + return material.Column( + children: [ + material.Container( + width: context.scaled(18), + height: context.scaled(18), + decoration: material.BoxDecoration( + color: color, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all( + color: material.Colors.black.withValues(alpha: 0.12), + ), + ), + ), + material.SizedBox(height: context.scaled(2)), + material.Text( + label, + style: material.TextStyle( + fontSize: context.scaled(9), + color: Theme.of(context).colorScheme.mutedForeground, + ), + ), + ], + ); + } +} From cf6e2312b0aef265c8e5543b9f8728305fc48445 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:49:38 +0300 Subject: [PATCH 50/72] test(settings): cover ThemePreviewCard and picker hover preview Closes #115 --- .../settings/theme_picker_button_test.dart | 125 ++++++++++++++++++ .../settings/theme_preview_card_test.dart | 72 ++++++++++ 2 files changed, 197 insertions(+) create mode 100644 test/features/settings/theme_preview_card_test.dart diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index 0ddb30fb..dcfe929f 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -1,7 +1,10 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/theme_definition.dart'; import 'package:querya_desktop/features/settings/theme_picker_button.dart'; +import 'package:querya_desktop/features/settings/theme_preview_card.dart'; import '../../support/querya_theme_test_shell.dart'; @@ -291,4 +294,126 @@ void main() { expect(selectionCount, 0); }); }); + + group('ThemePickerButton preview', () { + Future openMenu(WidgetTester tester) async { + await tester.tap(find.text('Theme 00')); + await tester.pumpAndSettle(); + } + + Future hoverRow(WidgetTester tester, String rowLabel) async { + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text(rowLabel))); + await tester.pump(); + } + + testWidgets('hover does not call onSelected', (tester) async { + var selectionCount = 0; + var previewCount = 0; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(20), + selectedThemeId: 'theme-0', + onSelected: (_) => selectionCount++, + onPreviewTheme: (_) async { + previewCount++; + return const ThemePreviewResult.theme(QueryaTheme.darkDefault); + }, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await hoverRow(tester, 'Theme 01'); + await tester.pump(themePreviewDebounce); + await tester.pump(); + + expect(selectionCount, 0); + expect(previewCount, 1); + expect(find.text('Sample text'), findsOneWidget); + }); + + testWidgets('preview future resolves and card updates after debounce', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(20), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + onPreviewTheme: (_) async { + await Future.delayed(const Duration(milliseconds: 20)); + return const ThemePreviewResult.theme(QueryaTheme.lightDefault); + }, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await hoverRow(tester, 'Theme 02'); + await tester.pump(themePreviewDebounce); + expect(find.text('Loading preview…'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 30)); + expect(find.text('Theme 02'), findsWidgets); + expect(find.text('Sample text'), findsOneWidget); + expect(find.text('Loading preview…'), findsNothing); + }); + + testWidgets('broken preview shows fallback error in card', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(20), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + onPreviewTheme: (_) async { + return const ThemePreviewResult.error('Could not parse theme'); + }, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await hoverRow(tester, 'Theme 03'); + await tester.pump(themePreviewDebounce); + await tester.pump(); + + expect(find.text('Could not parse theme'), findsOneWidget); + expect(find.text('Sample text'), findsNothing); + }); + + testWidgets('shows preview card only when onPreviewTheme is provided', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(5), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + expect(find.byType(ThemePreviewCard), findsNothing); + }); + }); } diff --git a/test/features/settings/theme_preview_card_test.dart b/test/features/settings/theme_preview_card_test.dart new file mode 100644 index 00000000..9cf26532 --- /dev/null +++ b/test/features/settings/theme_preview_card_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/features/settings/theme_preview_card.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('ThemePreviewCard', () { + testWidgets('shows placeholder when no theme is provided', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ThemePreviewCard(), + ), + ), + ); + await tester.pump(); + + expect(find.text('Hover a theme to preview.'), findsOneWidget); + }); + + testWidgets('renders preview swatches and sample text from QueryaTheme', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ThemePreviewCard( + theme: QueryaTheme.darkDefault, + label: 'Querya Dark', + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Querya Dark'), findsOneWidget); + expect(find.text('Sample text'), findsOneWidget); + expect(find.text('Bg'), findsOneWidget); + expect(find.text('Surface'), findsOneWidget); + expect(find.text('Primary'), findsOneWidget); + expect(find.text('Accent'), findsOneWidget); + }); + + testWidgets('shows loading state', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ThemePreviewCard(isLoading: true), + ), + ), + ); + await tester.pump(); + + expect(find.text('Loading preview…'), findsOneWidget); + expect(find.byType(material.CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows non-blocking error message', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ThemePreviewCard(errorMessage: 'Theme file is invalid'), + ), + ), + ); + await tester.pump(); + + expect(find.text('Theme file is invalid'), findsOneWidget); + }); + }); +} From cba6907d58bfc8b296f2819898313fcce354cba5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:01:27 +0300 Subject: [PATCH 51/72] feat(settings): wire ThemePickerButton into Preferences appearance Replace the Color preset dropdown with a registry-backed Theme row, inject built-in Querya Dark/Light definitions, and route selection and hover preview through ThemeController. --- lib/core/theme/theme_controller.dart | 98 ++++++++++++++++++- .../preferences_appearance_section.dart | 64 ++++++------ 2 files changed, 129 insertions(+), 33 deletions(-) diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 05184492..29ae0193 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -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 _builtinThemeDefinitions = [ + builtinQueryaDarkDefinition, + builtinQueryaLightDefinition, + ]; + ThemeRegistryService _registryService; ThemeController._({ThemeRegistryService? registryService}) @@ -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 get userColorOverrides => Map.unmodifiable(_userOverrides); @@ -171,7 +205,9 @@ class ThemeController extends ChangeNotifier { _themeAnimationEnabled = await AppSettings.instance.getThemeAnimationEnabled(); - _availableThemes = await _registryService.loadThemeDefinitions(); + _availableThemes = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); await _restoreSelectedRegistryTheme(); _loaded = true; @@ -179,11 +215,22 @@ class ThemeController extends ChangeNotifier { } Future loadAvailableThemes() async { - _availableThemes = await _registryService.loadThemeDefinitions(); + _availableThemes = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); notifyListeners(); } Future 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.'; @@ -214,6 +261,19 @@ class ThemeController extends ChangeNotifier { } Future 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( @@ -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(): @@ -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(); } @@ -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; @@ -399,6 +463,30 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.clearSelectedThemeRegistry(); } + Future _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 _mergeBuiltinThemes(List scanned) { + final merged = [..._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, diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index c5706448..032e361a 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -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]. @@ -43,8 +45,16 @@ class _PreferencesAppearanceSectionState await _controller.setThemeMode(mode); } - Future _setPreset(QueryaThemePreset preset) async { - await _controller.setPreset(preset); + Future _setThemeById(String id) async { + await _controller.setThemeById(id); + } + + Future _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 _pickAndImportTheme() async { @@ -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, @@ -125,30 +133,30 @@ class _PreferencesAppearanceSectionState ), const material.SizedBox(height: 12), PreferencesFieldRow( - label: 'Color preset', - control: PreferencesDropdownMenu( - 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: From 124d51d009a8c2c89e49f8eac8d7ca0c3750c6ca Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:01:27 +0300 Subject: [PATCH 52/72] test(settings): cover Preferences theme picker integration Closes #116 --- test/core/theme/theme_controller_test.dart | 42 +++++++ .../preferences_appearance_section_test.dart | 113 ++++++++++++++++++ .../settings/theme_picker_button_test.dart | 44 +++++++ 3 files changed, 199 insertions(+) create mode 100644 test/features/settings/preferences_appearance_section_test.dart diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index d767ac26..4e852fa5 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -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 { @@ -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()); + 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); + }); }); } diff --git a/test/features/settings/preferences_appearance_section_test.dart b/test/features/settings/preferences_appearance_section_test.dart new file mode 100644 index 00000000..40cbeb54 --- /dev/null +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +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/theme_controller.dart'; +import 'package:querya_desktop/core/theme/theme_import_service.dart'; +import 'package:querya_desktop/core/theme/theme_registry_service.dart'; +import 'package:querya_desktop/features/settings/preferences_appearance_section.dart'; +import 'package:querya_desktop/features/settings/theme_picker_button.dart'; + +import '../../support/querya_theme_test_shell.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; + late Directory themesDir; + late Directory importedDir; + late ThemeRegistryService registry; + + setUpAll(() async { + tempDir = await Directory.systemTemp + .createTemp('querya_preferences_appearance_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + 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); + await ThemeController.instance.load(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + 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(); + }); + + group('PreferencesAppearanceSection', () { + Future pumpSection(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const material.Size(1280, 900)); + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: material.SizedBox( + width: 640, + child: PreferencesAppearanceSection(), + ), + ), + ), + ); + await tester.pump(); + } + + testWidgets('shows built-in themes in ThemePickerButton', (tester) async { + await pumpSection(tester); + + expect(find.text('Theme'), findsOneWidget); + expect(find.text('Color preset'), findsNothing); + expect(find.text('Querya Dark'), findsOneWidget); + expect(find.byType(ThemePickerButton), findsOneWidget); + + await tester.tap(find.text('Querya Dark')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('Querya Light'), findsOneWidget); + }); + + testWidgets('import and reset buttons remain visible', (tester) async { + await pumpSection(tester); + + expect(find.text('Import theme…'), findsOneWidget); + expect(find.text('Reset appearance'), findsOneWidget); + }); + }); +} diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index dcfe929f..ec3726e6 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_definition.dart'; import 'package:querya_desktop/features/settings/theme_picker_button.dart'; import 'package:querya_desktop/features/settings/theme_preview_card.dart'; @@ -415,5 +416,48 @@ void main() { expect(find.byType(ThemePreviewCard), findsNothing); }); + + testWidgets('tap row still selects when onPreviewTheme is provided', + (tester) async { + String? picked; + const themes = [ + ThemeDefinition( + id: ThemeController.builtinQueryaDarkId, + name: 'Querya Dark', + source: ThemeSource.builtin, + format: ThemeFormat.queryaCustom, + isDark: true, + ), + ThemeDefinition( + id: ThemeController.builtinQueryaLightId, + name: 'Querya Light', + source: ThemeSource.builtin, + format: ThemeFormat.queryaCustom, + isDark: false, + ), + ]; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: ThemeController.builtinQueryaDarkId, + expandToParent: true, + onSelected: (id) => picked = id, + onPreviewTheme: (_) async => + const ThemePreviewResult.theme(QueryaTheme.darkDefault), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Querya Dark')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Querya Light')); + await tester.pumpAndSettle(); + + expect(picked, ThemeController.builtinQueryaLightId); + }); }); } From fc4832456d82c27c41d5b1efba51ffe71b4a5818 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:21:44 +0300 Subject: [PATCH 53/72] feat(settings): add Refresh themes action in Preferences Expose isLoadingAvailableThemes on ThemeController, rescan registry definitions without reloading the active theme, and wire a non-blocking Refresh themes button plus picker loading state in Appearance. --- lib/core/theme/theme_controller.dart | 40 +++++++++++++++++-- .../preferences_appearance_section.dart | 14 +++++++ 2 files changed, 51 insertions(+), 3 deletions(-) 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'), From 42339dd5cf87065d4190f86fb5140dab114e696c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:21:44 +0300 Subject: [PATCH 54/72] test(settings): cover theme refresh loading and list rescan Closes #117 --- test/core/theme/theme_controller_test.dart | 77 +++++++++++++++++++ .../preferences_appearance_section_test.dart | 8 +- 2 files changed, 84 insertions(+), 1 deletion(-) 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); }); }); From 9fd340691352828db5cbe8f58925a9675b7519c0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:26:52 +0300 Subject: [PATCH 55/72] feat(theme): import theme files into user themes registry Add registry-backed importThemeFile with deduplication by content hash, slugified VS Code filenames, and suffixed custom theme ids on conflicts. Wire Preferences Import theme to importRegistryThemeFile while keeping legacy imported.json flow intact. --- lib/core/theme/theme_controller.dart | 17 ++ lib/core/theme/theme_import_service.dart | 41 ++++ lib/core/theme/theme_registry_service.dart | 203 ++++++++++++++++++ .../preferences_appearance_section.dart | 6 +- 4 files changed, 264 insertions(+), 3 deletions(-) diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index c187a40c..6f995c1e 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -360,6 +360,23 @@ class ThemeController extends ChangeNotifier { _notifyThemeChanged(); } + /// Copies a theme into the user themes directory and activates it. + Future importRegistryThemeFile( + String path, + ) async { + final result = await _registryService.importThemeFile(path); + switch (result) { + case ThemeDefinitionImportSuccess(:final definition): + _availableThemes = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); + await setThemeById(definition.id); + case ThemeDefinitionImportFailure(): + notifyListeners(); + } + return result; + } + /// Parses a VS Code theme file, persists it, and activates the imported preset. Future importThemeFromFile(String path) async { final result = await ThemeImportService.importFromPath(path); diff --git a/lib/core/theme/theme_import_service.dart b/lib/core/theme/theme_import_service.dart index e47ece44..8787d884 100644 --- a/lib/core/theme/theme_import_service.dart +++ b/lib/core/theme/theme_import_service.dart @@ -4,6 +4,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'parser/vscode_theme_manifest.dart'; +import 'theme_definition.dart'; /// Result of importing a VS Code theme file. sealed class ThemeImportResult { @@ -31,11 +32,51 @@ class ThemeImportFailure extends ThemeImportResult { final String message; } +/// Result of copying a theme file into the user themes directory. +sealed class ThemeDefinitionImportResult { + const ThemeDefinitionImportResult(); +} + +final class ThemeDefinitionImportSuccess extends ThemeDefinitionImportResult { + const ThemeDefinitionImportSuccess({ + required this.definition, + required this.reusedExisting, + }); + + final ThemeDefinition definition; + final bool reusedExisting; +} + +final class ThemeDefinitionImportFailure extends ThemeDefinitionImportResult { + const ThemeDefinitionImportFailure(this.message); + final String message; +} + /// Parses and persists an imported VS Code theme under app support. abstract final class ThemeImportService { static const String legacyImportedThemeId = 'imported'; static const String storedFileName = 'imported.json'; + /// Lowercase slug for VS Code theme filenames. + static String slugifyThemeName(String name) { + final slug = name + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '-') + .replaceAll(RegExp(r'-+'), '-') + .replaceAll(RegExp(r'^-|-$'), ''); + return slug.isEmpty ? 'vscode-theme' : slug; + } + + /// Safe basename for theme files (without extension). + static String safeThemeFileBase(String value) { + final safe = value + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9._-]+'), '-') + .replaceAll(RegExp(r'-+'), '-') + .replaceAll(RegExp(r'^-|-$'), ''); + return safe.isEmpty ? 'theme' : safe; + } + /// Path to the persisted legacy import copy under app support. static Future persistedImportFile() => _storedThemeFile(); diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart index eb8154f6..1a75a63b 100644 --- a/lib/core/theme/theme_registry_service.dart +++ b/lib/core/theme/theme_registry_service.dart @@ -75,6 +75,92 @@ class ThemeRegistryService { return List.unmodifiable(definitions); } + /// Validates [sourcePath], copies into the user themes directory, and returns + /// the scanned [ThemeDefinition]. + Future importThemeFile(String sourcePath) async { + try { + final source = File(sourcePath); + if (!await source.exists()) { + return const ThemeDefinitionImportFailure('Theme file not found.'); + } + + final raw = await source.readAsString(); + final hash = _contentHash(raw); + final json = _decodeRoot(raw); + if (json == null) { + return const ThemeDefinitionImportFailure('Invalid JSON.'); + } + + final themesDir = await _userThemesDirectory(); + if (!await themesDir.exists()) { + await themesDir.create(recursive: true); + } + + final schema = json['schema']?.toString(); + late final String logicalId; + late final String preferredBaseName; + late String contentToWrite; + + if (schema == queryaThemeSchemaV1) { + final manifest = QueryaThemeManifest.fromJsonString(raw); + logicalId = manifest.id; + preferredBaseName = ThemeImportService.safeThemeFileBase(manifest.id); + contentToWrite = raw; + } else { + final manifest = VsCodeThemeManifest.fromJsonString(raw); + if (manifest.colors.isEmpty) { + return const ThemeDefinitionImportFailure( + 'Theme file has no "colors" section to import.', + ); + } + final displayName = manifest.name?.trim().isNotEmpty == true + ? manifest.name!.trim() + : p.basenameWithoutExtension(sourcePath); + preferredBaseName = ThemeImportService.slugifyThemeName(displayName); + logicalId = preferredBaseName; + contentToWrite = raw; + } + + var resolved = await _resolveImportDestination( + themesDir: themesDir, + hash: hash, + logicalId: logicalId, + preferredBaseName: preferredBaseName, + ); + + if (!resolved.reused && + schema == queryaThemeSchemaV1 && + resolved.renamedId != null) { + contentToWrite = _rewriteCustomThemeId(raw, resolved.renamedId!); + } + + if (!resolved.reused) { + await resolved.file.writeAsString(contentToWrite); + } + + final definition = + await _definitionFromFile(resolved.file, ThemeSource.filesystem); + if (definition == null) { + return const ThemeDefinitionImportFailure( + 'Failed to index imported theme.', + ); + } + + return ThemeDefinitionImportSuccess( + definition: definition, + reusedExisting: resolved.reused, + ); + } on QueryaThemeManifestParseException catch (e) { + return ThemeDefinitionImportFailure(e.message); + } on VsCodeThemeParseException catch (e) { + return ThemeDefinitionImportFailure(e.message); + } on IOException catch (e) { + return ThemeDefinitionImportFailure(e.toString()); + } on Object catch (e) { + return ThemeDefinitionImportFailure(e.toString()); + } + } + /// Parses a scanned [definition] into a runtime [QueryaTheme]. Future loadTheme(ThemeDefinition definition) async { final path = definition.path; @@ -361,6 +447,111 @@ class ThemeRegistryService { return hash.toRadixString(16).padLeft(8, '0'); } + Future<_ResolvedImportDestination> _resolveImportDestination({ + required Directory themesDir, + required String hash, + required String logicalId, + required String preferredBaseName, + }) async { + File? sameIdFile; + + await for (final entity in themesDir.list(followLinks: false)) { + if (entity is Directory) continue; + if (entity is! File) continue; + + final name = p.basename(entity.path); + if (name == ThemeImportService.storedFileName) continue; + + final ext = p.extension(entity.path).toLowerCase(); + if (ext != '.json' && ext != '.jsonc') continue; + + late final String existingRaw; + try { + existingRaw = await entity.readAsString(); + } on IOException { + continue; + } + + if (_contentHash(existingRaw) == hash) { + return _ResolvedImportDestination(file: entity, reused: true); + } + + final definition = + await _definitionFromFile(entity, ThemeSource.filesystem); + if (definition?.id == logicalId) { + sameIdFile = entity; + } + } + + if (sameIdFile != null) { + final renamedId = await _nextRenamedThemeId(themesDir, logicalId); + final baseName = ThemeImportService.safeThemeFileBase(renamedId); + final primary = File(p.join(themesDir.path, '$baseName.json')); + final file = await primary.exists() + ? await _nextAvailableThemeFile(themesDir, baseName, startSuffix: 2) + : primary; + return _ResolvedImportDestination( + file: file, + reused: false, + renamedId: renamedId, + ); + } + + final primary = File(p.join(themesDir.path, '$preferredBaseName.json')); + if (!await primary.exists()) { + return _ResolvedImportDestination(file: primary, reused: false); + } + + final file = await _nextAvailableThemeFile( + themesDir, + preferredBaseName, + startSuffix: 2, + ); + return _ResolvedImportDestination(file: file, reused: false); + } + + Future _nextRenamedThemeId(Directory themesDir, String baseId) async { + for (var suffix = 2; suffix < 1000; suffix++) { + final candidate = '$baseId-$suffix'; + final taken = await _themeIdExists(themesDir, candidate); + if (!taken) return candidate; + } + return '$baseId-${_contentHash(baseId)}'; + } + + Future _themeIdExists(Directory themesDir, String id) async { + await for (final entity in themesDir.list(followLinks: false)) { + if (entity is! File) continue; + final ext = p.extension(entity.path).toLowerCase(); + if (ext != '.json' && ext != '.jsonc') continue; + final definition = + await _definitionFromFile(entity, ThemeSource.filesystem); + if (definition?.id == id) return true; + } + return false; + } + + Future _nextAvailableThemeFile( + Directory themesDir, + String baseName, { + int startSuffix = 2, + }) async { + for (var suffix = startSuffix; suffix < 1000; suffix++) { + final candidate = File(p.join(themesDir.path, '$baseName-$suffix.json')); + if (!await candidate.exists()) return candidate; + } + return File( + p.join(themesDir.path, '$baseName-${DateTime.now().millisecondsSinceEpoch}.json'), + ); + } + + String _rewriteCustomThemeId(String raw, String newId) { + final decoded = jsonDecode(stripJsonc(raw)); + if (decoded is! Map) return raw; + decoded['id'] = newId; + return const JsonEncoder.withIndent(' ').convert(decoded); + } + void _logScanError(String path, Object error) { if (kDebugMode) { debugPrint('ThemeRegistryService: skipped $path ($error)'); @@ -368,6 +559,18 @@ class ThemeRegistryService { } } +class _ResolvedImportDestination { + const _ResolvedImportDestination({ + required this.file, + required this.reused, + this.renamedId, + }); + + final File file; + final bool reused; + final String? renamedId; +} + class _ThemeLruCache { _ThemeLruCache({required this.maxEntries}); diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index a23dd277..5ee9b966 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -74,12 +74,12 @@ class _PreferencesAppearanceSectionState if (file == null) return; final path = file.path; if (path.isEmpty) return; - final result = await _controller.importThemeFromFile(path); + final result = await _controller.importRegistryThemeFile(path); if (!mounted) return; switch (result) { - case ThemeImportSuccess(): + case ThemeDefinitionImportSuccess(): setState(() => _importError = null); - case ThemeImportFailure(:final message): + case ThemeDefinitionImportFailure(:final message): setState(() => _importError = message); } } finally { From 931e23a466c826e9c3c61c540501cde7d9d05bea Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:26:52 +0300 Subject: [PATCH 56/72] test(theme): cover registry theme import and duplicate handling Closes #118 --- test/core/theme/theme_controller_test.dart | 17 ++++ .../core/theme/theme_import_service_test.dart | 8 ++ .../theme/theme_registry_service_test.dart | 79 +++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 0ef9b839..88ada938 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -11,6 +11,7 @@ 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_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'; @@ -359,5 +360,21 @@ void main() { expect(c.isLoadingAvailableThemes, isFalse); }); + + test('importRegistryThemeFile adds theme to registry and selects it', () async { + final c = ThemeController.instance; + await c.load(); + final source = File(p.join('test/fixtures/themes', 'querya_custom_dark.json')); + + final result = await c.importRegistryThemeFile(source.path); + + expect(result, isA()); + expect(c.selectedThemeId, 'fixture-custom-dark'); + expect( + c.availableThemes.map((theme) => theme.id), + contains('fixture-custom-dark'), + ); + expect(c.activeTheme.colorScheme.primary, parseQueryaThemeColor('#38BDF8')); + }); }); } diff --git a/test/core/theme/theme_import_service_test.dart b/test/core/theme/theme_import_service_test.dart index 0a6fa510..e6ae274a 100644 --- a/test/core/theme/theme_import_service_test.dart +++ b/test/core/theme/theme_import_service_test.dart @@ -69,4 +69,12 @@ void main() { await ThemeImportService.importFromPath('/no/such/theme.json'); expect(result, isA()); }); + + test('slugifyThemeName produces filesystem-safe slug', () { + expect( + ThemeImportService.slugifyThemeName('Fixture Dark Subset'), + 'fixture-dark-subset', + ); + expect(ThemeImportService.safeThemeFileBase('My Theme!'), 'my-theme'); + }); } diff --git a/test/core/theme/theme_registry_service_test.dart b/test/core/theme/theme_registry_service_test.dart index ae305dfd..d870d2a6 100644 --- a/test/core/theme/theme_registry_service_test.dart +++ b/test/core/theme/theme_registry_service_test.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.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_import_service.dart'; import 'package:querya_desktop/core/theme/theme_load_result.dart'; import 'package:querya_desktop/core/theme/theme_registry_service.dart'; @@ -224,4 +225,82 @@ void main() { expect((result as ThemeLoadFailure).message, contains('id')); }); }); + + group('ThemeRegistryService.importThemeFile', () { + test('imports custom theme into user themes directory', () async { + final source = File(p.join('test/fixtures/themes', 'querya_custom_dark.json')); + final result = await registry.importThemeFile(source.path); + + expect(result, isA()); + final success = result as ThemeDefinitionImportSuccess; + expect(success.reusedExisting, isFalse); + expect(success.definition.id, 'fixture-custom-dark'); + expect(success.definition.source, ThemeSource.filesystem); + expect( + await File(p.join(themesDir.path, 'fixture-custom-dark.json')).exists(), + isTrue, + ); + + final definitions = await registry.loadThemeDefinitions(); + expect( + definitions.map((d) => d.id), + contains('fixture-custom-dark'), + ); + }); + + test('imports VS Code theme with slugified filename', () async { + final source = File(p.join('test/fixtures/themes', 'dark_subset.json')); + final result = await registry.importThemeFile(source.path); + + expect(result, isA()); + final success = result as ThemeDefinitionImportSuccess; + expect(success.definition.format, ThemeFormat.vscode); + expect( + p.basename(success.definition.path!), + 'fixture-dark-subset.json', + ); + }); + + test('reuses existing file when content hash matches', () async { + final source = File(p.join('test/fixtures/themes', 'querya_custom_minimal.json')); + final first = await registry.importThemeFile(source.path); + expect(first, isA()); + final firstSuccess = first as ThemeDefinitionImportSuccess; + + final second = await registry.importThemeFile(source.path); + expect(second, isA()); + final secondSuccess = second as ThemeDefinitionImportSuccess; + expect(secondSuccess.reusedExisting, isTrue); + expect(secondSuccess.definition.path, firstSuccess.definition.path); + + final themeFiles = themesDir + .listSync() + .whereType() + .where((f) => p.extension(f.path) == '.json') + .length; + expect(themeFiles, 1); + }); + + test('suffixes custom theme id when same id has different content', () async { + final source = File(p.join('test/fixtures/themes', 'querya_custom_minimal.json')); + final first = await registry.importThemeFile(source.path); + expect(first, isA()); + + final modified = File(p.join(tempDir.path, 'modified-custom.json')); + final raw = await source.readAsString(); + await modified.writeAsString(raw.replaceFirst('#FF00AA', '#00FFAA')); + + final second = await registry.importThemeFile(modified.path); + expect(second, isA()); + final secondSuccess = second as ThemeDefinitionImportSuccess; + expect(secondSuccess.reusedExisting, isFalse); + expect(secondSuccess.definition.id, 'fixture-custom-minimal-2'); + + final definitions = await registry.loadThemeDefinitions(); + expect( + definitions.map((d) => d.id), + containsAll(['fixture-custom-minimal', 'fixture-custom-minimal-2']), + ); + }); + }); } From 09eaa91f87698441faf7ee82126123d22d8f7fdf Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:30:30 +0300 Subject: [PATCH 57/72] fix ci --- test/core/theme/theme_controller_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 88ada938..48a55c8c 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -11,7 +11,6 @@ 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_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'; From 8fd69cb245f55e74363dacffb3b41e34b98d6964 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:33:01 +0300 Subject: [PATCH 58/72] feat(theme): ship built-in themes from Flutter asset bundle Bundle cyberpunk-neon in assets/themes and load it via ThemeRegistryService so release builds expose curated themes without depending on repo samples. --- assets/themes/cyberpunk-neon.json | 96 ++++++++++++++ lib/core/theme/builtin_theme_assets.dart | 11 ++ lib/core/theme/theme_controller.dart | 4 +- lib/core/theme/theme_registry_service.dart | 143 +++++++++++++++++---- pubspec.yaml | 1 + 5 files changed, 228 insertions(+), 27 deletions(-) create mode 100644 assets/themes/cyberpunk-neon.json create mode 100644 lib/core/theme/builtin_theme_assets.dart 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/ From 8121c2e71c3849453d49aecea534a67e10e50d72 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:33:03 +0300 Subject: [PATCH 59/72] test(theme): cover built-in asset theme registry loading Closes #119 --- test/core/theme/theme_controller_test.dart | 21 ++++ .../theme_registry_builtin_assets_test.dart | 117 ++++++++++++++++++ .../core/theme/theme_registry_cache_test.dart | 2 + .../theme_registry_legacy_import_test.dart | 1 + .../theme/theme_registry_service_test.dart | 1 + .../preferences_appearance_section_test.dart | 7 ++ test/fixtures/themes/cyberpunk-neon.json | 96 ++++++++++++++ 7 files changed, 245 insertions(+) create mode 100644 test/core/theme/theme_registry_builtin_assets_test.dart create mode 100644 test/fixtures/themes/cyberpunk-neon.json 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" + } + } + ] +} From c260ff5df212c4cf5b7cbdad0074c6c0e3ccd355 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:37:29 +0300 Subject: [PATCH 60/72] feat(theme): document user themes folder and add open-folder control Explain app support themes paths in docs and surface a Preferences hint plus Open themes folder button so users know where to drop .json/.jsonc files. --- docs/theme-custom-json.md | 19 ++++++- docs/theme-import.md | 43 +++++++++++++-- lib/core/platform/open_directory.dart | 38 +++++++++++++ .../preferences_appearance_section.dart | 54 ++++++++++++++++++- 4 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 lib/core/platform/open_directory.dart diff --git a/docs/theme-custom-json.md b/docs/theme-custom-json.md index c2a09f49..b77f3991 100644 --- a/docs/theme-custom-json.md +++ b/docs/theme-custom-json.md @@ -4,7 +4,7 @@ Querya supports two theme file formats: | Format | Root marker | Import | Docs | |--------|-------------|--------|------| -| **Querya custom** | `"schema": "querya.theme.v1"` | Planned (theme registry) | this document | +| **Querya custom** | `"schema": "querya.theme.v1"` | **Supported** — registry / themes folder | this document | | **VS Code** | `"colors"` object (no `schema`) | **Supported today** | [theme-import.md](theme-import.md) | VS Code JSON/JSONC import remains fully supported. The custom format is a first-class @@ -261,13 +261,28 @@ Only required fields; all colors come from Querya Dark defaults: | Detection | No `schema`; has `colors` | `"schema": "querya.theme.v1"` | | UI colors | VS Code keys (`editor.background`, `sideBar.background`, …) | `shadcn_colors` + `editor_colors` Querya keys | | Stable id | File name only | Required `id` field | -| Import today | **Yes** — Preferences → Import theme | Planned via theme registry | +| Import today | **Yes** — themes folder or Preferences → Import theme | **Yes** — themes folder or Preferences → Import theme | | Syntax tokens | `tokenColors` | `tokenColors` (same) | To convert a VS Code theme manually, map keys using [theme-import.md](theme-import.md) and place workbench values into `editor_colors`; derive shadcn tokens from your palette or leave `{}` to use preset defaults. +## Installing custom themes + +1. Create a `.json` file with `"schema": "querya.theme.v1"` (see examples above). +2. Copy it into the app support **themes folder** (`{appSupport}/themes/`). See + [theme-import.md](theme-import.md) for platform-specific paths and the + **Open themes folder** button in Preferences. +3. In **Preferences → Appearance**, click **Refresh themes** and select your theme. + +The registry scans `.json` and `.jsonc` files on refresh; there is no live folder watcher. +Invalid files are skipped (logged in debug builds). Required fields: `schema`, `id`, +`name`, `type`, `shadcn_colors`, `editor_colors` (the color maps may be empty `{}`). + +Built-in bundled themes (under `assets/themes/`) ship with the app and do not require +manual installation. + ## Related docs - [Theme import (VS Code)](theme-import.md) diff --git a/docs/theme-import.md b/docs/theme-import.md index 6403ef83..675a25d0 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -52,16 +52,49 @@ Hex strings as in VS Code: `#RRGGBB`, `#RRGGBBAA`, `#RGB`, `#RGBA` (see Comments and trailing commas are stripped before parse (`stripJsonc`). -## Preferences UI (#43) +## Preferences UI 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 +- **Theme** — built-in presets, bundled themes, and themes from the user themes folder +- **Import theme…** — pick `.json` / `.jsonc` and copy into the themes folder +- **Refresh themes** — rescan the themes folder (no live file watcher) +- **Open themes folder** — reveal the app support `themes/` directory in the file manager +- **Reset appearance** — clears overrides and returns to Querya Dark -Imported files are copied to app data (`themes/imported.json`) and survive restarts. +## Where theme files live + +Querya loads themes from the **application support** directory (see +`lib/core/theme/theme_paths.dart`): + +| Location | Purpose | +|----------|---------| +| `{appSupport}/themes/` | User-installed themes (`.json`, `.jsonc`) | +| `{appSupport}/themes/imported/` | Legacy import subdirectory (still scanned) | +| `assets/themes/` (bundled) | Built-in themes shipped with the app (e.g. Cyberpunk Neon) | + +`{appSupport}` is the OS-specific support folder for Querya Desktop +(`com.example.querya_desktop`). Typical examples: + +| OS | Example path | +|----|----------------| +| Linux | `~/.local/share/com.example.querya_desktop/themes` | +| macOS | `~/Library/Application Support/com.example.querya_desktop/themes` | +| Windows | `%APPDATA%\com.example.querya_desktop\themes` | + +**Workflow:** copy or import a theme file into `themes/`, then click **Refresh themes** +in Preferences. The app does **not** watch the folder; a restart is not required after +refresh. + +**Accepted extensions:** `.json`, `.jsonc` (comments and trailing commas stripped before parse). + +**Import via file picker** copies the file into `themes/` (deduplicated by content hash; +duplicate ids get a numeric suffix). Themes picked up from disk use the file basename +(VS Code format) or the `id` field (Querya custom format) as the registry id. + +Legacy single-file import (`themes/imported.json` under older builds) is still migrated +into the registry on load when present. ## User overrides (#45) diff --git a/lib/core/platform/open_directory.dart b/lib/core/platform/open_directory.dart new file mode 100644 index 00000000..692b6fcb --- /dev/null +++ b/lib/core/platform/open_directory.dart @@ -0,0 +1,38 @@ +import 'dart:io'; + +/// Opens [directoryPath] in the platform file manager. +/// +/// Creates the directory first when missing. Returns `false` when the platform +/// opener is unavailable or reports failure. +Future openDirectoryInFileManager( + String directoryPath, { + Future Function(String path)? opener, +}) async { + final dir = Directory(directoryPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + final open = opener ?? _defaultOpener; + return open(directoryPath); +} + +Future _defaultOpener(String path) async { + try { + if (Platform.isLinux) { + final result = await Process.run('xdg-open', [path]); + return result.exitCode == 0; + } + if (Platform.isMacOS) { + final result = await Process.run('open', [path]); + return result.exitCode == 0; + } + if (Platform.isWindows) { + await Process.run('explorer', [path]); + return true; + } + return false; + } on Object { + return false; + } +} diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 5ee9b966..4f5e8159 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -2,9 +2,11 @@ import 'dart:async' show unawaited; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/platform/open_directory.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_paths.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'; @@ -23,7 +25,9 @@ class _PreferencesAppearanceSectionState extends material.State { final _controller = ThemeController.instance; String? _importError; + String? _folderOpenError; bool _importing = false; + bool _openingThemesFolder = false; @override void initState() { @@ -98,6 +102,25 @@ class _PreferencesAppearanceSectionState await _controller.loadAvailableThemes(); } + Future _openThemesFolder() async { + setState(() { + _openingThemesFolder = true; + _folderOpenError = null; + }); + try { + final dir = await ThemePaths.ensureUserThemesDirectory(); + final opened = await openDirectoryInFileManager(dir.path); + if (!mounted) return; + if (!opened) { + setState(() => _folderOpenError = 'Could not open themes folder.'); + } + } finally { + if (mounted) { + setState(() => _openingThemesFolder = false); + } + } + } + Future _setThemeAnimation(bool enabled) async { await _controller.setThemeAnimationEnabled(enabled); } @@ -163,6 +186,16 @@ class _PreferencesAppearanceSectionState ), ), ], + const material.SizedBox(height: 8), + const material.Padding( + padding: material.EdgeInsets.only(left: kPreferencesLabelWidth + 12), + child: PreferencesHint( + 'Themes are loaded from the app support themes folder. ' + 'Drop .json or .jsonc files there, then use Refresh themes. ' + 'The folder is not watched automatically.', + ), + ), + const material.SizedBox(height: 12), const PreferencesFieldRow( label: 'Interface scale', hint: @@ -210,6 +243,14 @@ class _PreferencesAppearanceSectionState refreshingThemes ? 'Refreshing…' : 'Refresh themes', ), ), + OutlineButton( + onPressed: (_importing || _openingThemesFolder) + ? null + : () => unawaited(_openThemesFolder()), + child: material.Text( + _openingThemesFolder ? 'Opening…' : 'Open themes folder', + ), + ), OutlineButton( onPressed: () => unawaited(_resetAppearance()), child: const Text('Reset appearance'), @@ -226,9 +267,20 @@ class _PreferencesAppearanceSectionState ), ), ], + if (_folderOpenError != null) ...[ + const material.SizedBox(height: 8), + material.Text( + _folderOpenError!, + style: material.TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.destructive, + ), + ), + ], const material.SizedBox(height: 4), const PreferencesHint( - 'Import VS Code theme JSON/JSONC (.colors subset). Changes apply immediately.', + 'Import copies a theme into the themes folder. ' + 'VS Code JSON/JSONC (.colors subset) and Querya custom JSON are supported.', ), ], ); From 63a1d702c269b21f7b53319061b42e45e7ccee4f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:37:30 +0300 Subject: [PATCH 61/72] test(theme): cover themes folder hint and open-directory helper Closes #120 --- test/core/platform/open_directory_test.dart | 53 +++++++++++++++++++ .../preferences_appearance_section_test.dart | 13 +++++ 2 files changed, 66 insertions(+) create mode 100644 test/core/platform/open_directory_test.dart diff --git a/test/core/platform/open_directory_test.dart b/test/core/platform/open_directory_test.dart new file mode 100644 index 00000000..975add89 --- /dev/null +++ b/test/core/platform/open_directory_test.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/platform/open_directory.dart'; + +void main() { + group('openDirectoryInFileManager', () { + test('creates missing directory before delegating to opener', () async { + final root = await Directory.systemTemp + .createTemp('querya_open_directory_test_'); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + final target = p.join(root.path, 'themes'); + String? openedPath; + + final opened = await openDirectoryInFileManager( + target, + opener: (path) async { + openedPath = path; + return true; + }, + ); + + expect(opened, isTrue); + expect(openedPath, target); + expect(await Directory(target).exists(), isTrue); + }); + + test('returns false when opener reports failure', () async { + final root = await Directory.systemTemp + .createTemp('querya_open_directory_fail_test_'); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + final target = p.join(root.path, 'themes'); + final opened = await openDirectoryInFileManager( + target, + opener: (_) async => false, + ); + + expect(opened, isFalse); + expect(await Directory(target).exists(), isTrue); + }); + }); +} diff --git a/test/features/settings/preferences_appearance_section_test.dart b/test/features/settings/preferences_appearance_section_test.dart index 547c938e..936bd2f1 100644 --- a/test/features/settings/preferences_appearance_section_test.dart +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -120,7 +120,20 @@ void main() { expect(find.text('Import theme…'), findsOneWidget); expect(find.text('Refresh themes'), findsOneWidget); + expect(find.text('Open themes folder'), findsOneWidget); expect(find.text('Reset appearance'), findsOneWidget); }); + + testWidgets('shows themes folder hint without live reload promise', + (tester) async { + await pumpSection(tester); + + expect( + find.textContaining('Themes are loaded from the app support themes folder'), + findsOneWidget, + ); + expect(find.textContaining('not watched automatically'), findsOneWidget); + expect(find.textContaining('live reload'), findsNothing); + }); }); } From 12a4012ee551ae16864adb337fc03d370850d602 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:41:58 +0300 Subject: [PATCH 62/72] feat(theme): sync window chrome with QueryaThemeScope workbench tokens Drive title bar background and bitsdojo window button colors from workbench canvas, surface, and mutedForeground instead of shadcn ColorScheme alone. --- lib/features/main_screen/main_screen.dart | 192 +---------------- .../main_screen/querya_window_title_bar.dart | 201 ++++++++++++++++++ 2 files changed, 207 insertions(+), 186 deletions(-) create mode 100644 lib/features/main_screen/querya_window_title_bar.dart diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 2250f616..278765c4 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -6,12 +6,9 @@ import 'package:flutter/material.dart' as material Scaffold, Container, MainAxisSize, - GestureDetector, MouseRegion, SystemMouseCursors, HitTestBehavior, - Icons, - Icon, BuildContext, Widget, RepaintBoundary; @@ -20,11 +17,10 @@ import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; +import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; -import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; -import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'main_screen_workspace_state.dart'; import 'workspace_panel.dart'; @@ -129,19 +125,18 @@ class _MainScreenState extends State { @override material.Widget build(material.BuildContext context) { - final scheme = Theme.of(context).colorScheme; + final wb = context.workbench; return material.Scaffold( - backgroundColor: scheme.background, + backgroundColor: wb.canvas, body: WindowBorder( - color: scheme.border.withValues(alpha: 0.35), + color: wb.borderSubtle.withValues(alpha: 0.35), width: 1, child: Column( children: [ - _CustomTitleBar( - theme: scheme, + QueryaWindowTitleBar( onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, ), - Divider(height: 1, color: scheme.border.withValues(alpha: 0.22)), + Divider(height: 1, color: wb.borderSubtle.withValues(alpha: 0.22)), Expanded( child: _MainContentSplit( connectionsPanelKey: _connectionsPanelKey, @@ -391,178 +386,3 @@ class _VerticalResizeHandle extends StatelessWidget { ); } } - -class _CustomTitleBar extends StatefulWidget { - const _CustomTitleBar({ - required this.theme, - required this.onNewDatabaseConnection, - }); - - final ColorScheme theme; - final Future Function() onNewDatabaseConnection; - - @override - State<_CustomTitleBar> createState() => _CustomTitleBarState(); -} - -class _CustomTitleBarState extends State<_CustomTitleBar> { - @override - material.Widget build(material.BuildContext context) { - final c = widget.theme; - final onDestructive = context.workbench.onAccent; - final buttonColors = WindowButtonColors( - iconNormal: c.mutedForeground, - mouseOver: c.muted.withValues(alpha: 0.5), - mouseDown: c.muted.withValues(alpha: 0.7), - iconMouseOver: c.foreground, - iconMouseDown: c.foreground, - ); - final closeButtonColors = WindowButtonColors( - iconNormal: c.mutedForeground, - mouseOver: c.destructive, - mouseDown: c.destructive.withValues(alpha: 0.85), - iconMouseOver: onDestructive, - iconMouseDown: onDestructive, - ); - - return material.Container( - height: 40, - color: c.background, - child: WindowTitleBarBox( - child: Row( - children: [ - Expanded( - child: MoveWindow( - child: Row( - children: [ - const SizedBox(width: 16), - material.Icon( - material.Icons.search_rounded, - size: 18, - color: context.workbench.accent, - ), - const Gap(8), - const Text('Querya').semiBold().small(), - const Gap(24), - Menubar( - border: false, - popoverOffset: const Offset(0, 8), - children: [ - MenuButton( - subMenu: [ - MenuButton( - onPressed: (_) {}, child: const Text('New')), - MenuButton( - onPressed: (_) {}, - child: const Text('Open...')), - MenuButton( - onPressed: (_) {}, child: const Text('Save')), - const MenuDivider(), - MenuButton( - onPressed: (_) {}, child: const Text('Exit')), - ], - child: const Text('File'), - ), - MenuButton( - subMenu: [ - MenuButton( - leading: const material.Icon( - material.Icons.tune_rounded, - size: 18, - ), - onPressed: (ctx) => showPreferencesDialog(ctx), - child: const Text('Preferences…'), - ), - ], - child: const Text('Edit'), - ), - MenuButton( - subMenu: [ - MenuButton( - leading: const material.Icon( - material.Icons.add_link_rounded, size: 18), - trailing: - const Text('Shift+Ctrl+N').xSmall().muted(), - onPressed: (_) => - widget.onNewDatabaseConnection(), - child: const Text('New Database Connection'), - ), - MenuButton( - leading: const material.Icon( - material.Icons.link_rounded, size: 18), - onPressed: (_) {}, - child: const Text('New Connection from URL'), - ), - MenuButton( - leading: const material.Icon( - material.Icons.settings_rounded, size: 18), - onPressed: (ctx) => showDriverManagerDialog(ctx), - child: const Text('Driver Manager'), - ), - const MenuDivider(), - MenuButton( - enabled: false, - leading: const material.Icon( - material.Icons.power_rounded, size: 18), - onPressed: (_) {}, - child: const Text('Connect'), - ), - MenuButton( - leading: const material.Icon( - material.Icons.refresh_rounded, size: 18), - onPressed: (_) {}, - child: const Text('Invalidate/Reconnect'), - ), - MenuButton( - leading: const material.Icon( - material.Icons.power_off_rounded, size: 18), - onPressed: (_) {}, - child: const Text('Disconnect'), - ), - MenuButton( - onPressed: (_) {}, - child: const Text('Disconnect All')), - MenuButton( - onPressed: (_) {}, - child: const Text('Disconnect Others')), - const MenuDivider(), - MenuButton( - leading: const material.Icon( - material.Icons.lock_outline_rounded, - size: 18), - onPressed: (_) {}, - child: const Text('Read-only'), - ), - ], - child: const Text('Connection'), - ), - MenuButton( - subMenu: [ - MenuButton( - onPressed: (_) {}, child: const Text('About')), - MenuButton( - onPressed: (_) {}, - child: const Text('Documentation')), - ], - child: const Text('Help'), - ), - ], - ), - ], - ), - ), - ), - Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - MinimizeWindowButton(colors: buttonColors), - MaximizeWindowButton(colors: buttonColors), - CloseWindowButton(colors: closeButtonColors), - ], - ) - ], - ), - ), - ); - } -} diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart new file mode 100644 index 00000000..ef993854 --- /dev/null +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -0,0 +1,201 @@ +import 'dart:ui' show Color; + +import 'package:bitsdojo_window/bitsdojo_window.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' as material + show + BuildContext, + Container, + Icon, + Icons, + MainAxisSize, + Widget; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Custom bitsdojo title bar styled from [QueryaThemeScope] workbench tokens. +class QueryaWindowTitleBar extends StatelessWidget { + const QueryaWindowTitleBar({ + super.key, + required this.onNewDatabaseConnection, + }); + + final Future Function() onNewDatabaseConnection; + + @visibleForTesting + static Color titleBarBackground(BuildContext context) => + context.workbench.canvas; + + @visibleForTesting + static WindowButtonColors windowButtonColors(BuildContext context) { + final wb = context.workbench; + final cs = Theme.of(context).colorScheme; + return WindowButtonColors( + iconNormal: wb.mutedForeground, + mouseOver: wb.surface.withValues(alpha: 0.85), + mouseDown: wb.borderSubtle.withValues(alpha: 0.55), + iconMouseOver: cs.foreground, + iconMouseDown: cs.foreground, + ); + } + + @visibleForTesting + static WindowButtonColors closeButtonColors(BuildContext context) { + final wb = context.workbench; + return WindowButtonColors( + iconNormal: wb.mutedForeground, + mouseOver: wb.destructive, + mouseDown: wb.destructive.withValues(alpha: 0.85), + iconMouseOver: wb.onAccent, + iconMouseDown: wb.onAccent, + ); + } + + @override + material.Widget build(material.BuildContext context) { + final wb = context.workbench; + final buttonColors = windowButtonColors(context); + final closeButtonColors = QueryaWindowTitleBar.closeButtonColors(context); + + return material.Container( + height: 40, + color: titleBarBackground(context), + child: WindowTitleBarBox( + child: Row( + children: [ + Expanded( + child: MoveWindow( + child: Row( + children: [ + const SizedBox(width: 16), + material.Icon( + material.Icons.search_rounded, + size: 18, + color: wb.accent, + ), + const Gap(8), + const Text('Querya').semiBold().small(), + const Gap(24), + Menubar( + border: false, + popoverOffset: const Offset(0, 8), + children: [ + MenuButton( + subMenu: [ + MenuButton( + onPressed: (_) {}, child: const Text('New')), + MenuButton( + onPressed: (_) {}, + child: const Text('Open...')), + MenuButton( + onPressed: (_) {}, child: const Text('Save')), + const MenuDivider(), + MenuButton( + onPressed: (_) {}, child: const Text('Exit')), + ], + child: const Text('File'), + ), + MenuButton( + subMenu: [ + MenuButton( + leading: const material.Icon( + material.Icons.tune_rounded, + size: 18, + ), + onPressed: (ctx) => showPreferencesDialog(ctx), + child: const Text('Preferences…'), + ), + ], + child: const Text('Edit'), + ), + MenuButton( + subMenu: [ + MenuButton( + leading: const material.Icon( + material.Icons.add_link_rounded, size: 18), + trailing: + const Text('Shift+Ctrl+N').xSmall().muted(), + onPressed: (_) => onNewDatabaseConnection(), + child: const Text('New Database Connection'), + ), + MenuButton( + leading: const material.Icon( + material.Icons.link_rounded, size: 18), + onPressed: (_) {}, + child: const Text('New Connection from URL'), + ), + MenuButton( + leading: const material.Icon( + material.Icons.settings_rounded, size: 18), + onPressed: (ctx) => showDriverManagerDialog(ctx), + child: const Text('Driver Manager'), + ), + const MenuDivider(), + MenuButton( + enabled: false, + leading: const material.Icon( + material.Icons.power_rounded, size: 18), + onPressed: (_) {}, + child: const Text('Connect'), + ), + MenuButton( + leading: const material.Icon( + material.Icons.refresh_rounded, size: 18), + onPressed: (_) {}, + child: const Text('Invalidate/Reconnect'), + ), + MenuButton( + leading: const material.Icon( + material.Icons.power_off_rounded, size: 18), + onPressed: (_) {}, + child: const Text('Disconnect'), + ), + MenuButton( + onPressed: (_) {}, + child: const Text('Disconnect All')), + MenuButton( + onPressed: (_) {}, + child: const Text('Disconnect Others')), + const MenuDivider(), + MenuButton( + leading: const material.Icon( + material.Icons.lock_outline_rounded, + size: 18), + onPressed: (_) {}, + child: const Text('Read-only'), + ), + ], + child: const Text('Connection'), + ), + MenuButton( + subMenu: [ + MenuButton( + onPressed: (_) {}, child: const Text('About')), + MenuButton( + onPressed: (_) {}, + child: const Text('Documentation')), + ], + child: const Text('Help'), + ), + ], + ), + ], + ), + ), + ), + Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + MinimizeWindowButton(colors: buttonColors), + MaximizeWindowButton(colors: buttonColors), + CloseWindowButton(colors: closeButtonColors), + ], + ) + ], + ), + ), + ); + } +} From 1d69ec17329787983c610492ef2a2e53a8e0b823 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:41:58 +0300 Subject: [PATCH 63/72] test(theme): cover window chrome styling from QueryaThemeScope Closes #121 --- .../querya_window_title_bar_test.dart | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 test/features/main_screen/querya_window_title_bar_test.dart diff --git a/test/features/main_screen/querya_window_title_bar_test.dart b/test/features/main_screen/querya_window_title_bar_test.dart new file mode 100644 index 00000000..835f4a69 --- /dev/null +++ b/test/features/main_screen/querya_window_title_bar_test.dart @@ -0,0 +1,101 @@ +import 'dart:ui'; + +import 'package:bitsdojo_window/bitsdojo_window.dart'; +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; +import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +const _customCanvas = Color(0xFF112233); +const _customSurface = Color(0xFF445566); +const _customMuted = Color(0xFF99AABB); + +QueryaTheme _themeWithWorkbench(QueryaWorkbenchTheme workbench) { + return QueryaTheme.darkDefault.copyWith(workbench: workbench); +} + +void main() { + testWidgets('title bar background follows workbench canvas', (tester) async { + late Color background; + + await tester.pumpWidget( + queryaThemeTestShell( + data: _themeWithWorkbench( + QueryaWorkbenchTheme.darkDefault.copyWith(canvas: _customCanvas), + ), + child: material.Builder( + builder: (context) { + background = QueryaWindowTitleBar.titleBarBackground(context); + return const material.SizedBox(); + }, + ), + ), + ); + + expect(background, _customCanvas); + }); + + testWidgets('window button colors use workbench surface and mutedForeground', + (tester) async { + late WindowButtonColors colors; + + await tester.pumpWidget( + queryaThemeTestShell( + data: _themeWithWorkbench( + QueryaWorkbenchTheme.darkDefault.copyWith( + surface: _customSurface, + mutedForeground: _customMuted, + ), + ), + child: material.Builder( + builder: (context) { + colors = QueryaWindowTitleBar.windowButtonColors(context); + return const material.SizedBox(); + }, + ), + ), + ); + + expect(colors.iconNormal, _customMuted); + expect(colors.mouseOver, _customSurface.withValues(alpha: 0.85)); + }); + + testWidgets('chrome style updates when QueryaThemeScope workbench changes', + (tester) async { + late Color background; + + await tester.pumpWidget( + queryaThemeTestShell( + data: _themeWithWorkbench( + QueryaWorkbenchTheme.darkDefault.copyWith(canvas: _customCanvas), + ), + child: material.Builder( + builder: (context) { + background = QueryaWindowTitleBar.titleBarBackground(context); + return const material.SizedBox(); + }, + ), + ), + ); + expect(background, _customCanvas); + + await tester.pumpWidget( + queryaThemeTestShell( + data: _themeWithWorkbench( + QueryaWorkbenchTheme.lightDefault.copyWith(canvas: _customSurface), + ), + child: material.Builder( + builder: (context) { + background = QueryaWindowTitleBar.titleBarBackground(context); + return const material.SizedBox(); + }, + ), + ), + ); + expect(background, _customSurface); + }); +} From 84a8e6acd71154053f16682c7c2037813c16a7ef Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 13:44:43 +0300 Subject: [PATCH 64/72] fix: restore GestureDetector import and trim analyze warnings --- lib/features/main_screen/main_screen.dart | 2 +- lib/features/main_screen/querya_window_title_bar.dart | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 278765c4..30bec54d 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart' as material show Scaffold, Container, - MainAxisSize, + GestureDetector, MouseRegion, SystemMouseCursors, HitTestBehavior, diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index ef993854..e471749a 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -1,7 +1,4 @@ -import 'dart:ui' show Color; - import 'package:bitsdojo_window/bitsdojo_window.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material show BuildContext, From a5e681c16865d63c6d3d37ec3d9abdea684e77e1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 14:07:07 +0300 Subject: [PATCH 65/72] feat(theme): standardize startup fallback when selected theme fails Restore persisted registry themes safely on load with Querya Dark as the active fallback, a stable Preferences error message, and settings kept intact. --- lib/core/theme/theme_controller.dart | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index fbe168bf..75c52892 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -20,6 +20,10 @@ class ThemeController extends ChangeNotifier { static const String builtinQueryaDarkId = 'querya-dark'; static const String builtinQueryaLightId = 'querya-light'; + /// Shown in Preferences when persisted registry selection cannot be restored. + static const String selectedThemeStartupFallbackMessage = + 'Selected theme failed to load. Using Querya Dark.'; + static const ThemeDefinition builtinQueryaDarkDefinition = ThemeDefinition( id: builtinQueryaDarkId, name: 'Querya Dark', @@ -245,16 +249,22 @@ class ThemeController extends ChangeNotifier { path: _selectedThemePath, ); if (stillAvailable == null) { - _selectedThemeLoadError = - 'Selected theme "$selectedId" is not available.'; + _markRegistrySelectionFailed(); return; } if (_registryTheme != null) { _selectedThemeLoadError = null; + _registrySelectionFailed = false; } } + void _markRegistrySelectionFailed() { + _registryTheme = null; + _registrySelectionFailed = true; + _selectedThemeLoadError = selectedThemeStartupFallbackMessage; + } + Future setThemeById(String id) async { if (id == builtinQueryaDarkId) { await _applyBuiltinPreset(QueryaThemePreset.queryaDark); @@ -486,9 +496,7 @@ class ThemeController extends ChangeNotifier { path: _selectedThemePath, ); if (definition == null) { - _registrySelectionFailed = true; - _selectedThemeLoadError = - 'Selected theme "${_selectedThemeId!}" is not available.'; + _markRegistrySelectionFailed(); return; } @@ -498,12 +506,13 @@ class ThemeController extends ChangeNotifier { _registryTheme = theme; _selectedThemeId = definition.id; _selectedThemePath = definition.path; + _registrySelectionFailed = false; + _selectedThemeLoadError = null; _themeMode = theme.brightness == Brightness.light ? ThemeMode.light : ThemeMode.dark; - case ThemeLoadFailure(:final message): - _registrySelectionFailed = true; - _selectedThemeLoadError = message; + case ThemeLoadFailure(): + _markRegistrySelectionFailed(); } } From 51e1ccdf4c8cb7cf9cd2f4b4ccfecc7dd2f493cd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 14:07:07 +0300 Subject: [PATCH 66/72] test(theme): cover missing, invalid, and recovery startup theme paths Closes #122 --- test/core/theme/theme_controller_test.dart | 61 +++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index f71315df..2830361b 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -244,7 +244,10 @@ void main() { await c.load(); expect(c.activeTheme, QueryaTheme.darkDefault); - expect(c.selectedThemeLoadError, isNotNull); + expect( + c.selectedThemeLoadError, + ThemeController.selectedThemeStartupFallbackMessage, + ); expect(await AppSettings.instance.getSelectedThemeId(), 'missing-theme'); expect( await AppSettings.instance.getThemePreset(), @@ -252,6 +255,62 @@ void main() { ); }); + test('missing theme file on startup falls back to Querya Dark', () async { + final c = ThemeController.instance; + final themeFile = File(p.join(themesDir.path, 'querya_custom_dark.json')); + await _copyFixture('querya_custom_dark.json', themeFile); + await c.load(); + await c.setThemeById('fixture-custom-dark'); + await themeFile.delete(); + + await c.load(); + + expect(c.activeTheme, QueryaTheme.darkDefault); + expect( + c.selectedThemeLoadError, + ThemeController.selectedThemeStartupFallbackMessage, + ); + expect(c.selectedThemeId, 'fixture-custom-dark'); + expect(await AppSettings.instance.getSelectedThemeId(), 'fixture-custom-dark'); + }); + + test('invalid theme file skipped on startup falls back to Querya Dark', + () async { + final c = ThemeController.instance; + final themeFile = File(p.join(themesDir.path, 'broken-theme.json')); + await _copyFixture('querya_custom_invalid_missing_id.json', themeFile); + await AppSettings.instance.setSelectedThemeId('broken-theme'); + await AppSettings.instance.setSelectedThemeSource('filesystem'); + await AppSettings.instance.setSelectedThemePath(themeFile.path); + + await c.load(); + + expect(c.activeTheme, QueryaTheme.darkDefault); + expect( + c.selectedThemeLoadError, + ThemeController.selectedThemeStartupFallbackMessage, + ); + expect(await AppSettings.instance.getSelectedThemeId(), 'broken-theme'); + }); + + test('valid theme selection after startup failure clears error', () async { + final c = ThemeController.instance; + await AppSettings.instance.setSelectedThemeId('missing-theme'); + await AppSettings.instance.setSelectedThemeSource('filesystem'); + await c.load(); + expect(c.selectedThemeLoadError, isNotNull); + + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + await c.loadAvailableThemes(); + await c.setThemeById('fixture-custom-dark'); + + expect(c.selectedThemeLoadError, isNull); + expect(c.activeTheme.colorScheme.primary, parseQueryaThemeColor('#38BDF8')); + }); + test('setPreset clears registry selection', () async { final c = ThemeController.instance; await _copyFixture( From d294329a2fac34a78bf4b273711b1cd3c05e7bd9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 14:10:35 +0300 Subject: [PATCH 67/72] fix(theme): keep in-memory theme when refresh loses disk file Do not clear the active registry theme on loadAvailableThemes when the scanned list no longer includes the selection but the theme is already loaded. --- lib/core/theme/theme_controller.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 75c52892..9c15218c 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -249,6 +249,11 @@ class ThemeController extends ChangeNotifier { path: _selectedThemePath, ); if (stillAvailable == null) { + if (_registryTheme != null) { + // Keep the in-memory active theme; only the on-disk scan lost the file. + _selectedThemeLoadError = selectedThemeStartupFallbackMessage; + return; + } _markRegistrySelectionFailed(); return; } From 5e166578c5f3e6cf80e95257ece39b18e07bab37 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 14:19:15 +0300 Subject: [PATCH 68/72] test(theme): guard 60-theme picker against overflow and lazy-build regressions Closes #123 --- .../settings/theme_picker_button_test.dart | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index ec3726e6..d053b4fd 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -187,6 +187,124 @@ void main() { }); }); + group('ThemePickerButton large list', () { + Future openMenu(WidgetTester tester) async { + await tester.tap(find.text('Theme 00')); + await tester.pumpAndSettle(); + } + + testWidgets('uses ListView.builder for 60 themes without overflow', + (tester) async { + final themes = _fakeThemes(60); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + expect(tester.takeException(), isNull); + final listView = tester.widget( + find.byType(material.ListView), + ); + final delegate = listView.childrenDelegate; + expect(delegate, isA()); + expect( + (delegate as material.SliverChildBuilderDelegate).childCount, + 60, + ); + }); + + testWidgets('builds only a visible subset of 60 theme rows', (tester) async { + final themes = _fakeThemes(60); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + var visibleCount = 0; + for (var index = 0; index < 60; index++) { + final label = 'Theme ${index.toString().padLeft(2, '0')}'; + final row = find.descendant( + of: find.byType(material.ListView), + matching: find.text(label), + ); + if (row.evaluate().isNotEmpty) visibleCount++; + } + + expect(visibleCount, greaterThan(3)); + expect(visibleCount, lessThan(60)); + expect( + find.descendant( + of: find.byType(material.ListView), + matching: find.text('Theme 59'), + ), + findsNothing, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('selects last theme from large list', (tester) async { + final themes = _fakeThemes(60); + String? picked; + var selectedId = 'theme-0'; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.StatefulBuilder( + builder: (context, setState) { + return ThemePickerButton( + themes: themes, + selectedThemeId: selectedId, + onSelected: (id) { + picked = id; + setState(() => selectedId = id); + }, + ); + }, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await tester.enterText(find.byType(material.TextField), 'Theme 59'); + await tester.pump(); + + await tester.tap( + find.descendant( + of: find.byType(material.ListView), + matching: find.text('Theme 59'), + ), + ); + await tester.pumpAndSettle(); + + expect(picked, 'theme-59'); + expect(find.text('Theme 59'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + group('ThemePickerButton search', () { Future openMenu(WidgetTester tester) async { await tester.tap(find.text('Theme 00')); From ad09db4bf2fe14cf4d2e7f83dafac2241d8b0d2c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 14:31:40 +0300 Subject: [PATCH 69/72] test(theme): cover end-to-end registry import, apply, and reload flow Closes #124 --- .../settings/theme_import_flow_test.dart | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 test/features/settings/theme_import_flow_test.dart diff --git a/test/features/settings/theme_import_flow_test.dart b/test/features/settings/theme_import_flow_test.dart new file mode 100644 index 00000000..8e7638fc --- /dev/null +++ b/test/features/settings/theme_import_flow_test.dart @@ -0,0 +1,176 @@ +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/theme_controller.dart'; +import 'package:querya_desktop/core/theme/theme_definition.dart'; +import 'package:querya_desktop/core/theme/theme_import_service.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; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; +} + +Future _fixtureAssetLoader(String assetPath) async { + final fileName = p.basename(assetPath); + return File(p.join('test/fixtures/themes', fileName)).readAsString(); +} + +Future _stageFixtureSource( + Directory tempDir, + String fixtureName, + String sourceName, +) async { + final source = File(p.join(tempDir.path, sourceName)); + final fixture = File(p.join('test/fixtures/themes', fixtureName)); + await source.writeAsString(await fixture.readAsString()); + return source; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late Directory themesDir; + late Directory importedDir; + late ThemeRegistryService registry; + + setUpAll(() async { + tempDir = + await Directory.systemTemp.createTemp('querya_theme_import_flow_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + 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, + assetLoader: _fixtureAssetLoader, + ); + ThemeController.instance.setRegistryServiceForTest(registry); + await ThemeController.instance.load(); + }); + + tearDown(() async { + await AppSettings.instance.clearThemeSettings(); + await ThemeImportService.deletePersistedImport(); + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + ThemeController.instance.setRegistryServiceForTest( + ThemeRegistryService( + userThemesDirectory: () async => themesDir, + importedThemesDirectory: () async => importedDir, + assetLoader: _fixtureAssetLoader, + ), + ); + await ThemeController.instance.load(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('theme import flow', () { + test('imports custom JSON, applies theme, and restores after reload', + () async { + final c = ThemeController.instance; + final source = await _stageFixtureSource( + tempDir, + 'querya_custom_dark.json', + 'incoming-custom-dark.json', + ); + + final importResult = await c.importRegistryThemeFile(source.path); + + expect(importResult, isA()); + final success = importResult as ThemeDefinitionImportSuccess; + expect(success.definition.id, 'fixture-custom-dark'); + expect(success.definition.source, ThemeSource.filesystem); + expect( + c.availableThemes.map((theme) => theme.id), + contains('fixture-custom-dark'), + ); + expect(c.selectedThemeId, 'fixture-custom-dark'); + expect(c.selectedThemeLoadError, isNull); + expect( + c.activeTheme.colorScheme.primary, + parseQueryaThemeColor('#38BDF8'), + ); + expect( + await File(p.join(themesDir.path, 'fixture-custom-dark.json')).exists(), + isTrue, + ); + expect(await AppSettings.instance.getSelectedThemeId(), 'fixture-custom-dark'); + expect( + await AppSettings.instance.getSelectedThemeSource(), + 'filesystem', + ); + + await c.load(); + + expect(c.selectedThemeId, 'fixture-custom-dark'); + expect(c.selectedThemeLoadError, isNull); + expect( + c.activeTheme.colorScheme.primary, + parseQueryaThemeColor('#38BDF8'), + ); + expect( + c.activeTheme.editor.background, + parseQueryaThemeColor('#0F1117'), + ); + }); + + test('imports VS Code JSON through registry and restores after reload', + () async { + final c = ThemeController.instance; + final source = await _stageFixtureSource( + tempDir, + 'dark_subset.json', + 'incoming-vscode-dark.json', + ); + + final importResult = await c.importRegistryThemeFile(source.path); + + expect(importResult, isA()); + final success = importResult as ThemeDefinitionImportSuccess; + expect(success.definition.format, ThemeFormat.vscode); + expect(c.selectedThemeId, 'fixture-dark-subset'); + expect( + c.activeTheme.editor.background, + parseQueryaThemeColor('#1e1e1e'), + ); + + await c.load(); + + expect(c.selectedThemeId, 'fixture-dark-subset'); + expect(c.selectedThemeLoadError, isNull); + expect( + c.activeTheme.editor.background, + parseQueryaThemeColor('#1e1e1e'), + ); + }); + }); +} From 191848c78771740b08945bbb5311b824b59ac41d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 15:18:36 +0300 Subject: [PATCH 70/72] docs(theme): add release QA checklist and troubleshooting for custom themes Closes #125 --- CHANGELOG.md | 12 ++++++++++++ docs/release-checklist.md | 14 ++++++++++++++ docs/theme-custom-json.md | 11 +++++++++++ docs/theme-import.md | 12 ++++++++++++ 4 files changed, 49 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7c4d73..d7bf35f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Custom theme registry** — scan `{appSupport}/themes/` for `querya.theme.v1` and VS Code JSON/JSONC; **Theme** picker with search, preview, refresh, and **Open themes folder** (Preferences → Appearance). +- **Built-in bundled themes** — e.g. **Querya Cyberpunk Neon** from `assets/themes/` (no manual install). +- **Theme import** — **Import theme…** copies into the user themes directory with hash/id deduplication; selection persists across restarts. +- **Startup safety** — missing or broken selected theme falls back to Querya Dark with a Preferences error; settings are not auto-deleted. +- **Window chrome** — title bar and window controls follow active `QueryaThemeScope` workbench tokens. +- **Docs / QA** — [theme-custom-json.md](docs/theme-custom-json.md), updated [theme-import.md](docs/theme-import.md), custom-theme section in [release-checklist.md](docs/release-checklist.md). +- **Tests** — registry/parser/controller coverage, 60-theme picker performance guard, end-to-end import flow (`theme_import_flow_test.dart`). + ## [0.4.1] - 2026-06-13 Performance and UX release ([#93](https://github.com/QueryaHub/Querya-Desktop/issues/93)). Git tag **`0.4.1`**. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 63db411b..6885ee2a 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -10,6 +10,20 @@ Use this before tagging or running the **Release** workflow. - [ ] **Connection → New Database Connection** from the menu saves and shows in the tree. - [ ] **Driver Manager** shows only built-in drivers (no misleading JDBC requirement). +## Custom themes (manual QA) + +Use **Preferences → Appearance** unless noted. Fixtures for copy/import tests live under +`test/fixtures/themes/`; bundled built-in sample: **Querya Cyberpunk Neon** in the theme picker. + +- [ ] **Import valid custom dark** — import `test/fixtures/themes/querya_custom_dark.json` (or copy to themes folder + **Refresh themes**). Theme appears in picker; UI uses custom primary (`#38BDF8`). +- [ ] **Import valid custom light** — import `test/fixtures/themes/querya_custom_light.json`. App switches to light brightness; readable text on cards and sidebar. +- [ ] **Import VS Code JSONC** — import `test/fixtures/themes/querya_custom_jsonc.jsonc` or `themes/samples/cyberpunk-neon.jsonc`. Parser accepts comments/trailing commas; theme applies without crash. +- [ ] **Picker with many themes** — install 50+ themes (copy fixtures with unique ids, or duplicate renamed files) → open theme picker: no overflow, list scrolls, search filters rows. +- [ ] **Restart persists selection** — select a registry theme (not only Querya Dark/Light), quit and relaunch: same theme active, no error in Preferences. +- [ ] **Missing file fallback** — with a registry theme selected, delete its file from `{appSupport}/themes/`, restart: app starts on **Querya Dark**, Preferences shows *Selected theme failed to load. Using Querya Dark.*; saved selection id remains until user picks another theme. +- [ ] **Title bar / window controls** — switch Querya Dark, Querya Light, Cyberpunk Neon, and a custom theme: title bar background and minimize/maximize/close hover colors track the active theme. +- [ ] **SQL / JSON syntax** — open SQL editor with a theme that defines `tokenColors` (e.g. cyberpunk sample): comments, keywords, and strings use distinct colors; changing theme updates highlighting after editor refresh. + ## Automated - [ ] `flutter analyze` — clean (on Linux, if the analyzer crashes with **Too many open files**, try `ulimit -n 8192`; see [CONTRIBUTING.md](../CONTRIBUTING.md)). diff --git a/docs/theme-custom-json.md b/docs/theme-custom-json.md index b77f3991..924632e5 100644 --- a/docs/theme-custom-json.md +++ b/docs/theme-custom-json.md @@ -283,6 +283,17 @@ Invalid files are skipped (logged in debug builds). Required fields: `schema`, ` Built-in bundled themes (under `assets/themes/`) ship with the app and do not require manual installation. +## Troubleshooting + +| Symptom | Likely cause | What to do | +|---------|----------------|------------| +| Theme file not in picker | Invalid JSON, missing required fields, or wrong extension | Fix `schema`, `id`, `name`, `type`, `shadcn_colors`, `editor_colors`; use `.json` or `.jsonc`; click **Refresh themes**. In debug builds, skipped files log to the console. | +| Import dialog reports an error | Parse failure or empty VS Code `colors` | Open the file in an editor; validate JSON/JSONC; for VS Code format ensure a non-empty `colors` object. | +| *Selected theme failed to load. Using Querya Dark.* | Persisted theme id points to a missing or broken file | Restore the file under `themes/`, or pick another theme in Preferences. Settings are kept so you can fix the file and **Refresh themes**. | +| Colors look wrong or default | Invalid hex for a key | Invalid optional colors are **skipped** (preset fallback used). Check `#RRGGBB` / `#RRGGBBAA` formats in [Color string formats](#color-string-formats). | +| Duplicate theme names in picker | Same `id` with different content imported twice | Registry suffixes ids (`my-theme-2`). Rename files or ids to avoid confusion. | +| Dropped file not visible | No folder watcher | Use **Refresh themes** after copying into `themes/` (restart not required). | + ## Related docs - [Theme import (VS Code)](theme-import.md) diff --git a/docs/theme-import.md b/docs/theme-import.md index 675a25d0..d61c36f7 100644 --- a/docs/theme-import.md +++ b/docs/theme-import.md @@ -96,6 +96,18 @@ duplicate ids get a numeric suffix). Themes picked up from disk use the file bas Legacy single-file import (`themes/imported.json` under older builds) is still migrated into the registry on load when present. +## Troubleshooting + +| Symptom | Likely cause | What to do | +|---------|----------------|------------| +| Theme missing from picker after copy | Scan not run or file skipped as invalid | Click **Refresh themes**; verify `.json`/`.jsonc` and valid root object. Debug builds log skipped paths. | +| Import fails immediately | Unsupported file or empty `colors` (VS Code) | Use VS Code theme JSON with a `colors` section, or Querya custom JSON per [theme-custom-json.md](theme-custom-json.md). | +| JSONC import fails | Trailing commas/comments in strict JSON tool | Querya strips JSONC on import; ensure the file still has a single root object after stripping. | +| *Selected theme failed to load. Using Querya Dark.* on startup | Selected file deleted or corrupted | Replace or remove the file; select a working theme. See [theme-custom-json.md](theme-custom-json.md#troubleshooting). | +| Picker slow with many themes | Large registry list | Expected: picker uses `ListView.builder` and search; report regressions if opening Preferences lags with 50+ themes. | +| SQL colors unchanged | Theme has no `tokenColors` | Add VS Code-style `tokenColors` to the file; only imported/registry themes with rules affect syntax highlighting. | +| Title bar wrong color | Window chrome not synced | Switch theme again; file an issue if title bar stays on preset colors with a custom registry theme active. | + ## User overrides (#45) User customizations are stored as VS Code keys → hex strings in From 31cae8f43fd6eb3f7ca097471c1e614de1a744d6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 15:50:34 +0300 Subject: [PATCH 71/72] chore(release): prepare 0.4.2 and plan 0.4.3 theme follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump version to 0.4.2, finalize changelog for the custom theme registry epic, and document deferred TP-F1–F4 work for 0.4.3. --- CHANGELOG.md | 16 ++++-- README.md | 2 +- docs/README.md | 1 + docs/planned-0.4.3.md | 44 ++++++++++++++++ docs/release-checklist.md | 7 +-- docs/roadmap.md | 2 + docs/theme-parser-github-issues.md | 63 ++++++++++++----------- docs/theme-parser-implementation-tasks.md | 20 +++---- pubspec.yaml | 2 +- 9 files changed, 107 insertions(+), 50 deletions(-) create mode 100644 docs/planned-0.4.3.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d7bf35f3..1f817cab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Planned (0.4.3) + +Theme and extensions follow-ups — see [docs/planned-0.4.3.md](docs/planned-0.4.3.md): file watcher (TP-F1), marketplace metadata (TP-F2), visual theme editor (TP-F3), remote theme install (TP-F4). + +## [0.4.2] - 2026-06-14 + +Custom theme registry release (parser epic TP-01–TP-30, GitHub issues **#96–#125**). Git tag **`0.4.2`**. + ### Added -- **Custom theme registry** — scan `{appSupport}/themes/` for `querya.theme.v1` and VS Code JSON/JSONC; **Theme** picker with search, preview, refresh, and **Open themes folder** (Preferences → Appearance). -- **Built-in bundled themes** — e.g. **Querya Cyberpunk Neon** from `assets/themes/` (no manual install). -- **Theme import** — **Import theme…** copies into the user themes directory with hash/id deduplication; selection persists across restarts. -- **Startup safety** — missing or broken selected theme falls back to Querya Dark with a Preferences error; settings are not auto-deleted. +- **Custom theme registry** — scan `{appSupport}/themes/` for `querya.theme.v1` and VS Code JSON/JSONC; **Theme** picker with search, hover preview, **Refresh themes**, and **Open themes folder** (Preferences → Appearance). +- **Built-in bundled themes** — **Querya Cyberpunk Neon** from `assets/themes/` (no manual install). +- **Theme import** — **Import theme…** copies into the user themes directory with content-hash and id deduplication; selection persists across restarts. +- **Startup safety** — missing or broken selected theme falls back to Querya Dark with a Preferences error; saved theme id is kept until the user picks another theme. - **Window chrome** — title bar and window controls follow active `QueryaThemeScope` workbench tokens. - **Docs / QA** — [theme-custom-json.md](docs/theme-custom-json.md), updated [theme-import.md](docs/theme-import.md), custom-theme section in [release-checklist.md](docs/release-checklist.md). - **Tests** — registry/parser/controller coverage, 60-theme picker performance guard, end-to-end import flow (`theme_import_flow_test.dart`). diff --git a/README.md b/README.md index e7f4e94b..89d384d5 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ a clean, dark UI inspired by tools like pgAdmin. statement timeouts, query history, and CSV/JSON export. - **Object browsing** — connection tree with databases, tables, views, and server stats. -- **Themeable** — runtime dark/light/system modes and **VS Code theme import**. +- **Themeable** — dark/light/system, **VS Code theme import**, custom `querya.theme.v1` registry, and bundled themes (0.4.2). - **Scalable UI** — global interface scaling for high-DPI and accessibility. - **Secure by default** — passwords and connection strings live in the OS secure store, never in plaintext. diff --git a/docs/README.md b/docs/README.md index 2eecb4c7..52811ce3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,7 @@ Index of Querya Desktop documentation, grouped by audience. ## Planning - [Roadmap](roadmap.md) — current direction and follow-ups. +- [Planned 0.4.3](planned-0.4.3.md) — deferred theme/extensions follow-ups after 0.4.2. - [Custom theme parser requirements](scheme-parcer.md) — JSON theme format and scaling spec. - [Theme parser implementation plan](theme-parser-implementation-tasks.md) — task breakdown and architecture. - [Theme parser GitHub issues](theme-parser-github-issues.md) — issue templates for epic #96–#125. diff --git a/docs/planned-0.4.3.md b/docs/planned-0.4.3.md new file mode 100644 index 00000000..de3a7e2c --- /dev/null +++ b/docs/planned-0.4.3.md @@ -0,0 +1,44 @@ +# Planned release 0.4.3 — theme and extensions follow-ups + +**Status:** planning (not started). +**Depends on:** **0.4.2** custom theme registry (TP-01–TP-30, shipped). + +This document captures work intentionally deferred from the first custom-theme pass. +See also [theme-parser-github-issues.md](theme-parser-github-issues.md) (TP-F1–TP-F4) and +[market-tech.md](market-tech.md) for the broader extensions marketplace direction. + +## Theme folder and discovery + +| ID | Scope | Summary | +|----|--------|---------| +| **TP-F1** | `theme`, `filesystem` | **File watcher** for `{appSupport}/themes/` — auto-refresh the registry when files are added, removed, or renamed. Deferred: OS-specific watcher APIs and app lifecycle edge cases. | + +## Theme distribution and metadata + +| ID | Scope | Summary | +|----|--------|---------| +| **TP-F2** | `theme`, `marketplace` | **Marketplace metadata** on `ThemeDefinition` / manifests — preview image, tags, homepage, license, author. Prerequisite for listing themes in a future Extensions UI. | +| **TP-F4** | `theme`, `network` | **Remote theme install** — download from URL with checksum/trust policy. Requires security review (HTTPS, signatures, user consent). | + +## Authoring UX + +| ID | Scope | Summary | +|----|--------|---------| +| **TP-F3** | `theme`, `settings` | **Visual theme editor** in Preferences — tweak colors, export `querya.theme.v1`. Larger than parser/import; likely multiple PRs. | + +## Extensions marketplace (optional overlap) + +If **0.4.3** also starts the extensions shell from [market-tech.md](market-tech.md), align **TP-F2** theme metadata with `ExtensionManifest` so themes can appear as a category in **Explore**. Keep `lib/core/market/` isolated from theme parser internals. + +## Suggested PR order (draft) + +1. TP-F1 — watcher + debounced `loadAvailableThemes()` +2. TP-F2 — manifest fields + docs (no remote API yet) +3. TP-F3 — minimal editor MVP (export only) or split to **0.4.4** +4. TP-F4 — install-from-URL behind explicit user action + +## Out of scope for 0.4.3 + +- Full marketplace backend and **Extensions** sidebar UI (may land in **0.5.0** per market-tech.md) +- P2 Mongo/Redis semantic token colors ([roadmap.md](roadmap.md)) +- `re_editor` / LSP editor replacement diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 6885ee2a..eda02013 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,6 +1,7 @@ -# Pre-release checklist (toward 1.0) +# Pre-release checklist (release **0.4.2**) -Use this before tagging or running the **Release** workflow. +Use this before tagging **`0.4.2`** or running the **Release** workflow. +See [tags-and-releases.md](tags-and-releases.md) and [CHANGELOG.md](../CHANGELOG.md). ## Product smoke (manual) @@ -32,7 +33,7 @@ Use **Preferences → Appearance** unless noted. Fixtures for copy/import tests ## Versioning and release -- [ ] `pubspec.yaml` `version` matches the release you intend to ship. +- [ ] `pubspec.yaml` `version` is **`0.4.2+…`** (semver matches intended tag **`0.4.2`**). - [ ] **Tag** is placed on the **commit that includes all fixes** you want in binaries (a tag does not auto-include later commits; see [CONTRIBUTING.md](../CONTRIBUTING.md)). - [ ] Run the **Release** workflow from GitHub Actions (see [tags-and-releases.md](tags-and-releases.md)). - [ ] Verify **Linux** and **Windows** zip artifacts and `SHA256SUMS.txt` on the GitHub Release. diff --git a/docs/roadmap.md b/docs/roadmap.md index b0a48dbe..a62cc6fb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,6 +7,8 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **Shipped in 0.4.0 (epic #37):** runtime themes, VS Code `colors` + `tokenColors` import, SQL/JSON highlighting, P0 workbench migration, Preferences, tests, docs — [theme.md](theme.md). - **Shipped in 0.4.1 ([#93](https://github.com/QueryaHub/Querya-Desktop/issues/93)):** UI performance — virtual result grid, lazy connection tree, decoupled scale preview, stats polling, MySQL stats dashboard, local `docker/` dev stack — [perf-baseline.md](perf-baseline.md). +- **Shipped in 0.4.2 (TP-01–TP-30, #96–#125):** custom theme registry — `querya.theme.v1` + VS Code JSON/JSONC scan, Theme picker (50+), import/refresh, built-in Cyberpunk Neon asset, startup fallback, window chrome sync — [theme-custom-json.md](theme-custom-json.md), [theme-import.md](theme-import.md). +- **Planned 0.4.3:** theme follow-ups (file watcher, marketplace metadata, visual editor, remote install) — [planned-0.4.3.md](planned-0.4.3.md). - **Optional:** Preferences → **Animate theme changes** (off by default). - **Later:** P2 Mongo/Redis token colors; `re_editor` if perf gap; LSP epic per [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md) (**NO-GO** on `code_forge` for 0.3). diff --git a/docs/theme-parser-github-issues.md b/docs/theme-parser-github-issues.md index 58dc4b9c..6166d340 100644 --- a/docs/theme-parser-github-issues.md +++ b/docs/theme-parser-github-issues.md @@ -1574,7 +1574,8 @@ Add QA checklist: ## Optional follow-up issues -These are intentionally out of the first implementation pass. +These are intentionally out of the first implementation pass (**shipped in 0.4.2**). +**Target milestone: 0.4.3** — see [planned-0.4.3.md](planned-0.4.3.md). ### TP-F1 — File watcher for user themes folder @@ -1594,33 +1595,33 @@ Install theme from URL. Requires network, trust/security decisions, and probably ## Master checklist -- [ ] TP-01 docs schema -- [ ] TP-02 fixtures -- [ ] TP-03 manifest model -- [ ] TP-04 color parser wrapper -- [ ] TP-05 shadcn color scheme mapping -- [ ] TP-06 editor theme mapping -- [ ] TP-07 workbench theme mapping -- [ ] TP-08 QueryaTheme factory -- [ ] TP-09 load result types -- [ ] TP-10 ThemeDefinition -- [ ] TP-11 theme paths -- [ ] TP-12 filesystem scan -- [ ] TP-13 load selected definition -- [ ] TP-14 parsed theme cache -- [ ] TP-15 AppSettings selected theme -- [ ] TP-16 legacy imported migration -- [ ] TP-17 ThemeController registry integration -- [ ] TP-18 ThemePickerButton shell -- [ ] TP-19 picker search/filter -- [ ] TP-20 safe preview card -- [ ] TP-21 Preferences integration -- [ ] TP-22 refresh themes action -- [ ] TP-23 multi-theme import -- [ ] TP-24 built-in theme assets -- [ ] TP-25 user theme folder docs -- [ ] TP-26 window chrome sync -- [ ] TP-27 startup fallback -- [ ] TP-28 50+ themes performance test -- [ ] TP-29 end-to-end import test -- [ ] TP-30 release QA docs +- [x] TP-01 docs schema +- [x] TP-02 fixtures +- [x] TP-03 manifest model +- [x] TP-04 color parser wrapper +- [x] TP-05 shadcn color scheme mapping +- [x] TP-06 editor theme mapping +- [x] TP-07 workbench theme mapping +- [x] TP-08 QueryaTheme factory +- [x] TP-09 load result types +- [x] TP-10 ThemeDefinition +- [x] TP-11 theme paths +- [x] TP-12 filesystem scan +- [x] TP-13 load selected definition +- [x] TP-14 parsed theme cache +- [x] TP-15 AppSettings selected theme +- [x] TP-16 legacy imported migration +- [x] TP-17 ThemeController registry integration +- [x] TP-18 ThemePickerButton shell +- [x] TP-19 picker search/filter +- [x] TP-20 safe preview card +- [x] TP-21 Preferences integration +- [x] TP-22 refresh themes action +- [x] TP-23 multi-theme import +- [x] TP-24 built-in theme assets +- [x] TP-25 user theme folder docs +- [x] TP-26 window chrome sync +- [x] TP-27 startup fallback +- [x] TP-28 50+ themes performance test +- [x] TP-29 end-to-end import test +- [x] TP-30 release QA docs diff --git a/docs/theme-parser-implementation-tasks.md b/docs/theme-parser-implementation-tasks.md index ef34773a..dc1b5d15 100644 --- a/docs/theme-parser-implementation-tasks.md +++ b/docs/theme-parser-implementation-tasks.md @@ -579,13 +579,13 @@ flutter: ## Acceptance checklist -- [ ] Querya custom JSON импортируется и применяется. -- [ ] VS Code JSON/JSONC import продолжает работать. -- [ ] 50+ тем в Preferences не вызывают overflow и заметные лаги. -- [ ] Hover в списке не перестраивает весь app. -- [ ] Повторное переключение на уже открытую тему мгновенное. -- [ ] Сломанная выбранная тема не ломает запуск приложения. -- [ ] Выбранная тема сохраняется после рестарта. -- [ ] Window title bar синхронизирован с background/canvas темы. -- [ ] `flutter analyze` clean. -- [ ] `flutter test` green. +- [x] Querya custom JSON импортируется и применяется. +- [x] VS Code JSON/JSONC import продолжает работать. +- [x] 50+ тем в Preferences не вызывают overflow и заметные лаги. +- [x] Hover в списке не перестраивает весь app. +- [x] Повторное переключение на уже открытую тему мгновенное. +- [x] Сломанная выбранная тема не ломает запуск приложения. +- [x] Выбранная тема сохраняется после рестарта. +- [x] Window title bar синхронизирован с background/canvas темы. +- [x] `flutter analyze` clean. +- [x] `flutter test` green. diff --git a/pubspec.yaml b/pubspec.yaml index 83da6093..c6c697b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.1+7 +version: 0.4.2+1 From c87df6f6a50364f57812234e0b31e798a7984677 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 15 Jun 2026 06:55:59 +0300 Subject: [PATCH 72/72] chore(release): align pubspec with main for 0.4.2 version-bump Keep dev at 0.4.1+7 so the post-merge Auto Version Bump workflow sets 0.4.2+8 on main, matching the 0.4.1 release flow. --- docs/release-checklist.md | 3 ++- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-checklist.md b/docs/release-checklist.md index eda02013..5dd8f7ce 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -33,7 +33,8 @@ Use **Preferences → Appearance** unless noted. Fixtures for copy/import tests ## Versioning and release -- [ ] `pubspec.yaml` `version` is **`0.4.2+…`** (semver matches intended tag **`0.4.2`**). +- [ ] `pubspec.yaml` on **`dev`** is **`0.4.1+7`** before merging to `main` (auto version-bump sets **`0.4.2+8`** on `main`). +- [ ] After merge, confirm GitHub Action **Auto Version Bump** committed **`0.4.2+…`** on `main`. - [ ] **Tag** is placed on the **commit that includes all fixes** you want in binaries (a tag does not auto-include later commits; see [CONTRIBUTING.md](../CONTRIBUTING.md)). - [ ] Run the **Release** workflow from GitHub Actions (see [tags-and-releases.md](tags-and-releases.md)). - [ ] Verify **Linux** and **Windows** zip artifacts and `SHA256SUMS.txt` on the GitHub Release. diff --git a/pubspec.yaml b/pubspec.yaml index c6c697b3..83da6093 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.2+1 +version: 0.4.1+7