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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/theme-custom-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ registry services described in
| `description` | string | Short summary for theme picker / marketplace. |
| `author` | string | Author or org name. |
| `version` | string | Theme package version (informational). |
| `homepage` | string | Project or theme homepage URL. |
| `license` | string | SPDX or plain-text license id (e.g. `MIT`). |
| `preview` | string | Preview image URL or relative asset path (stored only; not fetched by the registry). |
| `tags` | string array | Search/filter labels (e.g. `dark`, `neon`). |

Unknown root fields are ignored. In debug builds the parser may log skipped keys.

Expand Down Expand Up @@ -274,9 +278,9 @@ derive shadcn tokens from your palette or leave `{}` to use preset defaults.
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.
3. In **Preferences → Appearance**, click **Refresh themes** (or wait for the folder watcher) and select your theme.

The registry scans `.json` and `.jsonc` files on refresh; there is no live folder watcher.
The registry scans `.json` and `.jsonc` files on refresh and when the themes folder changes.
Invalid files are skipped (logged in debug builds). Required fields: `schema`, `id`,
`name`, `type`, `shadcn_colors`, `editor_colors` (the color maps may be empty `{}`).

Expand All @@ -292,7 +296,7 @@ manual installation.
| *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). |
| Dropped file not visible | Watcher debounce / invalid file | Wait a moment after copy, or click **Refresh themes**; validate required fields and `.json`/`.jsonc` extension. |

## Related docs

Expand Down
65 changes: 65 additions & 0 deletions lib/core/market/extension_manifest.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import '../theme/theme_definition.dart';

/// Marketplace listing model (future `MarketplaceClient` response shape).
///
/// See [docs/market-tech.md](https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/market-tech.md).
class ExtensionManifest {
const ExtensionManifest({
required this.id,
required this.name,
required this.type,
required this.version,
required this.downloadUrl,
required this.sha256Checksum,
this.author,
this.description,
this.homepage,
this.license,
this.preview,
this.tags = const [],
});

static const typeTheme = 'theme';

final String id;
final String name;
final String type;
final String version;
final String downloadUrl;
final String sha256Checksum;
final String? author;
final String? description;
final String? homepage;
final String? license;
final String? preview;
final List<String> tags;

/// Maps a registry [ThemeDefinition] into marketplace field names.
///
/// [downloadUrl] and [sha256Checksum] are required for remote install (TP-F4);
/// pass empty strings when building a local-only listing stub.
factory ExtensionManifest.fromThemeDefinition(
ThemeDefinition definition, {
String downloadUrl = '',
String sha256Checksum = '',
String type = typeTheme,
}) {
final metadata = definition.metadata;
return ExtensionManifest(
id: definition.id,
name: definition.name,
type: type,
version: metadata?.version ?? '0.0.0',
downloadUrl: downloadUrl,
sha256Checksum: sha256Checksum.isNotEmpty
? sha256Checksum
: (definition.contentHash ?? ''),
author: metadata?.author,
description: metadata?.description,
homepage: metadata?.homepage,
license: metadata?.license,
preview: metadata?.preview,
tags: metadata?.tags ?? const [],
);
}
}
42 changes: 41 additions & 1 deletion lib/core/theme/parser/querya_theme_manifest.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class QueryaThemeManifest {
this.description,
this.author,
this.version,
this.homepage,
this.license,
this.preview,
this.tags = const [],
});

final String schema;
Expand All @@ -35,6 +39,10 @@ class QueryaThemeManifest {
final String? description;
final String? author;
final String? version;
final String? homepage;
final String? license;
final String? preview;
final List<String> tags;

bool get isDark => type == QueryaThemeType.dark;
bool get isLight => type == QueryaThemeType.light;
Expand Down Expand Up @@ -93,9 +101,25 @@ class QueryaThemeManifest {
description: _optionalString(json['description']),
author: _optionalString(json['author']),
version: _optionalString(json['version']),
homepage: _optionalString(json['homepage']),
license: _optionalString(json['license']),
preview: _optionalString(json['preview']),
tags: _parseTags(json['tags']),
);
}

static List<String> _parseTags(Object? raw) {
if (raw is! List) return const [];

final tags = <String>[];
for (final item in raw) {
if (item is! String) continue;
final trimmed = item.trim();
if (trimmed.isNotEmpty) tags.add(trimmed);
}
return List.unmodifiable(tags);
}

static String _requiredString(Map<String, dynamic> json, String key) {
if (!json.containsKey(key)) {
throw QueryaThemeManifestParseException('Missing required field "$key"');
Expand Down Expand Up @@ -156,7 +180,11 @@ class QueryaThemeManifest {
_listEquals(tokenColors, other.tokenColors) &&
description == other.description &&
author == other.author &&
version == other.version;
version == other.version &&
homepage == other.homepage &&
license == other.license &&
preview == other.preview &&
_stringListEquals(tags, other.tags);

@override
int get hashCode => Object.hash(
Expand All @@ -170,6 +198,10 @@ class QueryaThemeManifest {
description,
author,
version,
homepage,
license,
preview,
Object.hashAll(tags),
);

static bool _mapEquals(Map<String, String> a, Map<String, String> b) {
Expand All @@ -187,6 +219,14 @@ class QueryaThemeManifest {
}
return true;
}

static bool _stringListEquals(List<String> a, List<String> 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 {
Expand Down
8 changes: 7 additions & 1 deletion lib/core/theme/theme_definition.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'theme_metadata.dart';

enum ThemeSource {
builtin,
imported,
Expand All @@ -21,6 +23,7 @@ class ThemeDefinition {
this.path,
this.lastModified,
this.contentHash,
this.metadata,
});

final String id;
Expand All @@ -31,6 +34,7 @@ class ThemeDefinition {
final String? path;
final DateTime? lastModified;
final String? contentHash;
final ThemeMetadata? metadata;

bool get isFileBacked =>
source == ThemeSource.filesystem ||
Expand All @@ -51,7 +55,8 @@ class ThemeDefinition {
isDark == other.isDark &&
path == other.path &&
lastModified == other.lastModified &&
contentHash == other.contentHash;
contentHash == other.contentHash &&
metadata == other.metadata;

@override
int get hashCode => Object.hash(
Expand All @@ -63,5 +68,6 @@ class ThemeDefinition {
path,
lastModified,
contentHash,
metadata,
);
}
112 changes: 112 additions & 0 deletions lib/core/theme/theme_metadata.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/// Marketplace-oriented metadata carried on [ThemeDefinition].
class ThemeMetadata {
const ThemeMetadata({
this.description,
this.author,
this.version,
this.homepage,
this.license,
this.preview,
this.tags = const [],
});

final String? description;
final String? author;
final String? version;
final String? homepage;
final String? license;

/// Relative asset path or HTTPS URL string (not fetched by the registry).
final String? preview;
final List<String> tags;

bool get hasPickerSubtitle =>
(author != null && author!.isNotEmpty) || tags.isNotEmpty;

/// Subtitle for theme picker rows: author, else comma-separated tags.
String? get pickerSubtitle {
final authorLabel = author?.trim();
if (authorLabel != null && authorLabel.isNotEmpty) return authorLabel;
if (tags.isEmpty) return null;
return tags.join(', ');
}

static ThemeMetadata? fromQueryaJson(Map<String, dynamic> json) {
final description = _optionalString(json['description']);
final author = _optionalString(json['author']);
final version = _optionalString(json['version']);
final homepage = _optionalString(json['homepage']);
final license = _optionalString(json['license']);
final preview = _optionalString(json['preview']);
final tags = _parseTags(json['tags']);

if (description == null &&
author == null &&
version == null &&
homepage == null &&
license == null &&
preview == null &&
tags.isEmpty) {
return null;
}

return ThemeMetadata(
description: description,
author: author,
version: version,
homepage: homepage,
license: license,
preview: preview,
tags: tags,
);
}

static String? _optionalString(Object? value) {
if (value is! String) return null;
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}

static List<String> _parseTags(Object? raw) {
if (raw is! List) return const [];

final tags = <String>[];
for (final item in raw) {
if (item is! String) continue;
final trimmed = item.trim();
if (trimmed.isNotEmpty) tags.add(trimmed);
}
return List.unmodifiable(tags);
}

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ThemeMetadata &&
description == other.description &&
author == other.author &&
version == other.version &&
homepage == other.homepage &&
license == other.license &&
preview == other.preview &&
_listEquals(tags, other.tags);

@override
int get hashCode => Object.hash(
description,
author,
version,
homepage,
license,
preview,
Object.hashAll(tags),
);

static bool _listEquals(List<String> a, List<String> 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;
}
}
2 changes: 2 additions & 0 deletions lib/core/theme/theme_registry_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import 'querya_theme.dart';
import 'theme_definition.dart';
import 'theme_import_service.dart';
import 'theme_load_result.dart';
import 'theme_metadata.dart';
import 'theme_paths.dart';

/// Scans theme directories and exposes lightweight [ThemeDefinition] metadata.
Expand Down Expand Up @@ -491,6 +492,7 @@ class ThemeRegistryService {
path: path,
lastModified: lastModified,
contentHash: contentHash,
metadata: ThemeMetadata.fromQueryaJson(json),
);
}

Expand Down
Loading
Loading