feat(studio): Store + Cmd+K palette i18n (v0.27.0)

- Localize Store page: search hint, category and status filter
  chips (All / Published / Alpha / Planned / Installed),
  pluralized result count, hub-unreachable / no-matches empty
  states, featured strip header, store-card pills (installed /
  update / version), Install / Update / Details / Read docs /
  Uninstall / Not installable buttons, install-progress dialog,
  uninstall confirm dialog, install / uninstall toasts,
  open-browser failure toast.
- Localize detail-sheet sections: Tags, Required capabilities,
  Required host services, Screenshots, Documentation, Source.
- Localize docs panel: Load documentation button, fetching
  spinner copy, Open repository in browser button, friendly
  error mapping (not_found / no_docs / auth / network / parse).
- Localize Cmd+K palette: search hint, group labels (Pages /
  Modules / Store / Flows), keyboard hint footer, indexing /
  no-matches states, dynamic-hit copy ("installed module ·
  v{version}", "saved flow", "uncategorized"). Static page
  hits now flow through the localized "Pages" group; Settings
  entry uses the localized label and hint.

Status pill values ("published", "alpha", "planned") stay as
data-derived English strings since they map directly to the
store-index `status:` enum.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 02:49:18 +02:00
parent d25b4c87ae
commit e2b2639a86
9 changed files with 990 additions and 109 deletions

View file

@ -117,6 +117,66 @@
"@storeNResults": { "@storeNResults": {
"placeholders": { "n": { "type": "int" } } "placeholders": { "n": { "type": "int" } }
}, },
"storeReloadTooltip": "Aktualisieren",
"storePillInstalled": "installiert",
"storePillUpdate": "Update",
"storeUpdateButton": "Update auf {version}",
"@storeUpdateButton": { "placeholders": { "version": { "type": "String" } } },
"storeUpdateTooltip": "Installiert {installed} — Store bietet v{best}",
"@storeUpdateTooltip": {
"placeholders": {
"installed": { "type": "String" },
"best": { "type": "String" }
}
},
"storeNoTagline": "(keine Beschreibung)",
"storeUninstallTitle": "Modul deinstallieren?",
"storeUninstallBody": "Entfernt {name} aus diesem Hub. Flows, die es verwenden, schlagen beim nächsten Lauf fehl.",
"@storeUninstallBody": { "placeholders": { "name": { "type": "String" } } },
"storeInstalledToast": "{name} v{version} installiert.",
"@storeInstalledToast": {
"placeholders": {
"name": { "type": "String" },
"version": { "type": "String" }
}
},
"storeInstallFailedToast": "Installation fehlgeschlagen: {error}",
"@storeInstallFailedToast": { "placeholders": { "error": { "type": "String" } } },
"storeUninstalledToast": "{name} v{version} deinstalliert.",
"@storeUninstalledToast": {
"placeholders": {
"name": { "type": "String" },
"version": { "type": "String" }
}
},
"storeUninstallFailedToast": "Deinstallation fehlgeschlagen: {error}",
"@storeUninstallFailedToast": { "placeholders": { "error": { "type": "String" } } },
"storeOpenBrowserFailed": "Browser konnte nicht geöffnet werden: {error}",
"@storeOpenBrowserFailed": { "placeholders": { "error": { "type": "String" } } },
"storeSectionTags": "Tags",
"storeSectionRequiredCapabilities": "Erforderliche Capabilities",
"storeSectionRequiredHostServices": "Erforderliche Host-Dienste",
"storeSectionScreenshots": "Screenshots",
"storeSectionDocumentation": "Dokumentation",
"storeSectionSource": "Quelle",
"storeInstallingButton": "Installiere…",
"storeNotInstallable": "Nicht installierbar",
"storeNotInstallableTooltip": "Status „{status}\" — Installations-Pfad noch nicht verdrahtet.",
"@storeNotInstallableTooltip": { "placeholders": { "status": { "type": "String" } } },
"storeInstallProgressTitle": "Installation von {name}",
"@storeInstallProgressTitle": { "placeholders": { "name": { "type": "String" } } },
"storeInstallProgressBody": "Holen, verifizieren, entpacken…",
"storeLoadDocs": "Dokumentation laden",
"storeDocsHint": "README inline gerendert; nutzt das Registry-Token des Hubs, falls nötig.",
"storeDocsFetching": "README wird geladen…",
"storeOpenRepoButton": "Repository im Browser öffnen",
"storeDocsErrorNotFound": "Dieses Modul ist nicht im Store-Index des Hubs enthalten.",
"storeDocsErrorNoDocs": "Für dieses Modul ist keine Repository-URL hinterlegt.",
"storeDocsErrorAuth": "Die Registry erfordert Authentifizierung, um dieses README zu laden. Setze FAI_REGISTRY_TOKEN in der Umgebung des Daemons und starte den Hub neu.",
"storeDocsErrorNetwork": "Netzwerkfehler beim Laden des README. {detail}",
"@storeDocsErrorNetwork": { "placeholders": { "detail": { "type": "String" } } },
"storeDocsErrorParse": "README ist kein gültiges UTF-8.",
"storeDocsErrorGeneric": "README konnte nicht geladen werden.",
"storeFederationNudgeTitle": "Mehr Capabilities?", "storeFederationNudgeTitle": "Mehr Capabilities?",
"storeFederationNudgeBody": "MCP-Server oder n8n-Endpunkt unter Einstellungen → MCP-Clients / N8N-Endpunkte konfigurieren. Entdeckte Tools und Workflows tauchen hier als `mcp.<server>.<tool>`- und `n8n.<endpoint>.<workflow>`-Capabilities auf — jeder Anthropic-/Community-MCP-Server bringt mehrere auf einmal.", "storeFederationNudgeBody": "MCP-Server oder n8n-Endpunkt unter Einstellungen → MCP-Clients / N8N-Endpunkte konfigurieren. Entdeckte Tools und Workflows tauchen hier als `mcp.<server>.<tool>`- und `n8n.<endpoint>.<workflow>`-Capabilities auf — jeder Anthropic-/Community-MCP-Server bringt mehrere auf einmal.",
"storeFederationNudgeButton": "Einstellungen öffnen", "storeFederationNudgeButton": "Einstellungen öffnen",
@ -328,5 +388,28 @@
"hubUnreachableHint": "Hub starten mit `fai serve`.", "hubUnreachableHint": "Hub starten mit `fai serve`.",
"languageEnglish": "English", "languageEnglish": "English",
"languageGerman": "Deutsch" "languageGerman": "Deutsch",
"searchHint": "Springe überall hin — Module, Store, Flows, Seiten…",
"searchIndexing": "Indiziere…",
"searchNoMatches": "Keine Treffer.",
"searchHintNavigate": "↑↓ navigieren",
"searchHintOpen": "Enter öffnen",
"searchHintClose": "Esc schließen",
"searchGroupPages": "Seiten",
"searchGroupModules": "Module",
"searchGroupStore": "Store",
"searchGroupFlows": "Flows",
"searchSettingsLabel": "Einstellungen",
"searchPageHint": "Seite · ⌘{n}",
"@searchPageHint": { "placeholders": { "n": { "type": "int" } } },
"searchSettingsHint": "Hub-Endpunkt, Kanäle, System-AI · ⌘;",
"searchInstalledModuleHint": "installiertes Modul · v{version}",
"@searchInstalledModuleHint": { "placeholders": { "version": { "type": "String" } } },
"searchStoreUncategorized": "ohne Kategorie",
"searchStoreHintWithTagline": "Store · {tagline}",
"@searchStoreHintWithTagline": { "placeholders": { "tagline": { "type": "String" } } },
"searchStoreHintWithCategory": "Store · {category}",
"@searchStoreHintWithCategory": { "placeholders": { "category": { "type": "String" } } },
"searchSavedFlowHint": "gespeicherter Flow"
} }

View file

@ -118,6 +118,66 @@
"@storeNResults": { "@storeNResults": {
"placeholders": { "n": { "type": "int" } } "placeholders": { "n": { "type": "int" } }
}, },
"storeReloadTooltip": "Reload",
"storePillInstalled": "installed",
"storePillUpdate": "update",
"storeUpdateButton": "Update to {version}",
"@storeUpdateButton": { "placeholders": { "version": { "type": "String" } } },
"storeUpdateTooltip": "Installed {installed} — store has v{best}",
"@storeUpdateTooltip": {
"placeholders": {
"installed": { "type": "String" },
"best": { "type": "String" }
}
},
"storeNoTagline": "(no tagline)",
"storeUninstallTitle": "Uninstall module?",
"storeUninstallBody": "Removes {name} from this hub. Flows that reference it will fail at the next run.",
"@storeUninstallBody": { "placeholders": { "name": { "type": "String" } } },
"storeInstalledToast": "{name} v{version} installed.",
"@storeInstalledToast": {
"placeholders": {
"name": { "type": "String" },
"version": { "type": "String" }
}
},
"storeInstallFailedToast": "Install failed: {error}",
"@storeInstallFailedToast": { "placeholders": { "error": { "type": "String" } } },
"storeUninstalledToast": "{name} v{version} uninstalled.",
"@storeUninstalledToast": {
"placeholders": {
"name": { "type": "String" },
"version": { "type": "String" }
}
},
"storeUninstallFailedToast": "Uninstall failed: {error}",
"@storeUninstallFailedToast": { "placeholders": { "error": { "type": "String" } } },
"storeOpenBrowserFailed": "Could not open browser: {error}",
"@storeOpenBrowserFailed": { "placeholders": { "error": { "type": "String" } } },
"storeSectionTags": "Tags",
"storeSectionRequiredCapabilities": "Required capabilities",
"storeSectionRequiredHostServices": "Required host services",
"storeSectionScreenshots": "Screenshots",
"storeSectionDocumentation": "Documentation",
"storeSectionSource": "Source",
"storeInstallingButton": "Installing…",
"storeNotInstallable": "Not installable",
"storeNotInstallableTooltip": "Status \"{status}\" — install path not yet wired.",
"@storeNotInstallableTooltip": { "placeholders": { "status": { "type": "String" } } },
"storeInstallProgressTitle": "Installing {name}",
"@storeInstallProgressTitle": { "placeholders": { "name": { "type": "String" } } },
"storeInstallProgressBody": "Fetching, verifying, unpacking…",
"storeLoadDocs": "Load documentation",
"storeDocsHint": "README rendered inline; uses the hub's registry token when needed.",
"storeDocsFetching": "Fetching README…",
"storeOpenRepoButton": "Open repository in browser",
"storeDocsErrorNotFound": "This module is not in the hub's store index.",
"storeDocsErrorNoDocs": "No repository URL is recorded for this module.",
"storeDocsErrorAuth": "The registry requires authentication to fetch this README. Set FAI_REGISTRY_TOKEN in the daemon's environment and restart the hub.",
"storeDocsErrorNetwork": "Network error while fetching the README. {detail}",
"@storeDocsErrorNetwork": { "placeholders": { "detail": { "type": "String" } } },
"storeDocsErrorParse": "README is not valid UTF-8.",
"storeDocsErrorGeneric": "Could not load README.",
"storeFederationNudgeTitle": "Want more capabilities?", "storeFederationNudgeTitle": "Want more capabilities?",
"storeFederationNudgeBody": "Configure an MCP server or n8n endpoint in Settings → MCP Clients / N8N Endpoints. Discovered tools and workflows surface here as `mcp.<server>.<tool>` and `n8n.<endpoint>.<workflow>` capabilities — every Anthropic / community MCP server adds a handful at once.", "storeFederationNudgeBody": "Configure an MCP server or n8n endpoint in Settings → MCP Clients / N8N Endpoints. Discovered tools and workflows surface here as `mcp.<server>.<tool>` and `n8n.<endpoint>.<workflow>` capabilities — every Anthropic / community MCP server adds a handful at once.",
"storeFederationNudgeButton": "Open Settings", "storeFederationNudgeButton": "Open Settings",
@ -329,5 +389,28 @@
"hubUnreachableHint": "Start the hub with `fai serve`.", "hubUnreachableHint": "Start the hub with `fai serve`.",
"languageEnglish": "English", "languageEnglish": "English",
"languageGerman": "Deutsch" "languageGerman": "Deutsch",
"searchHint": "Jump anywhere — modules, store, flows, pages…",
"searchIndexing": "Indexing…",
"searchNoMatches": "No matches.",
"searchHintNavigate": "↑↓ navigate",
"searchHintOpen": "enter open",
"searchHintClose": "esc close",
"searchGroupPages": "Pages",
"searchGroupModules": "Modules",
"searchGroupStore": "Store",
"searchGroupFlows": "Flows",
"searchSettingsLabel": "Settings",
"searchPageHint": "page · ⌘{n}",
"@searchPageHint": { "placeholders": { "n": { "type": "int" } } },
"searchSettingsHint": "hub endpoint, channels, system AI · ⌘;",
"searchInstalledModuleHint": "installed module · v{version}",
"@searchInstalledModuleHint": { "placeholders": { "version": { "type": "String" } } },
"searchStoreUncategorized": "uncategorized",
"searchStoreHintWithTagline": "store · {tagline}",
"@searchStoreHintWithTagline": { "placeholders": { "tagline": { "type": "String" } } },
"searchStoreHintWithCategory": "store · {category}",
"@searchStoreHintWithCategory": { "placeholders": { "category": { "type": "String" } } },
"searchSavedFlowHint": "saved flow"
} }

View file

@ -686,6 +686,210 @@ abstract class AppLocalizations {
/// **'{n} result{n, plural, =1{} other{s}}'** /// **'{n} result{n, plural, =1{} other{s}}'**
String storeNResults(int n); String storeNResults(int n);
/// No description provided for @storeReloadTooltip.
///
/// In en, this message translates to:
/// **'Reload'**
String get storeReloadTooltip;
/// No description provided for @storePillInstalled.
///
/// In en, this message translates to:
/// **'installed'**
String get storePillInstalled;
/// No description provided for @storePillUpdate.
///
/// In en, this message translates to:
/// **'update'**
String get storePillUpdate;
/// No description provided for @storeUpdateButton.
///
/// In en, this message translates to:
/// **'Update to {version}'**
String storeUpdateButton(String version);
/// No description provided for @storeUpdateTooltip.
///
/// In en, this message translates to:
/// **'Installed {installed} — store has v{best}'**
String storeUpdateTooltip(String installed, String best);
/// No description provided for @storeNoTagline.
///
/// In en, this message translates to:
/// **'(no tagline)'**
String get storeNoTagline;
/// No description provided for @storeUninstallTitle.
///
/// In en, this message translates to:
/// **'Uninstall module?'**
String get storeUninstallTitle;
/// No description provided for @storeUninstallBody.
///
/// In en, this message translates to:
/// **'Removes {name} from this hub. Flows that reference it will fail at the next run.'**
String storeUninstallBody(String name);
/// No description provided for @storeInstalledToast.
///
/// In en, this message translates to:
/// **'{name} v{version} installed.'**
String storeInstalledToast(String name, String version);
/// No description provided for @storeInstallFailedToast.
///
/// In en, this message translates to:
/// **'Install failed: {error}'**
String storeInstallFailedToast(String error);
/// No description provided for @storeUninstalledToast.
///
/// In en, this message translates to:
/// **'{name} v{version} uninstalled.'**
String storeUninstalledToast(String name, String version);
/// No description provided for @storeUninstallFailedToast.
///
/// In en, this message translates to:
/// **'Uninstall failed: {error}'**
String storeUninstallFailedToast(String error);
/// No description provided for @storeOpenBrowserFailed.
///
/// In en, this message translates to:
/// **'Could not open browser: {error}'**
String storeOpenBrowserFailed(String error);
/// No description provided for @storeSectionTags.
///
/// In en, this message translates to:
/// **'Tags'**
String get storeSectionTags;
/// No description provided for @storeSectionRequiredCapabilities.
///
/// In en, this message translates to:
/// **'Required capabilities'**
String get storeSectionRequiredCapabilities;
/// No description provided for @storeSectionRequiredHostServices.
///
/// In en, this message translates to:
/// **'Required host services'**
String get storeSectionRequiredHostServices;
/// No description provided for @storeSectionScreenshots.
///
/// In en, this message translates to:
/// **'Screenshots'**
String get storeSectionScreenshots;
/// No description provided for @storeSectionDocumentation.
///
/// In en, this message translates to:
/// **'Documentation'**
String get storeSectionDocumentation;
/// No description provided for @storeSectionSource.
///
/// In en, this message translates to:
/// **'Source'**
String get storeSectionSource;
/// No description provided for @storeInstallingButton.
///
/// In en, this message translates to:
/// **'Installing…'**
String get storeInstallingButton;
/// No description provided for @storeNotInstallable.
///
/// In en, this message translates to:
/// **'Not installable'**
String get storeNotInstallable;
/// No description provided for @storeNotInstallableTooltip.
///
/// In en, this message translates to:
/// **'Status \"{status}\" — install path not yet wired.'**
String storeNotInstallableTooltip(String status);
/// No description provided for @storeInstallProgressTitle.
///
/// In en, this message translates to:
/// **'Installing {name}'**
String storeInstallProgressTitle(String name);
/// No description provided for @storeInstallProgressBody.
///
/// In en, this message translates to:
/// **'Fetching, verifying, unpacking…'**
String get storeInstallProgressBody;
/// No description provided for @storeLoadDocs.
///
/// In en, this message translates to:
/// **'Load documentation'**
String get storeLoadDocs;
/// No description provided for @storeDocsHint.
///
/// In en, this message translates to:
/// **'README rendered inline; uses the hub\'s registry token when needed.'**
String get storeDocsHint;
/// No description provided for @storeDocsFetching.
///
/// In en, this message translates to:
/// **'Fetching README…'**
String get storeDocsFetching;
/// No description provided for @storeOpenRepoButton.
///
/// In en, this message translates to:
/// **'Open repository in browser'**
String get storeOpenRepoButton;
/// No description provided for @storeDocsErrorNotFound.
///
/// In en, this message translates to:
/// **'This module is not in the hub\'s store index.'**
String get storeDocsErrorNotFound;
/// No description provided for @storeDocsErrorNoDocs.
///
/// In en, this message translates to:
/// **'No repository URL is recorded for this module.'**
String get storeDocsErrorNoDocs;
/// No description provided for @storeDocsErrorAuth.
///
/// In en, this message translates to:
/// **'The registry requires authentication to fetch this README. Set FAI_REGISTRY_TOKEN in the daemon\'s environment and restart the hub.'**
String get storeDocsErrorAuth;
/// No description provided for @storeDocsErrorNetwork.
///
/// In en, this message translates to:
/// **'Network error while fetching the README. {detail}'**
String storeDocsErrorNetwork(String detail);
/// No description provided for @storeDocsErrorParse.
///
/// In en, this message translates to:
/// **'README is not valid UTF-8.'**
String get storeDocsErrorParse;
/// No description provided for @storeDocsErrorGeneric.
///
/// In en, this message translates to:
/// **'Could not load README.'**
String get storeDocsErrorGeneric;
/// No description provided for @storeFederationNudgeTitle. /// No description provided for @storeFederationNudgeTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -1471,6 +1675,114 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Deutsch'** /// **'Deutsch'**
String get languageGerman; String get languageGerman;
/// No description provided for @searchHint.
///
/// In en, this message translates to:
/// **'Jump anywhere — modules, store, flows, pages…'**
String get searchHint;
/// No description provided for @searchIndexing.
///
/// In en, this message translates to:
/// **'Indexing…'**
String get searchIndexing;
/// No description provided for @searchNoMatches.
///
/// In en, this message translates to:
/// **'No matches.'**
String get searchNoMatches;
/// No description provided for @searchHintNavigate.
///
/// In en, this message translates to:
/// **'↑↓ navigate'**
String get searchHintNavigate;
/// No description provided for @searchHintOpen.
///
/// In en, this message translates to:
/// **'enter open'**
String get searchHintOpen;
/// No description provided for @searchHintClose.
///
/// In en, this message translates to:
/// **'esc close'**
String get searchHintClose;
/// No description provided for @searchGroupPages.
///
/// In en, this message translates to:
/// **'Pages'**
String get searchGroupPages;
/// No description provided for @searchGroupModules.
///
/// In en, this message translates to:
/// **'Modules'**
String get searchGroupModules;
/// No description provided for @searchGroupStore.
///
/// In en, this message translates to:
/// **'Store'**
String get searchGroupStore;
/// No description provided for @searchGroupFlows.
///
/// In en, this message translates to:
/// **'Flows'**
String get searchGroupFlows;
/// No description provided for @searchSettingsLabel.
///
/// In en, this message translates to:
/// **'Settings'**
String get searchSettingsLabel;
/// No description provided for @searchPageHint.
///
/// In en, this message translates to:
/// **'page · ⌘{n}'**
String searchPageHint(int n);
/// No description provided for @searchSettingsHint.
///
/// In en, this message translates to:
/// **'hub endpoint, channels, system AI · ⌘;'**
String get searchSettingsHint;
/// No description provided for @searchInstalledModuleHint.
///
/// In en, this message translates to:
/// **'installed module · v{version}'**
String searchInstalledModuleHint(String version);
/// No description provided for @searchStoreUncategorized.
///
/// In en, this message translates to:
/// **'uncategorized'**
String get searchStoreUncategorized;
/// No description provided for @searchStoreHintWithTagline.
///
/// In en, this message translates to:
/// **'store · {tagline}'**
String searchStoreHintWithTagline(String tagline);
/// No description provided for @searchStoreHintWithCategory.
///
/// In en, this message translates to:
/// **'store · {category}'**
String searchStoreHintWithCategory(String category);
/// No description provided for @searchSavedFlowHint.
///
/// In en, this message translates to:
/// **'saved flow'**
String get searchSavedFlowHint;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -342,6 +342,134 @@ class AppLocalizationsDe extends AppLocalizations {
return '$n Treffer'; return '$n Treffer';
} }
@override
String get storeReloadTooltip => 'Aktualisieren';
@override
String get storePillInstalled => 'installiert';
@override
String get storePillUpdate => 'Update';
@override
String storeUpdateButton(String version) {
return 'Update auf $version';
}
@override
String storeUpdateTooltip(String installed, String best) {
return 'Installiert $installed — Store bietet v$best';
}
@override
String get storeNoTagline => '(keine Beschreibung)';
@override
String get storeUninstallTitle => 'Modul deinstallieren?';
@override
String storeUninstallBody(String name) {
return 'Entfernt $name aus diesem Hub. Flows, die es verwenden, schlagen beim nächsten Lauf fehl.';
}
@override
String storeInstalledToast(String name, String version) {
return '$name v$version installiert.';
}
@override
String storeInstallFailedToast(String error) {
return 'Installation fehlgeschlagen: $error';
}
@override
String storeUninstalledToast(String name, String version) {
return '$name v$version deinstalliert.';
}
@override
String storeUninstallFailedToast(String error) {
return 'Deinstallation fehlgeschlagen: $error';
}
@override
String storeOpenBrowserFailed(String error) {
return 'Browser konnte nicht geöffnet werden: $error';
}
@override
String get storeSectionTags => 'Tags';
@override
String get storeSectionRequiredCapabilities => 'Erforderliche Capabilities';
@override
String get storeSectionRequiredHostServices => 'Erforderliche Host-Dienste';
@override
String get storeSectionScreenshots => 'Screenshots';
@override
String get storeSectionDocumentation => 'Dokumentation';
@override
String get storeSectionSource => 'Quelle';
@override
String get storeInstallingButton => 'Installiere…';
@override
String get storeNotInstallable => 'Nicht installierbar';
@override
String storeNotInstallableTooltip(String status) {
return 'Status „$status\" — Installations-Pfad noch nicht verdrahtet.';
}
@override
String storeInstallProgressTitle(String name) {
return 'Installation von $name';
}
@override
String get storeInstallProgressBody => 'Holen, verifizieren, entpacken…';
@override
String get storeLoadDocs => 'Dokumentation laden';
@override
String get storeDocsHint =>
'README inline gerendert; nutzt das Registry-Token des Hubs, falls nötig.';
@override
String get storeDocsFetching => 'README wird geladen…';
@override
String get storeOpenRepoButton => 'Repository im Browser öffnen';
@override
String get storeDocsErrorNotFound =>
'Dieses Modul ist nicht im Store-Index des Hubs enthalten.';
@override
String get storeDocsErrorNoDocs =>
'Für dieses Modul ist keine Repository-URL hinterlegt.';
@override
String get storeDocsErrorAuth =>
'Die Registry erfordert Authentifizierung, um dieses README zu laden. Setze FAI_REGISTRY_TOKEN in der Umgebung des Daemons und starte den Hub neu.';
@override
String storeDocsErrorNetwork(String detail) {
return 'Netzwerkfehler beim Laden des README. $detail';
}
@override
String get storeDocsErrorParse => 'README ist kein gültiges UTF-8.';
@override
String get storeDocsErrorGeneric => 'README konnte nicht geladen werden.';
@override @override
String get storeFederationNudgeTitle => 'Mehr Capabilities?'; String get storeFederationNudgeTitle => 'Mehr Capabilities?';
@ -814,4 +942,67 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get languageGerman => 'Deutsch'; String get languageGerman => 'Deutsch';
@override
String get searchHint =>
'Springe überall hin — Module, Store, Flows, Seiten…';
@override
String get searchIndexing => 'Indiziere…';
@override
String get searchNoMatches => 'Keine Treffer.';
@override
String get searchHintNavigate => '↑↓ navigieren';
@override
String get searchHintOpen => 'Enter öffnen';
@override
String get searchHintClose => 'Esc schließen';
@override
String get searchGroupPages => 'Seiten';
@override
String get searchGroupModules => 'Module';
@override
String get searchGroupStore => 'Store';
@override
String get searchGroupFlows => 'Flows';
@override
String get searchSettingsLabel => 'Einstellungen';
@override
String searchPageHint(int n) {
return 'Seite · ⌘$n';
}
@override
String get searchSettingsHint => 'Hub-Endpunkt, Kanäle, System-AI · ⌘;';
@override
String searchInstalledModuleHint(String version) {
return 'installiertes Modul · v$version';
}
@override
String get searchStoreUncategorized => 'ohne Kategorie';
@override
String searchStoreHintWithTagline(String tagline) {
return 'Store · $tagline';
}
@override
String searchStoreHintWithCategory(String category) {
return 'Store · $category';
}
@override
String get searchSavedFlowHint => 'gespeicherter Flow';
} }

View file

@ -360,6 +360,134 @@ class AppLocalizationsEn extends AppLocalizations {
return '$n result$_temp0'; return '$n result$_temp0';
} }
@override
String get storeReloadTooltip => 'Reload';
@override
String get storePillInstalled => 'installed';
@override
String get storePillUpdate => 'update';
@override
String storeUpdateButton(String version) {
return 'Update to $version';
}
@override
String storeUpdateTooltip(String installed, String best) {
return 'Installed $installed — store has v$best';
}
@override
String get storeNoTagline => '(no tagline)';
@override
String get storeUninstallTitle => 'Uninstall module?';
@override
String storeUninstallBody(String name) {
return 'Removes $name from this hub. Flows that reference it will fail at the next run.';
}
@override
String storeInstalledToast(String name, String version) {
return '$name v$version installed.';
}
@override
String storeInstallFailedToast(String error) {
return 'Install failed: $error';
}
@override
String storeUninstalledToast(String name, String version) {
return '$name v$version uninstalled.';
}
@override
String storeUninstallFailedToast(String error) {
return 'Uninstall failed: $error';
}
@override
String storeOpenBrowserFailed(String error) {
return 'Could not open browser: $error';
}
@override
String get storeSectionTags => 'Tags';
@override
String get storeSectionRequiredCapabilities => 'Required capabilities';
@override
String get storeSectionRequiredHostServices => 'Required host services';
@override
String get storeSectionScreenshots => 'Screenshots';
@override
String get storeSectionDocumentation => 'Documentation';
@override
String get storeSectionSource => 'Source';
@override
String get storeInstallingButton => 'Installing…';
@override
String get storeNotInstallable => 'Not installable';
@override
String storeNotInstallableTooltip(String status) {
return 'Status \"$status\" — install path not yet wired.';
}
@override
String storeInstallProgressTitle(String name) {
return 'Installing $name';
}
@override
String get storeInstallProgressBody => 'Fetching, verifying, unpacking…';
@override
String get storeLoadDocs => 'Load documentation';
@override
String get storeDocsHint =>
'README rendered inline; uses the hub\'s registry token when needed.';
@override
String get storeDocsFetching => 'Fetching README…';
@override
String get storeOpenRepoButton => 'Open repository in browser';
@override
String get storeDocsErrorNotFound =>
'This module is not in the hub\'s store index.';
@override
String get storeDocsErrorNoDocs =>
'No repository URL is recorded for this module.';
@override
String get storeDocsErrorAuth =>
'The registry requires authentication to fetch this README. Set FAI_REGISTRY_TOKEN in the daemon\'s environment and restart the hub.';
@override
String storeDocsErrorNetwork(String detail) {
return 'Network error while fetching the README. $detail';
}
@override
String get storeDocsErrorParse => 'README is not valid UTF-8.';
@override
String get storeDocsErrorGeneric => 'Could not load README.';
@override @override
String get storeFederationNudgeTitle => 'Want more capabilities?'; String get storeFederationNudgeTitle => 'Want more capabilities?';
@ -827,4 +955,66 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get languageGerman => 'Deutsch'; String get languageGerman => 'Deutsch';
@override
String get searchHint => 'Jump anywhere — modules, store, flows, pages…';
@override
String get searchIndexing => 'Indexing…';
@override
String get searchNoMatches => 'No matches.';
@override
String get searchHintNavigate => '↑↓ navigate';
@override
String get searchHintOpen => 'enter open';
@override
String get searchHintClose => 'esc close';
@override
String get searchGroupPages => 'Pages';
@override
String get searchGroupModules => 'Modules';
@override
String get searchGroupStore => 'Store';
@override
String get searchGroupFlows => 'Flows';
@override
String get searchSettingsLabel => 'Settings';
@override
String searchPageHint(int n) {
return 'page · ⌘$n';
}
@override
String get searchSettingsHint => 'hub endpoint, channels, system AI · ⌘;';
@override
String searchInstalledModuleHint(String version) {
return 'installed module · v$version';
}
@override
String get searchStoreUncategorized => 'uncategorized';
@override
String searchStoreHintWithTagline(String tagline) {
return 'store · $tagline';
}
@override
String searchStoreHintWithCategory(String category) {
return 'store · $category';
}
@override
String get searchSavedFlowHint => 'saved flow';
} }

View file

@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.25.0'; const String kStudioVersion = '0.27.0';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -215,23 +215,25 @@ class _StudioShellState extends State<StudioShell> {
} }
void _openSearchPalette() { void _openSearchPalette() {
final l = AppLocalizations.of(context)!;
final pagesGroup = l.searchGroupPages;
final hits = <FaiSearchHit>[ final hits = <FaiSearchHit>[
for (var i = 0; i < _pages.length; i++) for (var i = 0; i < _pages.length; i++)
FaiSearchHit( FaiSearchHit(
label: _pages[i].labelOf(context), label: _pages[i].labelOf(context),
hint: 'page · ⌘${i + 1}', hint: l.searchPageHint(i + 1),
icon: _pages[i].icon, icon: _pages[i].icon,
group: 'Pages', group: pagesGroup,
onSelect: () { onSelect: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
setState(() => _selectedIndex = i); setState(() => _selectedIndex = i);
}, },
), ),
FaiSearchHit( FaiSearchHit(
label: 'Settings', label: l.searchSettingsLabel,
hint: 'hub endpoint, channels, system AI · ⌘;', hint: l.searchSettingsHint,
icon: Icons.settings_outlined, icon: Icons.settings_outlined,
group: 'Pages', group: pagesGroup,
onSelect: () { onSelect: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
FaiSettingsDialog.show(context); FaiSettingsDialog.show(context);

View file

@ -99,10 +99,11 @@ class _StorePageState extends State<StorePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar( appBar: AppBar(
title: const Text('Store'), title: Text(l.searchGroupStore),
actions: [ actions: [
// The DE/EN toggle now lives in the sidebar footer // The DE/EN toggle now lives in the sidebar footer
// and flips the entire app's locale. The Store // and flips the entire app's locale. The Store
@ -110,7 +111,7 @@ class _StorePageState extends State<StorePage> {
// _locale reads from `Localizations.localeOf`. // _locale reads from `Localizations.localeOf`.
IconButton( IconButton(
icon: const Icon(Icons.refresh, size: 18), icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Reload', tooltip: l.storeReloadTooltip,
onPressed: _runSearch, onPressed: _runSearch,
), ),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
@ -181,11 +182,11 @@ class _StorePageState extends State<StorePage> {
return FaiEmptyState( return FaiEmptyState(
icon: Icons.cloud_off_outlined, icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error, iconColor: theme.colorScheme.error,
title: 'Hub unreachable', title: l.hubUnreachable,
hint: 'Start the hub with `fai daemon start`.', hint: l.hubUnreachableHint,
action: FilledButton.tonal( action: FilledButton.tonal(
onPressed: _runSearch, onPressed: _runSearch,
child: const Text('Retry'), child: Text(l.buttonRetry),
), ),
); );
} }
@ -193,9 +194,8 @@ class _StorePageState extends State<StorePage> {
if (items.isEmpty) { if (items.isEmpty) {
return FaiEmptyState( return FaiEmptyState(
icon: Icons.search_off, icon: Icons.search_off,
title: 'No matches', title: l.storeNoMatches,
hint: hint: l.storeNoMatchesHint,
'Adjust the filters or clear the search to see all entries.',
action: FilledButton.tonal( action: FilledButton.tonal(
onPressed: () { onPressed: () {
setState(() { setState(() {
@ -206,7 +206,7 @@ class _StorePageState extends State<StorePage> {
}); });
_runSearch(); _runSearch();
}, },
child: const Text('Clear filters'), child: Text(l.storeClearFilters),
), ),
); );
} }
@ -293,6 +293,7 @@ class _SearchField extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return ValueListenableBuilder<TextEditingValue>( return ValueListenableBuilder<TextEditingValue>(
valueListenable: controller, valueListenable: controller,
builder: (_, value, _) { builder: (_, value, _) {
@ -300,7 +301,7 @@ class _SearchField extends StatelessWidget {
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: const Icon(Icons.search, size: 20), prefixIcon: const Icon(Icons.search, size: 20),
hintText: 'Search modules — name, tagline, tag, capability…', hintText: l.storeSearchHint,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
isDense: true, isDense: true,
suffixIcon: value.text.isEmpty suffixIcon: value.text.isEmpty
@ -334,6 +335,7 @@ class _CategoryStrip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
if (categories.isEmpty) return const SizedBox.shrink(); if (categories.isEmpty) return const SizedBox.shrink();
return SizedBox( return SizedBox(
height: 36, height: 36,
@ -341,7 +343,7 @@ class _CategoryStrip extends StatelessWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
children: [ children: [
_ChoiceChip( _ChoiceChip(
label: 'All', label: l.storeStatusAll,
selected: selected.isEmpty, selected: selected.isEmpty,
onSelected: () => onSelected(''), onSelected: () => onSelected(''),
), ),
@ -375,38 +377,39 @@ class _FilterRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Row( return Row(
children: [ children: [
_ChoiceChip( _ChoiceChip(
label: 'All', label: l.storeStatusAll,
selected: status.isEmpty, selected: status.isEmpty,
onSelected: () => onStatus(''), onSelected: () => onStatus(''),
), ),
_ChoiceChip( _ChoiceChip(
label: 'Published', label: l.storeStatusPublished,
selected: status == 'published', selected: status == 'published',
onSelected: () => onStatus('published'), onSelected: () => onStatus('published'),
), ),
_ChoiceChip( _ChoiceChip(
label: 'Alpha', label: l.storeStatusAlpha,
selected: status == 'alpha', selected: status == 'alpha',
onSelected: () => onStatus('alpha'), onSelected: () => onStatus('alpha'),
), ),
_ChoiceChip( _ChoiceChip(
label: 'Planned', label: l.storeStatusPlanned,
selected: status == 'planned', selected: status == 'planned',
onSelected: () => onStatus('planned'), onSelected: () => onStatus('planned'),
), ),
const SizedBox(width: FaiSpace.md), const SizedBox(width: FaiSpace.md),
FilterChip( FilterChip(
label: const Text('Installed'), label: Text(l.storeInstalledOnly),
selected: installedOnly, selected: installedOnly,
onSelected: onInstalledOnly, onSelected: onInstalledOnly,
showCheckmark: true, showCheckmark: true,
), ),
const Spacer(), const Spacer(),
Text( Text(
'$resultCount result${resultCount == 1 ? "" : "s"}', l.storeNResults(resultCount),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -512,6 +515,7 @@ class _FeaturedStrip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -520,7 +524,7 @@ class _FeaturedStrip extends StatelessWidget {
Icon(Icons.star, size: 14, color: FaiColors.success), Icon(Icons.star, size: 14, color: FaiColors.success),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'FEATURED', l.storeFeatured,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6, letterSpacing: 0.6,
@ -529,7 +533,7 @@ class _FeaturedStrip extends StatelessWidget {
), ),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
Text( Text(
'curated picks · click for details', l.storeFeaturedHint,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -582,6 +586,7 @@ class _FeaturedTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final tagline = locale == 'de' && item.taglineDe.isNotEmpty final tagline = locale == 'de' && item.taglineDe.isNotEmpty
? item.taglineDe ? item.taglineDe
: item.taglineEn; : item.taglineEn;
@ -649,7 +654,7 @@ class _FeaturedTile extends StatelessWidget {
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Expanded( Expanded(
child: Text( child: Text(
tagline.isEmpty ? '(no tagline)' : tagline, tagline.isEmpty ? l.storeNoTagline : tagline,
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -672,8 +677,8 @@ class _FeaturedTile extends StatelessWidget {
), ),
const Spacer(), const Spacer(),
if (item.installed) if (item.installed)
const FaiPill( FaiPill(
label: 'installed', label: l.storePillInstalled,
tone: FaiPillTone.success, tone: FaiPillTone.success,
icon: Icons.check, icon: Icons.check,
) )
@ -681,7 +686,7 @@ class _FeaturedTile extends StatelessWidget {
FilledButton.icon( FilledButton.icon(
onPressed: onInstall, onPressed: onInstall,
icon: const Icon(Icons.download, size: 16), icon: const Icon(Icons.download, size: 16),
label: const Text('Install'), label: Text(l.buttonInstall),
), ),
], ],
), ),
@ -724,6 +729,7 @@ class _StoreCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final tagline = locale == 'de' && item.taglineDe.isNotEmpty final tagline = locale == 'de' && item.taglineDe.isNotEmpty
? item.taglineDe ? item.taglineDe
: item.taglineEn; : item.taglineEn;
@ -775,17 +781,19 @@ class _StoreCard extends StatelessWidget {
), ),
if (_hasUpdate) if (_hasUpdate)
Tooltip( Tooltip(
message: message: l.storeUpdateTooltip(
'Installed ${installedVersion ?? "?"} — store has v${item.bestVersion}', installedVersion ?? '?',
child: const FaiPill( item.bestVersion,
label: 'update', ),
child: FaiPill(
label: l.storePillUpdate,
tone: FaiPillTone.warning, tone: FaiPillTone.warning,
icon: Icons.system_update_alt, icon: Icons.system_update_alt,
), ),
) )
else if (item.installed) else if (item.installed)
const FaiPill( FaiPill(
label: 'installed', label: l.storePillInstalled,
tone: FaiPillTone.success, tone: FaiPillTone.success,
icon: Icons.check, icon: Icons.check,
) )
@ -799,7 +807,7 @@ class _StoreCard extends StatelessWidget {
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Expanded( Expanded(
child: Text( child: Text(
tagline.isEmpty ? '(no tagline)' : tagline, tagline.isEmpty ? l.storeNoTagline : tagline,
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -819,7 +827,7 @@ class _StoreCard extends StatelessWidget {
FilledButton.icon( FilledButton.icon(
onPressed: onInstall, onPressed: onInstall,
icon: const Icon(Icons.system_update_alt, size: 14), icon: const Icon(Icons.system_update_alt, size: 14),
label: Text('Update to ${item.bestVersion}'), label: Text(l.storeUpdateButton(item.bestVersion)),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
), ),
@ -828,13 +836,13 @@ class _StoreCard extends StatelessWidget {
TextButton.icon( TextButton.icon(
onPressed: onTap, onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14), icon: const Icon(Icons.info_outline, size: 14),
label: const Text('Details'), label: Text(l.buttonDetails),
) )
else if (installable) else if (installable)
FilledButton.icon( FilledButton.icon(
onPressed: onInstall, onPressed: onInstall,
icon: const Icon(Icons.download, size: 14), icon: const Icon(Icons.download, size: 14),
label: const Text('Install'), label: Text(l.buttonInstall),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
), ),
@ -843,7 +851,7 @@ class _StoreCard extends StatelessWidget {
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onTap, onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14), icon: const Icon(Icons.info_outline, size: 14),
label: const Text('Details'), label: Text(l.buttonDetails),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
), ),
@ -906,33 +914,33 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
try { try {
final r = await HubService.instance.installModule(source: widget.item.name); final r = await HubService.instance.installModule(source: widget.item.name);
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!;
setState(() { setState(() {
_busy = false; _busy = false;
_toast = '${r.name} v${r.version} installed.'; _toast = l.storeInstalledToast(r.name, r.version);
}); });
Navigator.pop(context, true); Navigator.pop(context, true);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!;
setState(() { setState(() {
_busy = false; _busy = false;
_toast = 'Install failed: $e'; _toast = l.storeInstallFailedToast(e.toString());
}); });
} }
} }
Future<void> _uninstall() async { Future<void> _uninstall() async {
final l = AppLocalizations.of(context)!;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Uninstall module?'), title: Text(l.storeUninstallTitle),
content: Text( content: Text(l.storeUninstallBody(widget.item.name)),
'Removes ${widget.item.name} from this hub. Flows that '
'reference it will fail at the next run.',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, false), onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'), child: Text(l.buttonCancel),
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
@ -940,7 +948,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
backgroundColor: Theme.of(ctx).colorScheme.error, backgroundColor: Theme.of(ctx).colorScheme.error,
foregroundColor: Theme.of(ctx).colorScheme.onError, foregroundColor: Theme.of(ctx).colorScheme.onError,
), ),
child: const Text('Uninstall'), child: Text(l.buttonUninstall),
), ),
], ],
), ),
@ -953,16 +961,18 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
try { try {
final r = await HubService.instance.uninstallModule(widget.item.name); final r = await HubService.instance.uninstallModule(widget.item.name);
if (!mounted) return; if (!mounted) return;
final l2 = AppLocalizations.of(context)!;
setState(() { setState(() {
_busy = false; _busy = false;
_toast = '${r.name} v${r.version} uninstalled.'; _toast = l2.storeUninstalledToast(r.name, r.version);
}); });
Navigator.pop(context, true); Navigator.pop(context, true);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final l2 = AppLocalizations.of(context)!;
setState(() { setState(() {
_busy = false; _busy = false;
_toast = 'Uninstall failed: $e'; _toast = l2.storeUninstallFailedToast(e.toString());
}); });
} }
} }
@ -972,8 +982,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
final r = await SystemActions.openInOs(widget.item.repository); final r = await SystemActions.openInOs(widget.item.repository);
if (!mounted) return; if (!mounted) return;
if (!r.ok) { if (!r.ok) {
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open browser: ${r.stderr}')), SnackBar(content: Text(l.storeOpenBrowserFailed(r.stderr))),
); );
} }
} }
@ -990,6 +1001,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final item = widget.item; final item = widget.item;
final tagline = _locale == 'de' && item.taglineDe.isNotEmpty final tagline = _locale == 'de' && item.taglineDe.isNotEmpty
? item.taglineDe ? item.taglineDe
@ -1069,8 +1081,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
tone: FaiPillTone.neutral, tone: FaiPillTone.neutral,
), ),
if (item.installed) if (item.installed)
const FaiPill( FaiPill(
label: 'installed', label: l.storePillInstalled,
tone: FaiPillTone.success, tone: FaiPillTone.success,
icon: Icons.check, icon: Icons.check,
), ),
@ -1107,7 +1119,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
const SizedBox(height: FaiSpace.xl), const SizedBox(height: FaiSpace.xl),
], ],
if (item.tags.isNotEmpty) ...[ if (item.tags.isNotEmpty) ...[
_SectionHeader('Tags'), _SectionHeader(l.storeSectionTags),
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Wrap( Wrap(
spacing: FaiSpace.xs, spacing: FaiSpace.xs,
@ -1120,7 +1132,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
], ],
if (item.requiresCapabilities.isNotEmpty) ...[ if (item.requiresCapabilities.isNotEmpty) ...[
_SectionHeader('Required capabilities'), _SectionHeader(l.storeSectionRequiredCapabilities),
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Wrap( Wrap(
spacing: FaiSpace.xs, spacing: FaiSpace.xs,
@ -1137,7 +1149,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
], ],
if (item.requiresServices.isNotEmpty) ...[ if (item.requiresServices.isNotEmpty) ...[
_SectionHeader('Required host services'), _SectionHeader(l.storeSectionRequiredHostServices),
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Wrap( Wrap(
spacing: FaiSpace.xs, spacing: FaiSpace.xs,
@ -1154,7 +1166,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
], ],
if (item.screenshotUrls.isNotEmpty) ...[ if (item.screenshotUrls.isNotEmpty) ...[
_SectionHeader('Screenshots'), _SectionHeader(l.storeSectionScreenshots),
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
SizedBox( SizedBox(
height: 220, height: 220,
@ -1171,7 +1183,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
], ],
if (item.repository.isNotEmpty || item.docsUrl.isNotEmpty) ...[ if (item.repository.isNotEmpty || item.docsUrl.isNotEmpty) ...[
_SectionHeader('Documentation'), _SectionHeader(l.storeSectionDocumentation),
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
_DocsPanel( _DocsPanel(
future: _docsFuture, future: _docsFuture,
@ -1179,7 +1191,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
onOpenRepo: _openRepository, onOpenRepo: _openRepository,
), ),
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
_SectionHeader('Source'), _SectionHeader(l.storeSectionSource),
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
InkWell( InkWell(
onTap: _openRepository, onTap: _openRepository,
@ -1225,7 +1237,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _openRepository, onPressed: _openRepository,
icon: const Icon(Icons.menu_book_outlined, size: 16), icon: const Icon(Icons.menu_book_outlined, size: 16),
label: const Text('Read docs'), label: Text(l.buttonReadDocs),
), ),
const Spacer(), const Spacer(),
if (item.installed) ...[ if (item.installed) ...[
@ -1239,7 +1251,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
CircularProgressIndicator(strokeWidth: 2), CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.delete_outline, size: 16), : const Icon(Icons.delete_outline, size: 16),
label: const Text('Uninstall'), label: Text(l.buttonUninstall),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error, foregroundColor: theme.colorScheme.error,
side: BorderSide( side: BorderSide(
@ -1259,15 +1271,16 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
CircularProgressIndicator(strokeWidth: 2), CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.download, size: 16), : const Icon(Icons.download, size: 16),
label: Text(_busy ? 'Installing…' : 'Install'), label: Text(
_busy ? l.storeInstallingButton : l.buttonInstall,
),
) )
else else
Tooltip( Tooltip(
message: message: l.storeNotInstallableTooltip(item.status),
'Status "${item.status}" — install path not yet wired.', child: FilledButton(
child: const FilledButton(
onPressed: null, onPressed: null,
child: Text('Not installable'), child: Text(l.storeNotInstallable),
), ),
), ),
], ],
@ -1324,8 +1337,9 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog( return AlertDialog(
title: Text('Installing ${widget.item.name}'), title: Text(l.storeInstallProgressTitle(widget.item.name)),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md), borderRadius: BorderRadius.circular(FaiRadius.md),
), ),
@ -1342,7 +1356,7 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
const CircularProgressIndicator(), const CircularProgressIndicator(),
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Text( Text(
'Fetching, verifying, unpacking…', l.storeInstallProgressBody,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -1384,7 +1398,7 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
), ),
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Text( Text(
'${r.name} v${r.version} installed.', l.storeInstalledToast(r.name, r.version),
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
), ),
], ],
@ -1395,7 +1409,7 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: const Text('Close'), child: Text(l.buttonClose),
), ),
], ],
); );
@ -1458,17 +1472,18 @@ class _DocsPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
if (future == null) { if (future == null) {
return Row( return Row(
children: [ children: [
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onLoad, onPressed: onLoad,
icon: const Icon(Icons.menu_book_outlined, size: 16), icon: const Icon(Icons.menu_book_outlined, size: 16),
label: const Text('Load documentation'), label: Text(l.storeLoadDocs),
), ),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
Text( Text(
'README rendered inline; uses the hub\'s registry token when needed.', l.storeDocsHint,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -1480,17 +1495,17 @@ class _DocsPanel extends StatelessWidget {
future: future, future: future,
builder: (context, snap) { builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) { if (snap.connectionState == ConnectionState.waiting) {
return const Padding( return Padding(
padding: EdgeInsets.symmetric(vertical: FaiSpace.md), padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
child: Row( child: Row(
children: [ children: [
SizedBox( const SizedBox(
width: 14, width: 14,
height: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
), ),
SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
Text('Fetching README…'), Text(l.storeDocsFetching),
], ],
), ),
); );
@ -1504,7 +1519,7 @@ class _DocsPanel extends StatelessWidget {
final r = snap.data!; final r = snap.data!;
if (r.errorKind.isNotEmpty) { if (r.errorKind.isNotEmpty) {
return _DocsErrorPanel( return _DocsErrorPanel(
message: _friendlyDocsError(r.errorKind, r.text), message: _friendlyDocsError(context, r.errorKind, r.text),
onOpenRepo: onOpenRepo, onOpenRepo: onOpenRepo,
sourceUrl: r.sourceUrl, sourceUrl: r.sourceUrl,
); );
@ -1599,7 +1614,7 @@ class _DocsErrorPanel extends StatelessWidget {
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onOpenRepo, onPressed: onOpenRepo,
icon: const Icon(Icons.open_in_new, size: 14), icon: const Icon(Icons.open_in_new, size: 14),
label: const Text('Open repository in browser'), label: Text(AppLocalizations.of(context)!.storeOpenRepoButton),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
), ),
@ -1636,22 +1651,21 @@ int _versionCmp(String a, String b) {
return 0; return 0;
} }
String _friendlyDocsError(String kind, String detail) { String _friendlyDocsError(BuildContext context, String kind, String detail) {
final l = AppLocalizations.of(context)!;
switch (kind) { switch (kind) {
case 'not_found': case 'not_found':
return 'This module is not in the hub\'s store index.'; return l.storeDocsErrorNotFound;
case 'no_docs': case 'no_docs':
return 'No repository URL is recorded for this module.'; return l.storeDocsErrorNoDocs;
case 'auth': case 'auth':
return 'The registry requires authentication to fetch this README. ' return l.storeDocsErrorAuth;
'Set FAI_REGISTRY_TOKEN in the daemon\'s environment '
'and restart the hub.';
case 'network': case 'network':
return 'Network error while fetching the README. $detail'; return l.storeDocsErrorNetwork(detail);
case 'parse': case 'parse':
return 'README is not valid UTF-8.'; return l.storeDocsErrorParse;
default: default:
return detail.isEmpty ? 'Could not load README.' : detail; return detail.isEmpty ? l.storeDocsErrorGeneric : detail;
} }
} }

View file

@ -11,6 +11,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import 'fai_module_sheet.dart'; import 'fai_module_sheet.dart';
/// One row in the palette. Carries everything needed to render /// One row in the palette. Carries everything needed to render
@ -90,6 +91,7 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
/// first character. /// first character.
Future<List<FaiSearchHit>> _loadDynamic() async { Future<List<FaiSearchHit>> _loadDynamic() async {
final svc = HubService.instance; final svc = HubService.instance;
final l = AppLocalizations.of(context)!;
final results = await Future.wait([ final results = await Future.wait([
svc.listModules().catchError((_) => <ModuleSummary>[]), svc.listModules().catchError((_) => <ModuleSummary>[]),
svc.searchStore(limit: 200).catchError((_) => <StoreItem>[]), svc.searchStore(limit: 200).catchError((_) => <StoreItem>[]),
@ -103,23 +105,26 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
for (final m in modules) { for (final m in modules) {
hits.add(FaiSearchHit( hits.add(FaiSearchHit(
label: m.name, label: m.name,
hint: 'installed module · v${m.version}', hint: l.searchInstalledModuleHint(m.version),
icon: Icons.extension, icon: Icons.extension,
group: 'Modules', group: l.searchGroupModules,
onSelect: () { onSelect: () {
Navigator.pop(context); Navigator.pop(context);
FaiModuleSheet.show(context, m.name); FaiModuleSheet.show(context, m.name);
}, },
)); ));
} }
final storeGroup = l.searchGroupStore;
final pagesGroup = l.searchGroupPages;
for (final s in store) { for (final s in store) {
hits.add(FaiSearchHit( hits.add(FaiSearchHit(
label: s.name, label: s.name,
hint: s.taglineEn.isEmpty hint: s.taglineEn.isEmpty
? 'store · ${s.category.isEmpty ? "uncategorized" : s.category}' ? l.searchStoreHintWithCategory(
: 'store · ${s.taglineEn}', s.category.isEmpty ? l.searchStoreUncategorized : s.category)
: l.searchStoreHintWithTagline(s.taglineEn),
icon: Icons.storefront_outlined, icon: Icons.storefront_outlined,
group: 'Store', group: storeGroup,
// Selecting a store hit just closes the palette and // Selecting a store hit just closes the palette and
// focuses the Store tab; deep-linking to the detail // focuses the Store tab; deep-linking to the detail
// sheet would require thread-through plumbing we don't // sheet would require thread-through plumbing we don't
@ -127,7 +132,7 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
onSelect: () { onSelect: () {
Navigator.pop(context); Navigator.pop(context);
for (final h in widget.staticHits) { for (final h in widget.staticHits) {
if (h.label == 'Store' && h.group == 'Pages') { if (h.label == storeGroup && h.group == pagesGroup) {
h.onSelect(); h.onSelect();
return; return;
} }
@ -135,16 +140,17 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
}, },
)); ));
} }
final flowsGroup = l.searchGroupFlows;
for (final f in flows) { for (final f in flows) {
hits.add(FaiSearchHit( hits.add(FaiSearchHit(
label: f.name, label: f.name,
hint: 'saved flow', hint: l.searchSavedFlowHint,
icon: Icons.account_tree_outlined, icon: Icons.account_tree_outlined,
group: 'Flows', group: flowsGroup,
onSelect: () { onSelect: () {
Navigator.pop(context); Navigator.pop(context);
for (final h in widget.staticHits) { for (final h in widget.staticHits) {
if (h.label == 'Flows' && h.group == 'Pages') { if (h.label == flowsGroup && h.group == pagesGroup) {
h.onSelect(); h.onSelect();
return; return;
} }
@ -201,6 +207,7 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Dialog( return Dialog(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
insetPadding: const EdgeInsets.only(top: 80, left: 24, right: 24), insetPadding: const EdgeInsets.only(top: 80, left: 24, right: 24),
@ -235,8 +242,7 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: prefixIcon:
const Icon(Icons.search, size: 20), const Icon(Icons.search, size: 20),
hintText: hintText: l.searchHint,
'Jump anywhere — modules, store, flows, pages…',
border: InputBorder.none, border: InputBorder.none,
suffixIcon: snap.connectionState == suffixIcon: snap.connectionState ==
ConnectionState.waiting ConnectionState.waiting
@ -264,8 +270,8 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Text( child: Text(
snap.connectionState == ConnectionState.waiting snap.connectionState == ConnectionState.waiting
? 'Indexing…' ? l.searchIndexing
: 'No matches.', : l.searchNoMatches,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -380,14 +386,14 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
), ),
child: Row( child: Row(
children: [ children: [
_Hint(text: '↑↓ navigate'), _Hint(text: l.searchHintNavigate),
const SizedBox(width: 12), const SizedBox(width: 12),
_Hint(text: 'enter open'), _Hint(text: l.searchHintOpen),
const SizedBox(width: 12), const SizedBox(width: 12),
_Hint(text: 'esc close'), _Hint(text: l.searchHintClose),
const Spacer(), const Spacer(),
Text( Text(
'${hits.length} result${hits.length == 1 ? "" : "s"}', l.storeNResults(hits.length),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),

View file

@ -1,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.26.0 version: 0.27.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta