// HubService — singleton wrapper around fai_dart_sdk's HubClient. // // Centralises the gRPC connection so each page just imports // `HubService.instance` instead of constructing its own client. // Methods return UI-friendly types so pages stay free of // protobuf imports. import 'package:fai_dart_sdk/fai_dart_sdk.dart'; import 'package:shared_preferences/shared_preferences.dart'; class HubService { HubService._(); static final HubService instance = HubService._(); HubClient _client = HubClient(); /// The endpoint string ("http://host:port") the connection /// targets. Shown in the navigation rail. String get endpointLabel => _client.endpoint.toString(); HubEndpoint get currentEndpoint => _client.endpoint; static const _kHostKey = 'hub.host'; static const _kPortKey = 'hub.port'; static const _kSecureKey = 'hub.secure'; /// Read persisted endpoint and reconnect if it differs from /// the default. Called once at app start. Future loadPersistedEndpoint() async { final prefs = await SharedPreferences.getInstance(); final host = prefs.getString(_kHostKey); final port = prefs.getInt(_kPortKey); final secure = prefs.getBool(_kSecureKey); if (host == null) return; final endpoint = HubEndpoint( host: host, port: port ?? 50051, secure: secure ?? false, ); if (endpoint.toString() != _client.endpoint.toString()) { await reconnect(endpoint); } } /// Reconnect to a new endpoint and persist for next launch. Future reconnect(HubEndpoint endpoint) async { await _client.close(); _client = HubClient(endpoint: endpoint); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kHostKey, endpoint.host); await prefs.setInt(_kPortKey, endpoint.port); await prefs.setBool(_kSecureKey, endpoint.secure); } static const _kThemeKey = 'theme.mode'; /// Read the persisted theme mode (system / light / dark) for /// initial app startup. Defaults to system. Future loadThemeMode() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_kThemeKey); return ThemeModeValue.fromWire(raw) ?? ThemeModeValue.system; } /// Persist the theme mode the user chose. Future saveThemeMode(ThemeModeValue mode) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kThemeKey, mode.wire); } Future healthy() => _client.healthy(); Future> listModules() async { final caps = await _client.listCapabilities(); // Group capabilities by module so Studio's UI maps a card // to a module rather than a capability. final byModule = >{}; for (final c in caps) { byModule.putIfAbsent(c.moduleName, () => []).add(c); } return byModule.entries.map((e) { final caps = e.value; return ModuleSummary( name: e.key, version: caps.first.moduleVersion, capabilities: caps .map((c) => '${c.capability}@${c.version}') .toList(), ); }).toList() ..sort((a, b) => a.name.compareTo(b.name)); } /// Fully-detailed manifest for one installed module. Future moduleInfo(String name) async { final r = await _client.moduleInfo(name); return ModuleDetail( name: r.name, version: r.version, capabilities: r.provides .map((c) => '${c.namespace}.${c.name}@${c.versionReq}') .toList(), permissions: r.permissions, directory: r.directory, ); } /// System-AI status snapshot — used by the Settings dialog /// and the audit drill-down to decide whether the "Explain" /// affordance is active. Always returns a struct (no /// exceptions on disabled state). Future systemAiStatus() async { final r = await _client.systemAiStatus(); return SystemAiStatus( enabled: r.enabled, provider: r.provider, endpoint: r.endpoint, model: r.model, privacyMode: r.privacyMode, apiKeyEnv: r.apiKeyEnv, cacheCount: r.cacheCount.toInt(), ); } /// Send a one-shot prompt to the System AI. Returns the /// answer + latency on success or an [AskAiResult] with /// `errorKind` set on failure (never throws). Studio maps /// the error kind to its inline-fix copy. Future askAi(String prompt, {bool forceFresh = false}) async { final r = await _client.askAi(prompt, forceFresh: forceFresh); return AskAiResult( errorKind: r.errorKind, text: r.text, latencyMs: r.latencyMs, cached: r.cached, cachedAt: r.cachedAt, cacheHits: r.cacheHits, ); } /// Drop every cached System-AI explanation. Returns the count /// of entries that were purged. Future clearSystemLlmCache() { return _client.clearSystemLlmCache(); } /// Forget a single cached entry by prompt (for "Regenerate"). Future forgetCachedExplanation(String prompt) { return _client.forgetCachedExplanation(prompt); } /// Persist a new System-AI configuration. The hub writes back /// to `~/.fai/config.yaml` and hot-reloads the in-memory copy /// in one round-trip; the caller gets the resulting status /// for an immediate UI refresh. Future updateSystemAi({ required String provider, required String endpoint, required String model, required String apiKeyEnv, required String privacyMode, }) async { final r = await _client.updateSystemAi( provider: provider, endpoint: endpoint, model: model, apiKeyEnv: apiKeyEnv, privacyMode: privacyMode, ); return SystemAiStatus( enabled: r.enabled, provider: r.provider, endpoint: r.endpoint, model: r.model, privacyMode: r.privacyMode, apiKeyEnv: r.apiKeyEnv, cacheCount: r.cacheCount.toInt(), ); } /// Probe a System-AI configuration with a tiny ping. Pass /// the editor's current form values so "Test connection" /// works *before* save. Empty fields fall back to the live /// in-memory config. Future testSystemAi({ String provider = '', String endpoint = '', String model = '', String apiKeyEnv = '', String privacyMode = '', }) async { final r = await _client.testSystemAi( provider: provider, endpoint: endpoint, model: model, apiKeyEnv: apiKeyEnv, privacyMode: privacyMode, ); return AskAiResult( errorKind: r.errorKind, text: r.text, latencyMs: r.latencyMs, ); } /// List the models the provider exposes via `GET /v1/models`. /// Studio's editor uses this to populate a dropdown instead /// of forcing the operator to type the id manually. Future<({String errorKind, String text, List ids})> listSystemAiModels({ String provider = '', String endpoint = '', String apiKeyEnv = '', }) async { final r = await _client.listSystemAiModels( provider: provider, endpoint: endpoint, apiKeyEnv: apiKeyEnv, ); return ( errorKind: r.errorKind, text: r.text, ids: r.models.map((m) => m.id).toList(), ); } /// Pull (download) an Ollama model via /api/pull. /// Synchronous; can take minutes for large models. Errors /// come back as `errorKind`. Future<({String errorKind, String text, int elapsedMs})> pullSystemAiModel({ required String endpoint, required String model, String apiKeyEnv = '', }) async { final r = await _client.pullSystemAiModel( endpoint: endpoint, model: model, apiKeyEnv: apiKeyEnv, ); return ( errorKind: r.errorKind, text: r.text, elapsedMs: r.elapsedMs, ); } /// Detected host hardware. Used by the System-AI editor to /// mark models as "recommended" / "may be slow" against the /// operator's actual machine. Future hardwareInfo() async { final r = await _client.hardwareInfo(); return HardwareSnapshot( tier: r.tier, cpuBrand: r.cpuBrand, cpuCores: r.cpuCores, ramMb: r.ramMb.toInt(), appleSilicon: r.appleSilicon, dedicatedGpu: r.dedicatedGpu, summary: r.summary, ); } /// Bundled curated model database. Refresh requires a hub /// redeploy; the `lastReviewed` field shows how stale the /// curation is. Future listSystemAiCuratedModels() async { final r = await _client.listSystemAiCuratedModels(); return CuratedSnapshot( lastReviewed: r.lastReviewed, models: r.models .map( (m) => CuratedModelInfo( id: m.id, family: m.family, paramsB: m.paramsB, qualityTier: m.qualityTier, minHwTier: m.minHwTier, contextK: m.contextK, notes: m.notes, license: m.license, ), ) .toList(), ); } /// Wipe the audit log on the active channel and seed a fresh /// `chain.reset` marker. Refused server-side on `beta` / /// `production`; the gRPC error surfaces as an exception so /// the caller can show the operator why it was blocked. /// Returns `(purged, channel)` so the UI can confirm what /// just happened. Future<({int purged, String channel})> clearEventLog({ required String reviewer, required String reason, }) async { final r = await _client.clearEventLog( reviewer: reviewer, reason: reason, ); return (purged: r.purged.toInt(), channel: r.channel); } /// Filesystem paths the daemon owns. Used by Doctor to wire /// "Open log file" / "Open config file" buttons. Future daemonPaths() async { final r = await _client.daemonPaths(); return DaemonPathsSnapshot( logPath: r.logPath, dbPath: r.dbPath, modulesDir: r.modulesDir, flowsDir: r.flowsDir, configPath: r.configPath, pidPath: r.pidPath, ); } /// Remove an installed module by name. Returns the version /// that was removed so the caller can confirm. Future<({String name, String version})> uninstallModule( String name, ) async { final r = await _client.uninstallModule(name); return (name: r.name, version: r.version); } /// Fetch a module's README markdown via the hub. Errors come /// back as `errorKind` strings (not exceptions) so callers can /// render fallback UI without try/catch ceremony. Future<({String errorKind, String text, String sourceUrl})> fetchModuleDocs(String name) async { final r = await _client.fetchModuleDocs(name); return ( errorKind: r.errorKind, text: r.text, sourceUrl: r.sourceUrl, ); } /// Active channel + per-channel daemon status snapshot. Future channelStatus() async { final r = await _client.channelStatus(); return ChannelStatusSnapshot( active: r.active, channels: r.channels .map( (c) => ChannelInfo( name: c.name, port: c.port, running: c.running, endpoint: c.endpoint, ), ) .toList(), ); } /// Browse the bundled store index. All filters optional; /// empty query returns the first [limit] entries. Future> searchStore({ String query = '', String category = '', String tag = '', String status = '', int limit = 50, }) async { final entries = await _client.searchStore( query: query, category: category, tag: tag, status: status, limit: limit, ); return entries .map( (e) => StoreItem( name: e.name, taglineEn: e.taglineEn, taglineDe: e.taglineDe, descriptionEn: e.descriptionEn, descriptionDe: e.descriptionDe, category: e.category, tags: e.tags, requiresCapabilities: e.requiresCapabilities, requiresServices: e.requiresServices, license: e.license, repository: e.repository, bestVersion: e.bestVersion, status: e.status, installed: e.installed, featured: e.featured, iconUrl: e.iconUrl, screenshotUrls: e.screenshotUrls, docsUrl: e.docsUrl, ), ) .toList(); } /// Install a module from a `.fai` bundle (URL or local path). /// Returns the installed module's name + version on success; /// throws on hub error (signature mismatch, sha256 fail, etc.). Future<({String name, String version})> installModule({ required String source, String expectedSha256 = '', }) async { final r = await _client.installModule( source: source, expectedSha256: expectedSha256, ); return (name: r.name, version: r.version); } /// Saved flows known to the hub. Future> listFlows() async { final flows = await _client.listFlows(); return flows .map( (f) => SavedFlow( name: f.name, path: f.path, sizeBytes: f.sizeBytes.toInt(), ), ) .toList() ..sort((a, b) => a.name.compareTo(b.name)); } /// Run a saved flow with the supplied text inputs. Returns /// the named outputs as a map of UI-friendly strings. Future> runSavedFlow({ required String name, required Map textInputs, }) async { final r = await _client.runSavedFlow( name: name, textInputs: textInputs, ); return { for (final entry in r.outputs.entries) entry.key: _payloadToText(entry.value), }; } String _payloadToText(Payload p) { if (p.hasText()) return p.text; if (p.hasJson()) return p.json.toString(); if (p.hasBytes()) { return ''; } if (p.hasFile()) { return ''; } return ''; } Future> recentEvents({ int limit = 50, List types = const [], }) async { final events = await _client.eventLog(limit: limit, types: types); return events .map( (e) => AuditEvent( eventId: e.eventId, timestamp: DateTime.tryParse(e.timestamp) ?? DateTime.now(), type: e.eventType, flowName: e.flowName.isEmpty ? null : e.flowName, stepId: e.stepId.isEmpty ? null : e.stepId, moduleName: e.moduleName.isEmpty ? null : e.moduleName, moduleVersion: e.moduleVersion.isEmpty ? null : e.moduleVersion, invocationId: e.invocationId.isEmpty ? null : e.invocationId, flowExecution: e.flowExecution.isEmpty ? null : e.flowExecution, durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(), error: e.error.isEmpty ? null : e.error, detail: e.detail.isEmpty ? null : e.detail, ), ) .toList(); } Future> pendingApprovals() async { final entries = await _client.listApprovals(statuses: ['pending']); return entries .map( (e) => PendingApproval( id: e.approvalId, flowName: e.flowName, stepId: e.stepId, prompt: e.prompt, showPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview, createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(), expiresAt: e.expiresAt.isEmpty ? null : DateTime.tryParse(e.expiresAt), ), ) .toList(); } /// List approvals filtered by status. Empty [statuses] returns /// every row (pending + decided + expired). Returned records /// carry the decided-side fields so the History tab can render /// "rejected by alice@studio at … because …". Future> listApprovalsRecords({ List statuses = const [], int limit = 200, }) async { final entries = await _client.listApprovals( statuses: statuses, limit: limit, ); return entries .map( (e) => ApprovalRecord( id: e.approvalId, flowName: e.flowName, stepId: e.stepId, prompt: e.prompt, payloadPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview, createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(), expiresAt: e.expiresAt.isEmpty ? null : DateTime.tryParse(e.expiresAt), status: e.status, decidedAt: e.decidedAt.isEmpty ? null : DateTime.tryParse(e.decidedAt), decidedBy: e.decidedBy, reason: e.reason, ), ) .toList(); } Future approve(String id, String reviewer) => _client.approve(approvalId: id, reviewer: reviewer); Future reject(String id, String reviewer, String reason) => _client.reject( approvalId: id, reviewer: reviewer, reason: reason, ); /// Composite "doctor" snapshot. One round-trip per piece, run /// in parallel so the page populates fast. Future doctor() async { final results = await Future.wait([ _client.listCapabilities().catchError((_) => []), _client.listApprovals(statuses: const ['pending']) .catchError((_) => []), _client.verifyEventChain().catchError( (_) => VerifyEventChainResponse(), ), _client.listServices().catchError((_) => []), _client.checkUpdate().catchError( (_) => CheckUpdateResponse(), ), _client.daemonPaths().catchError((_) => DaemonPathsResponse()), ]); final caps = results[0] as List; final approvals = results[1] as List; final chain = results[2] as VerifyEventChainResponse; final services = results[3] as List; final update = results[4] as CheckUpdateResponse; final paths = results[5] as DaemonPathsResponse; // Module count = distinct module_name across capabilities. final moduleNames = {for (final c in caps) c.moduleName}; return DoctorSnapshot( moduleCount: moduleNames.length, capabilityCount: caps.length, pendingApprovals: approvals.length, eventChainTotal: chain.total.toInt(), eventChainVerified: chain.verified.toInt(), eventChainTamperedAt: chain.tamperedAt.isEmpty ? null : chain.tamperedAt, services: services .map( (s) => ServiceEntry( name: s.name, endpoint: s.endpoint, tags: s.tags, ), ) .toList(), update: UpdateStatus( channel: update.channel, localVersion: update.localVersion, latestVersion: update.latestVersion, updateAvailable: update.updateAvailable, manifestReachable: update.manifestReachable, releaseNotesUrl: update.releaseNotesUrl.isEmpty ? null : update.releaseNotesUrl, reason: update.reason.isEmpty ? null : update.reason, ), paths: DaemonPathsSnapshot( logPath: paths.logPath, dbPath: paths.dbPath, modulesDir: paths.modulesDir, flowsDir: paths.flowsDir, configPath: paths.configPath, pidPath: paths.pidPath, ), ); } } class UpdateStatus { final String channel; final String localVersion; final String latestVersion; final bool updateAvailable; final bool manifestReachable; final String? releaseNotesUrl; final String? reason; const UpdateStatus({ required this.channel, required this.localVersion, required this.latestVersion, required this.updateAvailable, required this.manifestReachable, this.releaseNotesUrl, this.reason, }); } class DoctorSnapshot { final int moduleCount; final int capabilityCount; final int pendingApprovals; final int eventChainTotal; final int eventChainVerified; final String? eventChainTamperedAt; final List services; final UpdateStatus update; /// Filesystem paths the daemon owns. Empty fields when the /// hub couldn't resolve them (older daemon, no $HOME). final DaemonPathsSnapshot paths; const DoctorSnapshot({ required this.moduleCount, required this.capabilityCount, required this.pendingApprovals, required this.eventChainTotal, required this.eventChainVerified, required this.eventChainTamperedAt, required this.services, required this.update, required this.paths, }); bool get chainHealthy => eventChainTamperedAt == null; } class ServiceEntry { final String name; final String endpoint; final List tags; const ServiceEntry({ required this.name, required this.endpoint, required this.tags, }); } /// Three-way switch persisted across launches. enum ThemeModeValue { system('system'), light('light'), dark('dark'); final String wire; const ThemeModeValue(this.wire); static ThemeModeValue? fromWire(String? s) { if (s == null) return null; for (final v in values) { if (v.wire == s) return v; } return null; } } /// UI-side type, decoupled from the proto wire type so pages /// don't import protobuf packages. class ModuleSummary { final String name; final String version; final List capabilities; const ModuleSummary({ required this.name, required this.version, required this.capabilities, }); } class SavedFlow { final String name; final String path; final int sizeBytes; const SavedFlow({ required this.name, required this.path, required this.sizeBytes, }); } class ModuleDetail { final String name; final String version; final List capabilities; final List permissions; final String directory; const ModuleDetail({ required this.name, required this.version, required this.capabilities, required this.permissions, required this.directory, }); } class AuditEvent { final String eventId; final DateTime timestamp; final String type; final String? flowName; final String? stepId; final String? moduleName; final String? moduleVersion; final String? invocationId; final String? flowExecution; final int? durationMs; final String? error; /// Free-form JSON-string carrying event-type-specific fields. /// Surfaced verbatim in the audit drill-down dialog. final String? detail; const AuditEvent({ required this.eventId, required this.timestamp, required this.type, this.flowName, this.stepId, this.moduleName, this.moduleVersion, this.invocationId, this.flowExecution, this.durationMs, this.error, this.detail, }); } class PendingApproval { final String id; final String flowName; final String stepId; final String prompt; final String? showPreview; final DateTime createdAt; final DateTime? expiresAt; const PendingApproval({ required this.id, required this.flowName, required this.stepId, required this.prompt, this.showPreview, required this.createdAt, this.expiresAt, }); } /// Full approval record including decided-side fields. Used by /// the History tab so operators can review what they (or the /// system) decided. class ApprovalRecord { final String id; final String flowName; final String stepId; final String prompt; final String? payloadPreview; final DateTime createdAt; final DateTime? expiresAt; /// One of: pending / approved / rejected / expired. final String status; final DateTime? decidedAt; /// Reviewer handle (e.g. "alice@studio") or "system" for /// auto-expired approvals. Empty until decided. final String decidedBy; /// Reject reason — empty for approve / pending / expired. final String reason; const ApprovalRecord({ required this.id, required this.flowName, required this.stepId, required this.prompt, required this.payloadPreview, required this.createdAt, required this.expiresAt, required this.status, required this.decidedAt, required this.decidedBy, required this.reason, }); } class SystemAiStatus { final bool enabled; final String provider; final String endpoint; final String model; /// off / redacted / full. final String privacyMode; final String apiKeyEnv; /// Number of cached System-AI explanations currently stored. /// Surfaced in Doctor + Settings so operators see the cache /// earning its keep. final int cacheCount; const SystemAiStatus({ required this.enabled, required this.provider, required this.endpoint, required this.model, required this.privacyMode, required this.apiKeyEnv, this.cacheCount = 0, }); } class AskAiResult { /// "" on success; otherwise one of: /// disabled / env_missing / network / http / parse / empty_response. final String errorKind; /// On success: the assistant's reply. /// On failure: a human-readable diagnostic. final String text; /// Round-trip latency in ms (success only). For cache hits /// this is the *original* generation latency. final int latencyMs; /// True iff the answer came from the persistent cache. final bool cached; /// ISO-8601 timestamp the cached entry was first generated. /// Empty for fresh answers. final String cachedAt; /// How many times this entry has been served from cache. /// 0 for fresh answers. final int cacheHits; const AskAiResult({ required this.errorKind, required this.text, required this.latencyMs, this.cached = false, this.cachedAt = '', this.cacheHits = 0, }); bool get isSuccess => errorKind.isEmpty; /// One-line fix hint per error kind, mirroring /// `docs/architecture/system-ai.md`. Empty on success. String get fixHint { switch (errorKind) { case '': return ''; case 'disabled': return 'Configure System AI in Settings (Cmd+,) → System AI panel.'; case 'env_missing': return 'The API-key env var is empty. `export =` in your shell, then `fai daemon restart`.'; case 'network': return 'Hub cannot reach the configured endpoint. Is your provider running?'; case 'http': return 'Provider returned a non-2xx status. Verify endpoint, model name, and API key.'; case 'parse': return 'Provider returned an unexpected response shape. Non-OpenAI providers may need a compatibility proxy.'; case 'empty_response': return 'Provider answered with no content. Try a different model or rephrase the prompt.'; default: return 'Unknown failure kind ($errorKind). See system-ai.md.'; } } } class ChannelInfo { final String name; final int port; final bool running; final String endpoint; const ChannelInfo({ required this.name, required this.port, required this.running, required this.endpoint, }); } class ChannelStatusSnapshot { final String active; final List channels; const ChannelStatusSnapshot({ required this.active, required this.channels, }); } /// Filesystem paths the daemon owns. Studio's Doctor page /// surfaces these as "Open" buttons so Windows operators who /// never touch a shell can still inspect everything via the OS /// file manager / default editor. class DaemonPathsSnapshot { final String logPath; final String dbPath; final String modulesDir; final String flowsDir; final String configPath; final String pidPath; const DaemonPathsSnapshot({ required this.logPath, required this.dbPath, required this.modulesDir, required this.flowsDir, required this.configPath, required this.pidPath, }); } /// Detected host hardware — see /// docs/architecture/system-ai.md → "Hardware tiers". class HardwareSnapshot { /// One of: tiny / small / balanced / large / unknown. final String tier; final String cpuBrand; final int cpuCores; final int ramMb; final bool appleSilicon; final bool dedicatedGpu; /// Human-readable summary the editor renders verbatim. final String summary; const HardwareSnapshot({ required this.tier, required this.cpuBrand, required this.cpuCores, required this.ramMb, required this.appleSilicon, required this.dedicatedGpu, required this.summary, }); /// Numeric rank used for "is `min_hw_tier` ≤ detected?" /// comparisons. Unknown sits at -1 so it never qualifies as /// "at least" anything; the editor falls back to size-only /// heuristics in that case. int get rank { switch (tier) { case 'tiny': return 0; case 'small': return 1; case 'balanced': return 2; case 'large': return 3; default: return -1; } } } /// One curated model with editorial metadata. Wire-faithful /// with `crates/fai_hub/system-ai/models.yaml`. class CuratedModelInfo { final String id; final String family; final double paramsB; /// basic / good / excellent. final String qualityTier; /// tiny / small / balanced / large. final String minHwTier; final int contextK; final String notes; final String license; const CuratedModelInfo({ required this.id, required this.family, required this.paramsB, required this.qualityTier, required this.minHwTier, required this.contextK, required this.notes, required this.license, }); /// Same rank as HardwareSnapshot; lets us compare with `<=`. int get minHwRank { switch (minHwTier) { case 'tiny': return 0; case 'small': return 1; case 'balanced': return 2; case 'large': return 3; default: return -1; } } } /// Curated database snapshot returned from the hub. class CuratedSnapshot { /// ISO date string from `models.yaml`, e.g. "2026-05-07". final String lastReviewed; final List models; const CuratedSnapshot({ required this.lastReviewed, required this.models, }); } /// One row from the store-index search. class StoreItem { final String name; final String taglineEn; /// German tagline. Falls back to [taglineEn] in the UI when /// the index entry does not ship a localized one. final String taglineDe; final String descriptionEn; /// German long-form description. Falls back to [descriptionEn] /// when missing. final String descriptionDe; final String category; final List tags; final List requiresCapabilities; final List requiresServices; final String license; final String repository; final String bestVersion; /// "published", "alpha", "planned", or empty when unknown. final String status; final bool installed; /// True iff the store-index marks this entry as editorially /// featured (curated quarterly in the bundled seed.yaml). /// Drives the "Featured" strip at the top of the Store page. final bool featured; /// Optional icon URL — square PNG / SVG. Empty for entries /// that don't supply one; UI falls back to a category icon. final String iconUrl; /// Ordered list of screenshot URLs; empty when none. Studio /// renders them as a horizontally-scrollable strip in the /// detail sheet. final List screenshotUrls; /// Explicit docs URL override. When empty, the hub falls back /// to the well-known raw-README paths off [repository]. final String docsUrl; const StoreItem({ required this.name, required this.taglineEn, required this.taglineDe, required this.descriptionEn, required this.descriptionDe, required this.category, required this.tags, required this.requiresCapabilities, required this.requiresServices, required this.license, required this.repository, required this.bestVersion, required this.status, required this.installed, required this.featured, required this.iconUrl, required this.screenshotUrls, required this.docsUrl, }); }