diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7c4d73..1f817cab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ 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] + +### 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, 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`). + ## [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/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/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/docs/README.md b/docs/README.md index 2d38f3ea..52811ce3 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 @@ -25,6 +26,11 @@ 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. +- [Marketplace extensions spec](market-tech.md) — extensions manager and marketplace integration. ## Archive 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 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 63db411b..5dd8f7ce 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) @@ -10,6 +11,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)). @@ -18,7 +33,8 @@ Use this before tagging or running the **Release** workflow. ## Versioning and release -- [ ] `pubspec.yaml` `version` matches the release you intend to ship. +- [ ] `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/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/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 diff --git a/docs/theme-custom-json.md b/docs/theme-custom-json.md new file mode 100644 index 00000000..924632e5 --- /dev/null +++ b/docs/theme-custom-json.md @@ -0,0 +1,302 @@ +# Querya custom theme JSON (`querya.theme.v1`) + +Querya supports two theme file formats: + +| Format | Root marker | Import | Docs | +|--------|-------------|--------|------| +| **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 +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** — 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. + +## 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) +- [Theme system overview](theme.md) +- [Implementation plan](theme-parser-implementation-tasks.md) +- Sample VS Code themes: `themes/samples/cyberpunk-neon.json` diff --git a/docs/theme-import.md b/docs/theme-import.md index 0ccacb70..d61c36f7 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). @@ -48,16 +52,61 @@ 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 - -Imported files are copied to app data (`themes/imported.json`) and survive restarts. +- **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 + +## 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. + +## 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) diff --git a/docs/theme-parser-github-issues.md b/docs/theme-parser-github-issues.md new file mode 100644 index 00000000..6166d340 --- /dev/null +++ b/docs/theme-parser-github-issues.md @@ -0,0 +1,1627 @@ +# 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 (**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 + +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 + +- [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 new file mode 100644 index 00000000..dc1b5d15 --- /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 + +- [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/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/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(); } 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/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) => 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), + ); +} 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..026f191c --- /dev/null +++ b/lib/core/theme/parser/querya_theme_color_scheme.dart @@ -0,0 +1,98 @@ +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; + } + } + + 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), + 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: destructive, + // ignore: deprecated_member_use + destructiveForeground: 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), + ); +} 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, + ); +} 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..becf6bc0 --- /dev/null +++ b/lib/core/theme/parser/querya_theme_manifest.dart @@ -0,0 +1,198 @@ +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 const 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'; +} 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), + ); +} diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 8abdb638..9c15218c 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -10,11 +10,45 @@ 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._(); + 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', + 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}) + : _registryService = registryService ?? ThemeRegistryService(); static final ThemeController instance = ThemeController._(); @@ -27,6 +61,14 @@ class ThemeController extends ChangeNotifier { bool _loaded = false; bool _themeAnimationEnabled = false; + List _availableThemes = const []; + String? _selectedThemeId; + String? _selectedThemePath; + String? _selectedThemeLoadError; + QueryaTheme? _registryTheme; + bool _registrySelectionFailed = false; + bool _isLoadingAvailableThemes = false; + QueryaTheme? _cachedLightTheme; QueryaTheme? _cachedDarkTheme; QueryaTheme? _cachedActiveTheme; @@ -48,6 +90,27 @@ class ThemeController extends ChangeNotifier { String? get importedThemeName => _importedThemeName; + 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; + + 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); @@ -81,15 +144,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 +162,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 +212,133 @@ class ThemeController extends ChangeNotifier { _importedColors = Map.unmodifiable(imported); _themeAnimationEnabled = await AppSettings.instance.getThemeAnimationEnabled(); + + _availableThemes = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); + await _restoreSelectedRegistryTheme(); + _loaded = true; _notifyThemeChanged(); } + Future loadAvailableThemes() async { + 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) { + if (_registryTheme != null) { + // Keep the in-memory active theme; only the on-disk scan lost the file. + _selectedThemeLoadError = selectedThemeStartupFallbackMessage; + return; + } + _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); + return; + } + if (id == builtinQueryaLightId) { + await _applyBuiltinPreset(QueryaThemePreset.queryaLight); + return; + } + + 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 { + 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( + 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 +347,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 +361,7 @@ class ThemeController extends ChangeNotifier { if (preset == QueryaThemePreset.imported && !hasImportedTheme) { return; } + await _clearRegistrySelection(); _preset = preset; if (preset == QueryaThemePreset.imported) { await AppSettings.instance.setThemePreset(preset); @@ -182,6 +375,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); @@ -193,6 +403,7 @@ class ThemeController extends ChangeNotifier { :final tokenColors, :final storedPath, ): + await _clearRegistrySelection(); _importedColors = Map.unmodifiable(colors); _importedTokenColors = List.unmodifiable(tokenColors); _importedThemeName = name; @@ -203,6 +414,9 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemeImportPath(storedPath); await AppSettings.instance.setThemePreset(QueryaThemePreset.imported); await AppSettings.instance.setThemeMode(_themeMode); + _availableThemes = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); _notifyThemeChanged(); return result; case ThemeImportFailure(): @@ -238,11 +452,15 @@ 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 = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); _notifyThemeChanged(); } @@ -256,9 +474,120 @@ class ThemeController extends ChangeNotifier { _userOverrides = const {}; _importedThemeName = null; _themeAnimationEnabled = false; + _availableThemes = _mergeBuiltinThemes( + await _registryService.loadThemeDefinitions(), + ); + _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) { + _markRegistrySelectionFailed(); + return; + } + + final result = await _registryService.loadTheme(definition); + switch (result) { + case ThemeLoadSuccess(:final theme, :final definition): + _registryTheme = theme; + _selectedThemeId = definition.id; + _selectedThemePath = definition.path; + _registrySelectionFailed = false; + _selectedThemeLoadError = null; + _themeMode = theme.brightness == Brightness.light + ? ThemeMode.light + : ThemeMode.dark; + case ThemeLoadFailure(): + _markRegistrySelectionFailed(); + } + } + + Future _clearRegistrySelection() async { + _registryTheme = null; + _registrySelectionFailed = false; + _selectedThemeId = null; + _selectedThemePath = null; + _selectedThemeLoadError = null; + 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, + 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; diff --git a/lib/core/theme/theme_definition.dart b/lib/core/theme/theme_definition.dart new file mode 100644 index 00000000..d9f5675c --- /dev/null +++ b/lib/core/theme/theme_definition.dart @@ -0,0 +1,67 @@ +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}'; + + @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, + ); +} diff --git a/lib/core/theme/theme_import_service.dart b/lib/core/theme/theme_import_service.dart index 7a40e5d0..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,9 +32,53 @@ 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 _storedFileName = 'imported.json'; + 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(); /// Reads [sourcePath], parses JSON/JSONC, copies to app data, returns colors. static Future importFromPath(String sourcePath) async { @@ -109,6 +154,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_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/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)); + } +} diff --git a/lib/core/theme/theme_registry_service.dart b/lib/core/theme/theme_registry_service.dart new file mode 100644 index 00000000..43a65efd --- /dev/null +++ b/lib/core/theme/theme_registry_service.dart @@ -0,0 +1,688 @@ +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'; +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'; + +/// Scans theme directories and exposes lightweight [ThemeDefinition] metadata. +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; + + /// 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 = []; + + await _loadBuiltinAssetDefinitions(definitions); + + await _scanDirectory( + await _userThemesDirectory(), + ThemeSource.filesystem, + definitions, + ); + await _scanDirectory( + await _importedThemesDirectory(), + ThemeSource.imported, + 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()), + ); + 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; + if (path == null || path.isEmpty) { + return ThemeLoadFailure( + definition: definition, + message: 'Theme file path is missing.', + ); + } + + final cacheKey = definition.stableCacheKey; + final cachedTheme = _themeCache.get(cacheKey); + if (cachedTheme != null) { + return ThemeLoadSuccess(definition: definition, theme: cachedTheme); + } + + try { + 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), + }; + _themeCache.put(cacheKey, theme); + _themeParseCount++; + 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 _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 _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, + 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; + + if (p.basename(entity.path) == ThemeImportService.storedFileName) { + 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 _definitionFromRaw( + json: json, + path: file.path, + fileBaseName: p.basenameWithoutExtension(file.path), + source: source, + contentHash: hash, + lastModified: stat.modified, + ); + } + + return _definitionFromRaw( + json: json, + path: file.path, + fileBaseName: p.basenameWithoutExtension(file.path), + source: source, + contentHash: hash, + lastModified: stat.modified, + ); + } on Object catch (e) { + _logScanError(file.path, e); + return null; + } + } + + 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 ThemeSource source, + 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(path, 'Missing required custom theme fields'); + return null; + } + if (type != 'dark' && type != 'light') { + _logScanError(path, 'Invalid custom theme type "$type"'); + return null; + } + + return ThemeDefinition( + id: id, + name: name, + source: source, + format: ThemeFormat.queryaCustom, + isDark: type == 'dark', + path: path, + lastModified: lastModified, + contentHash: contentHash, + ); + } + + ThemeDefinition? _vscodeDefinition({ + required Map json, + required ThemeSource source, + required String contentHash, + required String fileBaseName, + required String path, + DateTime? lastModified, + }) { + final rawName = json['name']?.toString().trim(); + final name = rawName != null && rawName.isNotEmpty ? rawName : fileBaseName; + final type = json['type']?.toString().trim().toLowerCase(); + + return ThemeDefinition( + id: fileBaseName, + name: name, + source: source, + format: ThemeFormat.vscode, + isDark: type == 'dark', + path: 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'); + } + + 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)'); + } + } +} + +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}); + + 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(); +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 2250f616..30bec54d 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -5,13 +5,10 @@ import 'package:flutter/material.dart' as material show 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..e471749a --- /dev/null +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -0,0 +1,198 @@ +import 'package:bitsdojo_window/bitsdojo_window.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), + ], + ) + ], + ), + ), + ); + } +} diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index c5706448..4f5e8159 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -2,10 +2,14 @@ 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/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'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Appearance / theme controls for [PreferencesDialog]. @@ -21,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() { @@ -43,8 +49,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 { @@ -64,12 +78,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 { @@ -84,6 +98,29 @@ class _PreferencesAppearanceSectionState if (mounted) setState(() => _importError = null); } + Future _refreshThemes() async { + 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); } @@ -91,9 +128,8 @@ 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; + final refreshingThemes = c.isLoadingAvailableThemes; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, @@ -125,27 +161,38 @@ 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, + isLoading: refreshingThemes, + onSelected: (id) => unawaited(_setThemeById(id)), + onPreviewTheme: _previewThemeById, + ), + ), + 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 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), @@ -188,6 +235,22 @@ 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: (_importing || _openingThemesFolder) + ? null + : () => unawaited(_openThemesFolder()), + child: material.Text( + _openingThemesFolder ? 'Opening…' : 'Open themes folder', + ), + ), OutlineButton( onPressed: () => unawaited(_resetAppearance()), child: const Text('Reset appearance'), @@ -204,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.', ), ], ); diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart new file mode 100644 index 00000000..7541cc4d --- /dev/null +++ b/lib/features/settings/theme_picker_button.dart @@ -0,0 +1,624 @@ +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({ + super.key, + required this.themes, + required this.selectedThemeId, + required this.onSelected, + this.onPreviewTheme, + this.isLoading = false, + this.expandToParent = false, + this.width, + }); + + 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; + + static const double menuMaxHeight = 320; + + @override + 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; + String? _previewThemeId; + String? _previewThemeLabel; + QueryaTheme? _previewTheme; + String? _previewError; + bool _previewLoading = false; + Timer? _previewDebounce; + int _previewRequestSerial = 0; + + @override + void initState() { + super.initState(); + _searchController.addListener(_onSearchChanged); + } + + @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) { + _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 { + 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: const 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: _buildMenuPanel(context, cs), + ), + ], + 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 _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), + ), + ), + ), + ), + 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( + 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, + onHover: widget.onPreviewTheme == null + ? null + : () => _schedulePreview(theme), + onSelected: () { + widget.onSelected(theme.id); + _clearSearch(); + _resetPreviewState(); + _controller.close(); + }, + ); + }, + ), + ), + ), + ], + ); + } + + 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 { + _clearSearch(); + _resetPreviewState(); + 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, + 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(); +} + +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); + widget.onHover?.call(); + }, + 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', + }; +} 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, + ), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 3f764c8a..83da6093 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,3 +42,4 @@ flutter: uses-material-design: true assets: - assets/images/ + - assets/themes/ diff --git a/test/core/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/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', () { 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: ', + ), + ), + ); + }); + }); } 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); + }); + }); +} 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')); + }); + }); +} 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..e6e1c814 --- /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); + const 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()); + }); + }); +} 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']); + }); + }); +} 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); + }); + }); +} diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index ece87f8c..2830361b 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -1,13 +1,19 @@ +import 'dart:async'; 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_definition.dart'; +import 'package:querya_desktop/core/theme/theme_registry_service.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; class _FakePathProvider extends PathProviderPlatform { @@ -24,10 +30,38 @@ 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()); +} + +class _GatedRegistryService extends ThemeRegistryService { + _GatedRegistryService({ + required super.userThemesDirectory, + required super.importedThemesDirectory, + }); + + final gate = Completer(); + + @override + Future> loadThemeDefinitions() async { + await gate.future; + return super.loadThemeDefinitions(); + } +} + +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 = @@ -36,6 +70,18 @@ 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, + assetLoader: _fixtureAssetLoader, + ); + ThemeController.instance.setRegistryServiceForTest(registry); + }); + tearDownAll(() async { await LocalDb.instance.close(); if (await tempDir.exists()) { @@ -45,6 +91,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(); }); @@ -55,6 +106,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 { @@ -139,4 +195,265 @@ 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, + ThemeController.selectedThemeStartupFallbackMessage, + ); + expect(await AppSettings.instance.getSelectedThemeId(), 'missing-theme'); + expect( + await AppSettings.instance.getThemePreset(), + QueryaThemePreset.queryaLight, + ); + }); + + 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( + '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); + }); + + 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(); + + 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, + 'cyberpunk-neon', + ])); + 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); + }); + + 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_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, + ); +} 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_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); + }); + }); +} 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); + }); + }); +} 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 new file mode 100644 index 00000000..60029779 --- /dev/null +++ b/test/core/theme/theme_registry_cache_test.dart @@ -0,0 +1,144 @@ +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, + bundledThemeAssetFiles: const [], + ); + }); + + 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, + bundledThemeAssetFiles: const [], + ); + + 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); + }); + }); +} 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..8604ffe1 --- /dev/null +++ b/test/core/theme/theme_registry_legacy_import_test.dart @@ -0,0 +1,153 @@ +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, + bundledThemeAssetFiles: const [], + ); + }); + + 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, + ); + }); + }); +} 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..2e0734a5 --- /dev/null +++ b/test/core/theme/theme_registry_service_test.dart @@ -0,0 +1,307 @@ +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_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; +} + +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, + bundledThemeAssetFiles: const [], + ); + }); + + 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)); + }); + }); + + 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')); + }); + }); + + 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']), + ); + }); + }); +} 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); + }); +} 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..936bd2f1 --- /dev/null +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -0,0 +1,139 @@ +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'; + +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; + + @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, + assetLoader: _fixtureAssetLoader, + ); + 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 { + ThemeController.instance.setRegistryServiceForTest( + ThemeRegistryService( + userThemesDirectory: () async => themesDir, + importedThemesDirectory: () async => importedDir, + assetLoader: _fixtureAssetLoader, + ), + ); + await AppSettings.instance.clearThemeSettings(); + await ThemeImportService.deletePersistedImport(); + if (await themesDir.exists()) { + await themesDir.delete(recursive: true); + } + 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('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); + }); + }); +} 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'), + ); + }); + }); +} 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..d053b4fd --- /dev/null +++ b/test/features/settings/theme_picker_button_test.dart @@ -0,0 +1,581 @@ +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'; + +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('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); + + 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 { + const themes = [ + 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); + }); + }); + + 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')); + 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); + }); + }); + + 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); + }); + + 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); + }); + }); +} 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); + }); + }); +} 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" + } + } + ] +} 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" + } +}