diff --git a/CHANGELOG.md b/CHANGELOG.md index 617635b..38c130b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,20 +3,6 @@ All notable changes to `fai_studio` recorded here. Pubspec version + `kStudioVersion` in `lib/main.dart` stay in lockstep. -## 0.70.0 — 2026-06-13 - -### Added - -- **Federation panel.** A new "Föderation" destination shows the - satellites connected to this hub (name, region, version, wire - version, advertised capabilities) and adds them in one step: press - **Add satellite**, name it, and the hub issues a single-use - bootstrap token bundled with its CA as a ready-to-paste satellite - config — the bundled CA makes the satellite's first connect - tamper-proof. Localized (EN + DE) with an in-app help doc. Backed - by new `HubClient.listSatellites` / `issueBootstrapToken` in the - Dart SDK. - ## 0.68.0 — 2026-06-09 ### Added diff --git a/assets/docs/federation.md b/assets/docs/federation.md deleted file mode 100644 index 475cdf6..0000000 --- a/assets/docs/federation.md +++ /dev/null @@ -1,43 +0,0 @@ -# Federation - -Federation lets one hub (the **primary**) run capabilities on -another hub (a **satellite**) — for example to reach a GPU box or to -keep data inside a specific location. A flow step opts in with an -`on:` selector; everything else stays exactly like a local step. - -This panel is the **primary** side: it shows the satellites currently -connected and lets you enrol new ones. - -## Add a satellite - -1. Press **Add satellite** and give it a name (e.g. `satellite-a`). -2. The hub issues a single-use **bootstrap token** and bundles its - **CA certificate**. Copy the shown config into the satellite's - `config.yaml` (one paste) and export the token as - `FAI_BOOTSTRAP_TOKEN`. -3. Start the satellite. It dials the primary, the CA pin makes the - first connect tamper-proof, and the primary signs it a short-lived - certificate. It then appears in this list. - -## How it stays secure - -- The satellite **dials outbound** — no inbound firewall change. -- Every connection is **mTLS**. The first connect is verified against - the bundled CA; later connects authenticate with the satellite's - own certificate (no token needed). -- The satellite resolves and runs the capability in **its own - sandbox** — only inputs cross the wire, never code. -- Each cross-hub call is recorded on both sides (`dispatched_to`), so - the audit trail and replay determinism survive federation. - -## Using a satellite in a flow - -Add an `on:` selector to a step: - -```yaml -- id: extract - use: text.extract@^1 - on: region=berlin # or pin=satellite-a, auto:healthiest, auto:lowest_rtt - with: - input: $inputs.doc -``` diff --git a/assets/docs/federation_de.md b/assets/docs/federation_de.md deleted file mode 100644 index b3f9628..0000000 --- a/assets/docs/federation_de.md +++ /dev/null @@ -1,48 +0,0 @@ -# Föderation - -Föderation lässt einen Hub (den **Primary**) Capabilities auf einem -anderen Hub (einem **Satelliten**) ausführen — etwa um eine GPU-Maschine -zu erreichen oder Daten an einem bestimmten Ort zu halten. Ein -Flow-Schritt entscheidet sich per `on:`-Selektor dafür; alles andere -bleibt wie bei einem lokalen Schritt. - -Dieses Panel ist die **Primary**-Seite: Es zeigt die aktuell -verbundenen Satelliten und richtet neue ein. - -## Satellit hinzufügen - -1. **Satellit hinzufügen** drücken und einen Namen vergeben - (z.B. `satellite-a`). -2. Der Hub stellt ein einmaliges **Bootstrap-Token** aus und legt sein - **CA-Zertifikat** bei. Kopiere die gezeigte Konfiguration in die - `config.yaml` des Satelliten (ein Einfügen) und exportiere das - Token als `FAI_BOOTSTRAP_TOKEN`. -3. Starte den Satelliten. Er wählt sich beim Primary ein, der CA-Pin - macht den ersten Connect manipulationssicher, und der Primary - signiert ihm ein kurzlebiges Zertifikat. Danach erscheint er in - dieser Liste. - -## Warum es sicher bleibt - -- Der Satellit **wählt sich nach außen ein** — keine eingehende - Firewall-Änderung nötig. -- Jede Verbindung ist **mTLS**. Der erste Connect wird gegen die - beigelegte CA verifiziert; spätere Connects authentifizieren sich - mit dem eigenen Zertifikat des Satelliten (kein Token nötig). -- Der Satellit löst auf und führt die Capability in **seiner eigenen - Sandbox** aus — nur Eingaben gehen über die Leitung, niemals Code. -- Jeder hub-übergreifende Aufruf wird auf beiden Seiten protokolliert - (`dispatched_to`), sodass Audit-Trail und Replay-Determinismus die - Föderation überstehen. - -## Satellit in einem Flow nutzen - -Ergänze einen `on:`-Selektor an einem Schritt: - -```yaml -- id: extract - use: text.extract@^1 - on: region=berlin # oder pin=satellite-a, auto:healthiest, auto:lowest_rtt - with: - input: $inputs.doc -``` diff --git a/assets/fonts/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf deleted file mode 100644 index 9fb9b75..0000000 Binary files a/assets/fonts/Inter-Bold.ttf and /dev/null differ diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf deleted file mode 100644 index 458cd06..0000000 Binary files a/assets/fonts/Inter-Medium.ttf and /dev/null differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf deleted file mode 100644 index b7aaca8..0000000 Binary files a/assets/fonts/Inter-Regular.ttf and /dev/null differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf deleted file mode 100644 index 47f8ab1..0000000 Binary files a/assets/fonts/Inter-SemiBold.ttf and /dev/null differ diff --git a/assets/fonts/JetBrainsMono-Medium.ttf b/assets/fonts/JetBrainsMono-Medium.ttf deleted file mode 100644 index 9767115..0000000 Binary files a/assets/fonts/JetBrainsMono-Medium.ttf and /dev/null differ diff --git a/assets/fonts/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono-Regular.ttf deleted file mode 100644 index dff66cc..0000000 Binary files a/assets/fonts/JetBrainsMono-Regular.ttf and /dev/null differ diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 0de2e2d..1431e66 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -11,7 +11,6 @@ import 'package:fai_client_sdk/fai_client_sdk.dart'; import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../l10n/app_localizations.dart'; import 'flow_output.dart'; import 'hub_auth_token.dart'; export 'flow_output.dart'; @@ -859,39 +858,6 @@ class HubService { Future reject(String id, String reviewer, String reason) => _client.reject(approvalId: id, reviewer: reviewer, reason: reason); - /// Federation satellites currently connected to this hub - /// (primary side). Empty when none are connected. - Future> listSatellites() async { - final entries = await _client.listSatellites(); - return entries - .map( - (e) => Satellite( - hubId: e.hubId, - displayName: e.displayName, - hubVersion: e.hubVersion, - wireVersion: e.wireVersion, - region: e.region, - capabilities: e.capabilities.toList(), - ), - ) - .toList(); - } - - /// Issue a single-use federation bootstrap token enrolling - /// `name`. The result carries the primary CA so the operator can - /// bootstrap the satellite in one step (secure first connect). - Future issueSatelliteToken( - String name, { - String region = '', - }) async { - final r = await _client.issueBootstrapToken(name, region: region); - return SatelliteEnrollment( - token: r.token, - expiresAt: r.expiresAt, - primaryCaPem: r.primaryCaPem, - ); - } - /// Composite "doctor" snapshot. One round-trip per piece, run /// in parallel so the page populates fast. Future doctor() async { @@ -1241,51 +1207,6 @@ class PendingApproval { /// Full approval record including decided-side fields. Used by /// the History tab so operators can review what they (or the -/// A federation satellite currently connected to this hub. -class Satellite { - final String hubId; - final String displayName; - final String hubVersion; - final int wireVersion; - final String region; - final List capabilities; - - const Satellite({ - required this.hubId, - required this.displayName, - required this.hubVersion, - required this.wireVersion, - required this.region, - required this.capabilities, - }); -} - -/// The artifact a satellite operator needs to enrol: the single-use -/// bootstrap token plus the primary's CA cert (for a secure first -/// connect). Both are pasted into the satellite's config. -class SatelliteEnrollment { - final String token; - final String expiresAt; - final String primaryCaPem; - - const SatelliteEnrollment({ - required this.token, - required this.expiresAt, - required this.primaryCaPem, - }); - - /// A ready-to-paste `federation:` config block for the satellite. - String toConfigYaml(String name) { - final ca = primaryCaPem.trim().isEmpty - ? '' - : '\n primary_ca: |\n${primaryCaPem.trimRight().split('\n').map((l) => ' $l').join('\n')}'; - return 'federation:\n' - ' upstream: https://:\n' - ' hub_name: $name\n' - ' bootstrap_token_env: FAI_BOOTSTRAP_TOKEN$ca'; - } -} - /// system) decided. class ApprovalRecord { final String id; @@ -1385,25 +1306,24 @@ class AskAiResult { /// One-line fix hint per error kind, mirroring /// `docs/architecture/system-ai.md`. Empty on success. - /// Localized — pass the active [AppLocalizations]. - String fixHint(AppLocalizations l) { + String get fixHint { switch (errorKind) { case '': return ''; case 'disabled': - return l.sysAiFixDisabled; + return 'Configure System AI in Settings (Cmd+,) → System AI panel.'; case 'env_missing': - return l.sysAiFixEnvMissing; + return 'The API-key env var is empty. `export =` in your shell, then `fai daemon restart`.'; case 'network': - return l.sysAiFixNetwork; + return 'Hub cannot reach the configured endpoint. Is your provider running?'; case 'http': - return l.sysAiFixHttp; + return 'Provider returned a non-2xx status. Verify endpoint, model name, and API key.'; case 'parse': - return l.sysAiFixParse; + return 'Provider returned an unexpected response shape. Non-OpenAI providers may need a compatibility proxy.'; case 'empty_response': - return l.sysAiFixEmptyResponse; + return 'Provider answered with no content. Try a different model or rephrase the prompt.'; default: - return l.sysAiFixUnknown(errorKind); + return 'Unknown failure kind ($errorKind). See system-ai.md.'; } } } diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index 2884edd..fc48ce6 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -18,61 +18,9 @@ import 'dart:io'; -import 'package:shared_preferences/shared_preferences.dart'; - -/// Sentinel returned in `_runFai(...).stderr` when no `fai` -/// binary could be located. UI layers should not display this -/// verbatim — they check [SystemActions.faiBinaryExists] first -/// and render a localized, actionable message instead. -const String kFaiBinaryNotFound = 'fai-binary-not-found'; - -/// Getting-started / install guide for the F∆I platform. Opened -/// in the OS browser as the fallback affordance when no `fai` -/// binary can be located on the machine. -const String kFaiInstallDocsUrl = - 'https://git.flemming.ai/fai/platform#installation'; - class SystemActions { SystemActions._(); - /// SharedPreferences key for an operator-chosen `fai` binary - /// path (set via the file picker when auto-detection fails). - static const String _kFaiBinaryPrefKey = 'system.fai_binary_path'; - - /// In-memory cache of the operator override, loaded once at - /// startup by [loadFaiBinaryOverride]. Null = no override. - static String? _faiBinaryOverride; - - /// Restore the persisted `fai` binary override (if any) so it - /// is available before the first daemon action. Call once at - /// app start, alongside the other persisted-state loaders. - static Future loadFaiBinaryOverride() async { - final prefs = await SharedPreferences.getInstance(); - final p = prefs.getString(_kFaiBinaryPrefKey); - if (p != null && p.isNotEmpty && File(p).existsSync()) { - _faiBinaryOverride = p; - } - } - - /// Persist an operator-chosen `fai` binary path. Used by the - /// "Locate `fai` binary…" affordance when auto-detection on - /// PATH and the canonical install dir both come up empty. - /// Returns false (and persists nothing) when [path] is empty - /// or does not point at an existing file. - static Future setFaiBinaryPath(String path) async { - if (path.isEmpty || !File(path).existsSync()) return false; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kFaiBinaryPrefKey, path); - _faiBinaryOverride = path; - return true; - } - - /// True when a `fai` binary can be located (override, PATH, or - /// the canonical install dir). UI uses this to decide between - /// the normal daemon-control affordances and the "locate the - /// binary / read the install guide" recovery path. - static bool faiBinaryExists() => _faiExecutable() != null; - /// Ask the OS to open [path] in the default handler. On macOS /// this opens text files in TextEdit, configs in the registered /// editor, etc. Returns true on a clean spawn (process exited @@ -201,11 +149,13 @@ class SystemActions { ) async { final exe = _faiExecutable(); if (exe == null) { - // Sentinel, not a user-facing string. Callers detect this - // via `faiBinaryExists()` and render a localized, - // actionable recovery message (locate binary / install - // guide) instead of CLI jargon like "set FAI_BIN". - return (ok: false, stdout: '', stderr: kFaiBinaryNotFound); + return ( + ok: false, + stdout: '', + stderr: + 'Could not locate the `fai` binary. Install via the ' + 'platform installer or set FAI_BIN to its full path.', + ); } try { final r = await Process.run(exe, args); @@ -225,17 +175,9 @@ class SystemActions { return (executable: 'xdg-open', args: const []); } - /// Locate the `fai` binary. Order: operator override (set via - /// the file picker), $FAI_BIN, PATH, fallback to the canonical - /// install location under the user's home dir. + /// Locate the `fai` binary. Order: $FAI_BIN, PATH, fallback to + /// the canonical install location under the user's home dir. static String? _faiExecutable() { - final override = _faiBinaryOverride; - if (override != null && - override.isNotEmpty && - File(override).existsSync()) { - return override; - } - final fromEnv = Platform.environment['FAI_BIN']; if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) { return fromEnv; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5b7dda8..5708e53 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1520,97 +1520,5 @@ "placeholders": { "error": {"type": "String"} } - }, - "welcomeHubDownTitle": "Der Hub läuft noch nicht", - "welcomeHubDownBody": "Studio spricht mit einem lokalen Hub — der Engine, die Module lädt und Flows ausführt. Sie ist gerade nicht erreichbar. Starte sie, um loszulegen.", - "welcomeHubDownStart": "Hub starten", - "welcomeHubDownStarting": "Wird gestartet …", - "welcomeHubDownDocsLink": "Klappt der Start nicht? Zur Erste-Schritte-Anleitung", - "welcomeHubDownEndpoint": "Endpunkt: {endpoint}", - "@welcomeHubDownEndpoint": { - "placeholders": { - "endpoint": {"type": "String"} - } - }, - "daemonStartRequested": "Start des Daemons angefordert. Verbinde neu …", - "daemonStartFailed": "Daemon konnte nicht gestartet werden: {detail}", - "@daemonStartFailed": { - "placeholders": { - "detail": {"type": "String"} - } - }, - "faiBinaryNotFound": "Das Programm `fai` wurde auf diesem Rechner nicht gefunden.", - "faiBinaryNotFoundHint": "Installiere die F∆I-Plattform oder verweise Studio auf eine vorhandene `fai`-Datei.", - "faiBinaryLocateButton": "`fai`-Programm suchen …", - "faiBinaryInstallDocsButton": "Installationsanleitung öffnen", - "faiBinaryPickerTitle": "`fai`-Programm auswählen", - "faiBinarySetOk": "Verwende `fai` unter {path}", - "@faiBinarySetOk": { - "placeholders": { - "path": {"type": "String"} - } - }, - "sysAiFixParse": "Der Anbieter hat in einem unerwarteten Format geantwortet. Anbieter, die nicht OpenAI-kompatibel sind, brauchen ggf. einen Übersetzungs-Proxy.", - "sysAiFixUnknown": "Unerwarteter Fehler ({kind}). Siehe System-KI-Dokumentation.", - "@sysAiFixUnknown": { - "placeholders": { - "kind": {"type": "String"} - } - }, - "sysAiFixDisabled": "Richte die System-KI in den Einstellungen (Cmd+,) → Bereich System-KI ein.", - "sysAiFixEnvMissing": "Die Umgebungsvariable für den API-Schlüssel ist leer. Setze sie in deiner Shell und starte den Daemon neu.", - "sysAiFixNetwork": "Der Hub erreicht den konfigurierten Endpunkt nicht. Läuft dein Anbieter?", - "sysAiFixHttp": "Der Anbieter hat einen Status außerhalb 2xx geliefert. Prüfe Endpunkt, Modellname und API-Schlüssel.", - "sysAiFixEmptyResponse": "Der Anbieter hat keinen Inhalt geliefert. Probiere ein anderes Modell oder formuliere den Prompt um.", - "channelSwitchOk": "Aktiver Kanal auf \"{name}\" umgeschaltet. Daemon neu gestartet.", - "@channelSwitchOk": { - "placeholders": { - "name": {"type": "String"} - } - }, - "channelSwitchFailed": "Kanalwechsel fehlgeschlagen: {detail}", - "@channelSwitchFailed": { - "placeholders": { - "detail": {"type": "String"} - } - }, - "providerDescOllama": "Lokales `ollama serve`. Die Modelle bleiben auf deinem Rechner. Kein API-Schlüssel nötig.", - "providerDescOpenai": "Gehostete OpenAI-API. Erfordert einen API-Schlüssel. Daten verlassen deinen Rechner.", - "providerDescLmstudio": "Lokaler LM-Studio-Server. Die Modelle bleiben auf deinem Rechner. Kein API-Schlüssel nötig.", - "providerDescVllm": "Selbst gehostetes vLLM oder ein anderer OpenAI-kompatibler Server. Endpunkt erforderlich.", - "providerDescCustom": "Alles andere, das die OpenAI-kompatible Chat-Completions-Schnittstelle spricht (LiteLLM-Proxy, interner Spiegel, …).", - "welcomeDocLoadFailedBody": "Die mitgelieferte Doku \"{slug}\" ({locale}) konnte nicht geladen werden.\nVersucht: {attempted}\nUrsache: {underlying}\nMeist ist der installierte Studio-Build älter als das Doku-Bundle. Baue Studio neu oder öffne die Online-Kopie.", - "@welcomeDocLoadFailedBody": { - "placeholders": { - "slug": {"type": "String"}, - "locale": {"type": "String"}, - "attempted": {"type": "String"}, - "underlying": {"type": "String"} - } - }, - "hubUnreachableBanner": "{endpoint} nicht erreichbar — Einstellungen öffnen", - "@hubUnreachableBanner": { - "placeholders": { - "endpoint": {"type": "String"} - } - }, - "hubUnreachableOpenSettings": "Einstellungen öffnen", - "navFederation": "Föderation", - "federationTitle": "Föderation", - "federationReloadTooltip": "Satelliten neu laden", - "federationAddSatellite": "Satellit hinzufügen", - "federationEmptyTitle": "Keine Satelliten verbunden", - "federationEmptyHint": "Verbinde einen weiteren Hub als Satellit, um Capabilities dort auszuführen. Stelle dazu ein Enrollment-Token aus.", - "federationPillConnected": "verbunden", - "federationRegionNone": "keine Region", - "federationCapabilities": "{count} angebotene Capabilities", - "federationAddDialogTitle": "Enrollment-Token ausstellen", - "federationNameLabel": "Satelliten-Name", - "federationIssueButton": "Ausstellen", - "federationIssueFailed": "Token konnte nicht ausgestellt werden: {error}", - "federationEnrollmentTitle": "{name} einrichten", - "federationTokenLabel": "Bootstrap-Token (einmalig)", - "federationConfigLabel": "Satelliten-Konfiguration (in den Satelliten einfügen)", - "federationCopied": "In die Zwischenablage kopiert", - "federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA macht den ersten Connect des Satelliten manipulationssicher." + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0be3e46..2e8c83e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1523,112 +1523,5 @@ "placeholders": { "error": {"type": "String"} } - }, - "welcomeHubDownTitle": "The hub isn't running yet", - "welcomeHubDownBody": "Studio talks to a local hub — the engine that loads modules and runs flows. It isn't reachable right now. Start it to begin.", - "welcomeHubDownStart": "Start hub", - "welcomeHubDownStarting": "Starting…", - "welcomeHubDownDocsLink": "Can't start it? Read the getting-started guide", - "welcomeHubDownEndpoint": "Endpoint: {endpoint}", - "@welcomeHubDownEndpoint": { - "placeholders": { - "endpoint": {"type": "String"} - } - }, - "daemonStartRequested": "Daemon start requested. Reconnecting…", - "daemonStartFailed": "Could not start daemon: {detail}", - "@daemonStartFailed": { - "placeholders": { - "detail": {"type": "String"} - } - }, - "faiBinaryNotFound": "Could not find the `fai` program on this machine.", - "faiBinaryNotFoundHint": "Install the F∆I platform, or point Studio at an existing `fai` binary.", - "faiBinaryLocateButton": "Locate `fai` binary…", - "faiBinaryInstallDocsButton": "Open install guide", - "faiBinaryPickerTitle": "Select the `fai` binary", - "faiBinarySetOk": "Using `fai` binary at {path}", - "@faiBinarySetOk": { - "placeholders": { - "path": {"type": "String"} - } - }, - "sysAiFixParse": "The provider sent a response in an unexpected format. Providers that aren't OpenAI-compatible may need a translation proxy.", - "sysAiFixUnknown": "Unexpected failure ({kind}). See the System AI documentation.", - "@sysAiFixUnknown": { - "placeholders": { - "kind": {"type": "String"} - } - }, - "sysAiFixDisabled": "Configure System AI in Settings (Cmd+,) → System AI panel.", - "sysAiFixEnvMissing": "The API-key environment variable is empty. Set it in your shell, then restart the daemon.", - "sysAiFixNetwork": "The hub can't reach the configured endpoint. Is your provider running?", - "sysAiFixHttp": "The provider returned a non-2xx status. Verify the endpoint, model name, and API key.", - "sysAiFixEmptyResponse": "The provider answered with no content. Try a different model or rephrase the prompt.", - "channelSwitchOk": "Switched active channel to \"{name}\". Daemon restarted.", - "@channelSwitchOk": { - "placeholders": { - "name": {"type": "String"} - } - }, - "channelSwitchFailed": "Channel switch failed: {detail}", - "@channelSwitchFailed": { - "placeholders": { - "detail": {"type": "String"} - } - }, - "providerDescOllama": "Local `ollama serve`. Models stay on your machine. No API key needed.", - "providerDescOpenai": "OpenAI hosted API. Requires an API key. Data leaves your machine.", - "providerDescLmstudio": "Local LM Studio server. Models stay on your machine. No API key needed.", - "providerDescVllm": "Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.", - "providerDescCustom": "Anything else that speaks the OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).", - "welcomeDocLoadFailedBody": "Could not load the bundled doc \"{slug}\" ({locale}).\nTried: {attempted}\nUnderlying: {underlying}\nThis usually means the Studio build on disk predates the doc bundle. Rebuild Studio or open the online copy.", - "@welcomeDocLoadFailedBody": { - "placeholders": { - "slug": {"type": "String"}, - "locale": {"type": "String"}, - "attempted": {"type": "String"}, - "underlying": {"type": "String"} - } - }, - "hubUnreachableBanner": "Can't reach {endpoint} — open Settings", - "@hubUnreachableBanner": { - "placeholders": { - "endpoint": {"type": "String"} - } - }, - "hubUnreachableOpenSettings": "Open Settings", - "navFederation": "Federation", - "federationTitle": "Federation", - "federationReloadTooltip": "Reload satellites", - "federationAddSatellite": "Add satellite", - "federationEmptyTitle": "No satellites connected", - "federationEmptyHint": "Connect another hub as a satellite to run capabilities on it. Issue an enrollment token to get started.", - "federationPillConnected": "connected", - "federationRegionNone": "no region", - "federationCapabilities": "{count} advertised capabilities", - "@federationCapabilities": { - "placeholders": { - "count": {"type": "int"} - } - }, - "federationAddDialogTitle": "Issue an enrollment token", - "federationNameLabel": "Satellite name", - "federationIssueButton": "Issue", - "federationIssueFailed": "Could not issue token: {error}", - "@federationIssueFailed": { - "placeholders": { - "error": {"type": "String"} - } - }, - "federationEnrollmentTitle": "Enroll {name}", - "@federationEnrollmentTitle": { - "placeholders": { - "name": {"type": "String"} - } - }, - "federationTokenLabel": "Bootstrap token (single use)", - "federationConfigLabel": "Satellite config (paste into the satellite)", - "federationCopied": "Copied to clipboard", - "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite's first connect tamper-proof." + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 748b286..bd6684b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4279,305 +4279,6 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Install failed: {error}'** String installFailed(String error); - - /// No description provided for @welcomeHubDownTitle. - /// - /// In en, this message translates to: - /// **'The hub isn\'t running yet'** - String get welcomeHubDownTitle; - - /// No description provided for @welcomeHubDownBody. - /// - /// In en, this message translates to: - /// **'Studio talks to a local hub — the engine that loads modules and runs flows. It isn\'t reachable right now. Start it to begin.'** - String get welcomeHubDownBody; - - /// No description provided for @welcomeHubDownStart. - /// - /// In en, this message translates to: - /// **'Start hub'** - String get welcomeHubDownStart; - - /// No description provided for @welcomeHubDownStarting. - /// - /// In en, this message translates to: - /// **'Starting…'** - String get welcomeHubDownStarting; - - /// No description provided for @welcomeHubDownDocsLink. - /// - /// In en, this message translates to: - /// **'Can\'t start it? Read the getting-started guide'** - String get welcomeHubDownDocsLink; - - /// No description provided for @welcomeHubDownEndpoint. - /// - /// In en, this message translates to: - /// **'Endpoint: {endpoint}'** - String welcomeHubDownEndpoint(String endpoint); - - /// No description provided for @daemonStartRequested. - /// - /// In en, this message translates to: - /// **'Daemon start requested. Reconnecting…'** - String get daemonStartRequested; - - /// No description provided for @daemonStartFailed. - /// - /// In en, this message translates to: - /// **'Could not start daemon: {detail}'** - String daemonStartFailed(String detail); - - /// No description provided for @faiBinaryNotFound. - /// - /// In en, this message translates to: - /// **'Could not find the `fai` program on this machine.'** - String get faiBinaryNotFound; - - /// No description provided for @faiBinaryNotFoundHint. - /// - /// In en, this message translates to: - /// **'Install the F∆I platform, or point Studio at an existing `fai` binary.'** - String get faiBinaryNotFoundHint; - - /// No description provided for @faiBinaryLocateButton. - /// - /// In en, this message translates to: - /// **'Locate `fai` binary…'** - String get faiBinaryLocateButton; - - /// No description provided for @faiBinaryInstallDocsButton. - /// - /// In en, this message translates to: - /// **'Open install guide'** - String get faiBinaryInstallDocsButton; - - /// No description provided for @faiBinaryPickerTitle. - /// - /// In en, this message translates to: - /// **'Select the `fai` binary'** - String get faiBinaryPickerTitle; - - /// No description provided for @faiBinarySetOk. - /// - /// In en, this message translates to: - /// **'Using `fai` binary at {path}'** - String faiBinarySetOk(String path); - - /// No description provided for @sysAiFixParse. - /// - /// In en, this message translates to: - /// **'The provider sent a response in an unexpected format. Providers that aren\'t OpenAI-compatible may need a translation proxy.'** - String get sysAiFixParse; - - /// No description provided for @sysAiFixUnknown. - /// - /// In en, this message translates to: - /// **'Unexpected failure ({kind}). See the System AI documentation.'** - String sysAiFixUnknown(String kind); - - /// No description provided for @sysAiFixDisabled. - /// - /// In en, this message translates to: - /// **'Configure System AI in Settings (Cmd+,) → System AI panel.'** - String get sysAiFixDisabled; - - /// No description provided for @sysAiFixEnvMissing. - /// - /// In en, this message translates to: - /// **'The API-key environment variable is empty. Set it in your shell, then restart the daemon.'** - String get sysAiFixEnvMissing; - - /// No description provided for @sysAiFixNetwork. - /// - /// In en, this message translates to: - /// **'The hub can\'t reach the configured endpoint. Is your provider running?'** - String get sysAiFixNetwork; - - /// No description provided for @sysAiFixHttp. - /// - /// In en, this message translates to: - /// **'The provider returned a non-2xx status. Verify the endpoint, model name, and API key.'** - String get sysAiFixHttp; - - /// No description provided for @sysAiFixEmptyResponse. - /// - /// In en, this message translates to: - /// **'The provider answered with no content. Try a different model or rephrase the prompt.'** - String get sysAiFixEmptyResponse; - - /// No description provided for @channelSwitchOk. - /// - /// In en, this message translates to: - /// **'Switched active channel to \"{name}\". Daemon restarted.'** - String channelSwitchOk(String name); - - /// No description provided for @channelSwitchFailed. - /// - /// In en, this message translates to: - /// **'Channel switch failed: {detail}'** - String channelSwitchFailed(String detail); - - /// No description provided for @providerDescOllama. - /// - /// In en, this message translates to: - /// **'Local `ollama serve`. Models stay on your machine. No API key needed.'** - String get providerDescOllama; - - /// No description provided for @providerDescOpenai. - /// - /// In en, this message translates to: - /// **'OpenAI hosted API. Requires an API key. Data leaves your machine.'** - String get providerDescOpenai; - - /// No description provided for @providerDescLmstudio. - /// - /// In en, this message translates to: - /// **'Local LM Studio server. Models stay on your machine. No API key needed.'** - String get providerDescLmstudio; - - /// No description provided for @providerDescVllm. - /// - /// In en, this message translates to: - /// **'Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.'** - String get providerDescVllm; - - /// No description provided for @providerDescCustom. - /// - /// In en, this message translates to: - /// **'Anything else that speaks the OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).'** - String get providerDescCustom; - - /// No description provided for @welcomeDocLoadFailedBody. - /// - /// In en, this message translates to: - /// **'Could not load the bundled doc \"{slug}\" ({locale}).\nTried: {attempted}\nUnderlying: {underlying}\nThis usually means the Studio build on disk predates the doc bundle. Rebuild Studio or open the online copy.'** - String welcomeDocLoadFailedBody( - String slug, - String locale, - String attempted, - String underlying, - ); - - /// No description provided for @hubUnreachableBanner. - /// - /// In en, this message translates to: - /// **'Can\'t reach {endpoint} — open Settings'** - String hubUnreachableBanner(String endpoint); - - /// No description provided for @hubUnreachableOpenSettings. - /// - /// In en, this message translates to: - /// **'Open Settings'** - String get hubUnreachableOpenSettings; - - /// No description provided for @navFederation. - /// - /// In en, this message translates to: - /// **'Federation'** - String get navFederation; - - /// No description provided for @federationTitle. - /// - /// In en, this message translates to: - /// **'Federation'** - String get federationTitle; - - /// No description provided for @federationReloadTooltip. - /// - /// In en, this message translates to: - /// **'Reload satellites'** - String get federationReloadTooltip; - - /// No description provided for @federationAddSatellite. - /// - /// In en, this message translates to: - /// **'Add satellite'** - String get federationAddSatellite; - - /// No description provided for @federationEmptyTitle. - /// - /// In en, this message translates to: - /// **'No satellites connected'** - String get federationEmptyTitle; - - /// No description provided for @federationEmptyHint. - /// - /// In en, this message translates to: - /// **'Connect another hub as a satellite to run capabilities on it. Issue an enrollment token to get started.'** - String get federationEmptyHint; - - /// No description provided for @federationPillConnected. - /// - /// In en, this message translates to: - /// **'connected'** - String get federationPillConnected; - - /// No description provided for @federationRegionNone. - /// - /// In en, this message translates to: - /// **'no region'** - String get federationRegionNone; - - /// No description provided for @federationCapabilities. - /// - /// In en, this message translates to: - /// **'{count} advertised capabilities'** - String federationCapabilities(int count); - - /// No description provided for @federationAddDialogTitle. - /// - /// In en, this message translates to: - /// **'Issue an enrollment token'** - String get federationAddDialogTitle; - - /// No description provided for @federationNameLabel. - /// - /// In en, this message translates to: - /// **'Satellite name'** - String get federationNameLabel; - - /// No description provided for @federationIssueButton. - /// - /// In en, this message translates to: - /// **'Issue'** - String get federationIssueButton; - - /// No description provided for @federationIssueFailed. - /// - /// In en, this message translates to: - /// **'Could not issue token: {error}'** - String federationIssueFailed(String error); - - /// No description provided for @federationEnrollmentTitle. - /// - /// In en, this message translates to: - /// **'Enroll {name}'** - String federationEnrollmentTitle(String name); - - /// No description provided for @federationTokenLabel. - /// - /// In en, this message translates to: - /// **'Bootstrap token (single use)'** - String get federationTokenLabel; - - /// No description provided for @federationConfigLabel. - /// - /// In en, this message translates to: - /// **'Satellite config (paste into the satellite)'** - String get federationConfigLabel; - - /// No description provided for @federationCopied. - /// - /// In en, this message translates to: - /// **'Copied to clipboard'** - String get federationCopied; - - /// No description provided for @federationEnrollmentHint. - /// - /// In en, this message translates to: - /// **'Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite\'s first connect tamper-proof.'** - String get federationEnrollmentHint; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 7a4d453..6d55a0c 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2502,197 +2502,4 @@ class AppLocalizationsDe extends AppLocalizations { String installFailed(String error) { return 'Installation fehlgeschlagen: $error'; } - - @override - String get welcomeHubDownTitle => 'Der Hub läuft noch nicht'; - - @override - String get welcomeHubDownBody => - 'Studio spricht mit einem lokalen Hub — der Engine, die Module lädt und Flows ausführt. Sie ist gerade nicht erreichbar. Starte sie, um loszulegen.'; - - @override - String get welcomeHubDownStart => 'Hub starten'; - - @override - String get welcomeHubDownStarting => 'Wird gestartet …'; - - @override - String get welcomeHubDownDocsLink => - 'Klappt der Start nicht? Zur Erste-Schritte-Anleitung'; - - @override - String welcomeHubDownEndpoint(String endpoint) { - return 'Endpunkt: $endpoint'; - } - - @override - String get daemonStartRequested => - 'Start des Daemons angefordert. Verbinde neu …'; - - @override - String daemonStartFailed(String detail) { - return 'Daemon konnte nicht gestartet werden: $detail'; - } - - @override - String get faiBinaryNotFound => - 'Das Programm `fai` wurde auf diesem Rechner nicht gefunden.'; - - @override - String get faiBinaryNotFoundHint => - 'Installiere die F∆I-Plattform oder verweise Studio auf eine vorhandene `fai`-Datei.'; - - @override - String get faiBinaryLocateButton => '`fai`-Programm suchen …'; - - @override - String get faiBinaryInstallDocsButton => 'Installationsanleitung öffnen'; - - @override - String get faiBinaryPickerTitle => '`fai`-Programm auswählen'; - - @override - String faiBinarySetOk(String path) { - return 'Verwende `fai` unter $path'; - } - - @override - String get sysAiFixParse => - 'Der Anbieter hat in einem unerwarteten Format geantwortet. Anbieter, die nicht OpenAI-kompatibel sind, brauchen ggf. einen Übersetzungs-Proxy.'; - - @override - String sysAiFixUnknown(String kind) { - return 'Unerwarteter Fehler ($kind). Siehe System-KI-Dokumentation.'; - } - - @override - String get sysAiFixDisabled => - 'Richte die System-KI in den Einstellungen (Cmd+,) → Bereich System-KI ein.'; - - @override - String get sysAiFixEnvMissing => - 'Die Umgebungsvariable für den API-Schlüssel ist leer. Setze sie in deiner Shell und starte den Daemon neu.'; - - @override - String get sysAiFixNetwork => - 'Der Hub erreicht den konfigurierten Endpunkt nicht. Läuft dein Anbieter?'; - - @override - String get sysAiFixHttp => - 'Der Anbieter hat einen Status außerhalb 2xx geliefert. Prüfe Endpunkt, Modellname und API-Schlüssel.'; - - @override - String get sysAiFixEmptyResponse => - 'Der Anbieter hat keinen Inhalt geliefert. Probiere ein anderes Modell oder formuliere den Prompt um.'; - - @override - String channelSwitchOk(String name) { - return 'Aktiver Kanal auf \"$name\" umgeschaltet. Daemon neu gestartet.'; - } - - @override - String channelSwitchFailed(String detail) { - return 'Kanalwechsel fehlgeschlagen: $detail'; - } - - @override - String get providerDescOllama => - 'Lokales `ollama serve`. Die Modelle bleiben auf deinem Rechner. Kein API-Schlüssel nötig.'; - - @override - String get providerDescOpenai => - 'Gehostete OpenAI-API. Erfordert einen API-Schlüssel. Daten verlassen deinen Rechner.'; - - @override - String get providerDescLmstudio => - 'Lokaler LM-Studio-Server. Die Modelle bleiben auf deinem Rechner. Kein API-Schlüssel nötig.'; - - @override - String get providerDescVllm => - 'Selbst gehostetes vLLM oder ein anderer OpenAI-kompatibler Server. Endpunkt erforderlich.'; - - @override - String get providerDescCustom => - 'Alles andere, das die OpenAI-kompatible Chat-Completions-Schnittstelle spricht (LiteLLM-Proxy, interner Spiegel, …).'; - - @override - String welcomeDocLoadFailedBody( - String slug, - String locale, - String attempted, - String underlying, - ) { - return 'Die mitgelieferte Doku \"$slug\" ($locale) konnte nicht geladen werden.\nVersucht: $attempted\nUrsache: $underlying\nMeist ist der installierte Studio-Build älter als das Doku-Bundle. Baue Studio neu oder öffne die Online-Kopie.'; - } - - @override - String hubUnreachableBanner(String endpoint) { - return '$endpoint nicht erreichbar — Einstellungen öffnen'; - } - - @override - String get hubUnreachableOpenSettings => 'Einstellungen öffnen'; - - @override - String get navFederation => 'Föderation'; - - @override - String get federationTitle => 'Föderation'; - - @override - String get federationReloadTooltip => 'Satelliten neu laden'; - - @override - String get federationAddSatellite => 'Satellit hinzufügen'; - - @override - String get federationEmptyTitle => 'Keine Satelliten verbunden'; - - @override - String get federationEmptyHint => - 'Verbinde einen weiteren Hub als Satellit, um Capabilities dort auszuführen. Stelle dazu ein Enrollment-Token aus.'; - - @override - String get federationPillConnected => 'verbunden'; - - @override - String get federationRegionNone => 'keine Region'; - - @override - String federationCapabilities(int count) { - return '$count angebotene Capabilities'; - } - - @override - String get federationAddDialogTitle => 'Enrollment-Token ausstellen'; - - @override - String get federationNameLabel => 'Satelliten-Name'; - - @override - String get federationIssueButton => 'Ausstellen'; - - @override - String federationIssueFailed(String error) { - return 'Token konnte nicht ausgestellt werden: $error'; - } - - @override - String federationEnrollmentTitle(String name) { - return '$name einrichten'; - } - - @override - String get federationTokenLabel => 'Bootstrap-Token (einmalig)'; - - @override - String get federationConfigLabel => - 'Satelliten-Konfiguration (in den Satelliten einfügen)'; - - @override - String get federationCopied => 'In die Zwischenablage kopiert'; - - @override - String get federationEnrollmentHint => - 'Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA macht den ersten Connect des Satelliten manipulationssicher.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f105d4b..9613671 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2506,196 +2506,4 @@ class AppLocalizationsEn extends AppLocalizations { String installFailed(String error) { return 'Install failed: $error'; } - - @override - String get welcomeHubDownTitle => 'The hub isn\'t running yet'; - - @override - String get welcomeHubDownBody => - 'Studio talks to a local hub — the engine that loads modules and runs flows. It isn\'t reachable right now. Start it to begin.'; - - @override - String get welcomeHubDownStart => 'Start hub'; - - @override - String get welcomeHubDownStarting => 'Starting…'; - - @override - String get welcomeHubDownDocsLink => - 'Can\'t start it? Read the getting-started guide'; - - @override - String welcomeHubDownEndpoint(String endpoint) { - return 'Endpoint: $endpoint'; - } - - @override - String get daemonStartRequested => 'Daemon start requested. Reconnecting…'; - - @override - String daemonStartFailed(String detail) { - return 'Could not start daemon: $detail'; - } - - @override - String get faiBinaryNotFound => - 'Could not find the `fai` program on this machine.'; - - @override - String get faiBinaryNotFoundHint => - 'Install the F∆I platform, or point Studio at an existing `fai` binary.'; - - @override - String get faiBinaryLocateButton => 'Locate `fai` binary…'; - - @override - String get faiBinaryInstallDocsButton => 'Open install guide'; - - @override - String get faiBinaryPickerTitle => 'Select the `fai` binary'; - - @override - String faiBinarySetOk(String path) { - return 'Using `fai` binary at $path'; - } - - @override - String get sysAiFixParse => - 'The provider sent a response in an unexpected format. Providers that aren\'t OpenAI-compatible may need a translation proxy.'; - - @override - String sysAiFixUnknown(String kind) { - return 'Unexpected failure ($kind). See the System AI documentation.'; - } - - @override - String get sysAiFixDisabled => - 'Configure System AI in Settings (Cmd+,) → System AI panel.'; - - @override - String get sysAiFixEnvMissing => - 'The API-key environment variable is empty. Set it in your shell, then restart the daemon.'; - - @override - String get sysAiFixNetwork => - 'The hub can\'t reach the configured endpoint. Is your provider running?'; - - @override - String get sysAiFixHttp => - 'The provider returned a non-2xx status. Verify the endpoint, model name, and API key.'; - - @override - String get sysAiFixEmptyResponse => - 'The provider answered with no content. Try a different model or rephrase the prompt.'; - - @override - String channelSwitchOk(String name) { - return 'Switched active channel to \"$name\". Daemon restarted.'; - } - - @override - String channelSwitchFailed(String detail) { - return 'Channel switch failed: $detail'; - } - - @override - String get providerDescOllama => - 'Local `ollama serve`. Models stay on your machine. No API key needed.'; - - @override - String get providerDescOpenai => - 'OpenAI hosted API. Requires an API key. Data leaves your machine.'; - - @override - String get providerDescLmstudio => - 'Local LM Studio server. Models stay on your machine. No API key needed.'; - - @override - String get providerDescVllm => - 'Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.'; - - @override - String get providerDescCustom => - 'Anything else that speaks the OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).'; - - @override - String welcomeDocLoadFailedBody( - String slug, - String locale, - String attempted, - String underlying, - ) { - return 'Could not load the bundled doc \"$slug\" ($locale).\nTried: $attempted\nUnderlying: $underlying\nThis usually means the Studio build on disk predates the doc bundle. Rebuild Studio or open the online copy.'; - } - - @override - String hubUnreachableBanner(String endpoint) { - return 'Can\'t reach $endpoint — open Settings'; - } - - @override - String get hubUnreachableOpenSettings => 'Open Settings'; - - @override - String get navFederation => 'Federation'; - - @override - String get federationTitle => 'Federation'; - - @override - String get federationReloadTooltip => 'Reload satellites'; - - @override - String get federationAddSatellite => 'Add satellite'; - - @override - String get federationEmptyTitle => 'No satellites connected'; - - @override - String get federationEmptyHint => - 'Connect another hub as a satellite to run capabilities on it. Issue an enrollment token to get started.'; - - @override - String get federationPillConnected => 'connected'; - - @override - String get federationRegionNone => 'no region'; - - @override - String federationCapabilities(int count) { - return '$count advertised capabilities'; - } - - @override - String get federationAddDialogTitle => 'Issue an enrollment token'; - - @override - String get federationNameLabel => 'Satellite name'; - - @override - String get federationIssueButton => 'Issue'; - - @override - String federationIssueFailed(String error) { - return 'Could not issue token: $error'; - } - - @override - String federationEnrollmentTitle(String name) { - return 'Enroll $name'; - } - - @override - String get federationTokenLabel => 'Bootstrap token (single use)'; - - @override - String get federationConfigLabel => - 'Satellite config (paste into the satellite)'; - - @override - String get federationCopied => 'Copied to clipboard'; - - @override - String get federationEnrollmentHint => - 'Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite\'s first connect tamper-proof.'; } diff --git a/lib/main.dart b/lib/main.dart index 0d10ff7..301e5be 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,7 +16,6 @@ import 'l10n/app_localizations.dart'; import 'pages/approvals.dart'; import 'pages/audit.dart'; import 'pages/doctor.dart'; -import 'pages/federation.dart'; import 'pages/flows.dart'; import 'pages/store.dart'; import 'pages/welcome.dart'; @@ -28,7 +27,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.70.0'; +const String kStudioVersion = '0.69.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -36,7 +35,6 @@ Future main() async { // (when installed) the operator's theme-plugin choice // before the first frame so nothing flickers on startup. await HubService.instance.loadPersistedEndpoint(); - await SystemActions.loadFaiBinaryOverride(); final themeMode = await HubService.instance.loadThemeMode(); final locale = await HubService.instance.loadLocale(); final themePlugin = await loadActiveThemePlugin(); @@ -247,42 +245,6 @@ class StudioShellState extends State { String? _activeChannel; // local / dev / beta / production Timer? _healthPoll; - /// Consecutive failed health polls. After [_unreachableThreshold] - /// in a row we stop showing an indefinite silent "connecting…" - /// and surface a one-line "can't reach the hub" banner with a - /// jump to Settings. - int _failedPolls = 0; - static const int _unreachableThreshold = 3; - - /// Whether to render the unreachable banner. Distinct from - /// `_connected == false` so a single transient miss doesn't - /// flash the banner — only sustained failure does. - bool get _hubUnreachable => _failedPolls >= _unreachableThreshold; - - /// Current connection state, for descendants (e.g. WelcomePage) - /// that adapt their content to hub availability. - bool? get connected => _connected; - - /// Start the local daemon via the platform binary, then surface - /// the outcome as a snackbar. Shared by the sidebar's connection - /// row and the WelcomePage hero CTA. - Future startDaemon() async { - final l = AppLocalizations.of(context)!; - final r = await SystemActions.faiDaemon(['start']); - if (!mounted) return; - final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail), - ), - ), - ); - // Probe again shortly so the UI flips to "connected" without - // waiting for the next 5 s tick. - Future.delayed(const Duration(seconds: 1), _checkHealth); - } - /// Switch the sidebar's selected page by its registered id /// (`store`, `flows`, `audit`, etc.). Used by the welcome /// page's onboarding checklist to make each item a real @@ -349,12 +311,6 @@ class StudioShellState extends State { selectedIcon: Icons.inbox, page: ApprovalsPage(), ), - _NavPage( - id: 'federation', - icon: Icons.hub_outlined, - selectedIcon: Icons.hub, - page: FederationPage(), - ), ]; /// Pending-approval count surfaced as a sidebar badge so the @@ -375,17 +331,7 @@ class StudioShellState extends State { Future _checkHealth() async { final ok = await HubService.instance.healthy(); if (!mounted) return; - final wasUnreachable = _hubUnreachable; - final nextFailed = ok ? 0 : _failedPolls + 1; - final connectionChanged = _connected != ok; - final bannerChanged = - wasUnreachable != (nextFailed >= _unreachableThreshold); - if (connectionChanged || _failedPolls != nextFailed || bannerChanged) { - setState(() { - _connected = ok; - _failedPolls = nextFailed; - }); - } + if (_connected != ok) setState(() => _connected = ok); if (ok) { try { final snap = await HubService.instance.channelStatus(); @@ -508,35 +454,24 @@ class StudioShellState extends State { ), Container(width: 1, color: theme.colorScheme.outlineVariant), Expanded( - child: Column( - children: [ - if (_hubUnreachable) - _HubUnreachableBanner( - endpoint: HubService.instance.endpointLabel, - onOpenSettings: () => FaiSettingsDialog.show(context), - ), - Expanded( - child: AnimatedSwitcher( - duration: FaiMotion.base, - switchInCurve: FaiMotion.easing, - switchOutCurve: FaiMotion.easing, - transitionBuilder: (child, anim) => FadeTransition( - opacity: anim, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.02), - end: Offset.zero, - ).animate(anim), - child: child, - ), - ), - child: KeyedSubtree( - key: ValueKey(_selectedIndex), - child: _pages[_selectedIndex].page, - ), - ), + child: AnimatedSwitcher( + duration: FaiMotion.base, + switchInCurve: FaiMotion.easing, + switchOutCurve: FaiMotion.easing, + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.02), + end: Offset.zero, + ).animate(anim), + child: child, ), - ], + ), + child: KeyedSubtree( + key: ValueKey(_selectedIndex), + child: _pages[_selectedIndex].page, + ), ), ), ], @@ -548,58 +483,6 @@ class StudioShellState extends State { } } -/// One-line banner shown when the hub has been unreachable for -/// several consecutive health polls. Replaces the indefinite -/// silent "connecting…" with an explicit message + a jump to -/// Settings, where the operator can fix the endpoint. -class _HubUnreachableBanner extends StatelessWidget { - final String endpoint; - final VoidCallback onOpenSettings; - - const _HubUnreachableBanner({ - required this.endpoint, - required this.onOpenSettings, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - return Material( - color: theme.colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: FaiSpace.lg, - vertical: FaiSpace.sm, - ), - child: Row( - children: [ - Icon( - Icons.cloud_off_outlined, - size: 18, - color: theme.colorScheme.onErrorContainer, - ), - const SizedBox(width: FaiSpace.sm), - Expanded( - child: Text( - l.hubUnreachableBanner(endpoint), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onErrorContainer, - ), - ), - ), - const SizedBox(width: FaiSpace.sm), - TextButton( - onPressed: onOpenSettings, - child: Text(l.hubUnreachableOpenSettings), - ), - ], - ), - ), - ); - } -} - /// Intents driven by the Shortcuts/Actions pair on the shell. class _GoToPageIntent extends Intent { final int index; @@ -670,12 +553,7 @@ class _SidebarState extends State<_Sidebar> super.initState(); _ctrl = AnimationController( vsync: this, - // Expand uses the slow (~320 ms) token: the icon→label - // reveal is a deliberate, eased gesture, not a flicker. - // The collapse runs faster (reverseDuration) so dismissing - // the rail feels crisp rather than sluggish. - duration: FaiMotion.slow, - reverseDuration: FaiMotion.base, + duration: FaiMotion.fast, value: 0, ); } @@ -687,14 +565,14 @@ class _SidebarState extends State<_Sidebar> } Future _startDaemon(BuildContext context) async { - final l = AppLocalizations.of(context)!; final r = await SystemActions.faiDaemon(['start']); if (!context.mounted) return; - final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail), + r.ok + ? 'Daemon start requested. Reconnecting…' + : 'Could not start daemon: ${r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim()}', ), ), ); @@ -1283,16 +1161,7 @@ class _SidebarItemState extends State<_SidebarItem> { // the left. The bg highlight spans the rail's // current width (typical nav-rail behaviour). padding: const EdgeInsets.symmetric(vertical: FaiSpace.md), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(FaiRadius.sm), - // Lift only the selected item — a whisper of depth - // marks "you are here" without competing with the - // accent fill. - boxShadow: widget.selected - ? FaiElevation.low(theme.brightness) - : null, - ), + decoration: BoxDecoration(color: bg), child: content, ), ), @@ -1404,8 +1273,6 @@ class _NavPage { return l.navAudit; case 'approvals': return l.navApprovals; - case 'federation': - return l.navFederation; default: return id; } diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index ebef8bf..ea6a0c5 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -973,7 +973,7 @@ class _ExplanationPanel extends StatelessWidget { : theme.colorScheme.error, ), ), - if (result!.fixHint(l).isNotEmpty) ...[ + if (result!.fixHint.isNotEmpty) ...[ const SizedBox(height: FaiSpace.sm), Text( l.auditFixLabel, @@ -984,7 +984,7 @@ class _ExplanationPanel extends StatelessWidget { ), const SizedBox(height: 2), Text( - result!.fixHint(l), + result!.fixHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface, ), diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 4b3fe0a..247934b 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -402,15 +402,7 @@ class _PathRow extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; - final lower = path.toLowerCase(); - final isLog = !isDirectory && lower.endsWith('.log'); - // Text config files get an in-Studio viewer too (read top-down, - // no log colouring) so the operator can read config.yaml - // without leaving Studio or hunting for an external editor. - final isConfig = !isDirectory && - (lower.endsWith('.yaml') || - lower.endsWith('.yml') || - lower.endsWith('.toml')); + final isLog = !isDirectory && path.toLowerCase().endsWith('.log'); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( @@ -436,11 +428,10 @@ class _PathRow extends StatelessWidget { ), ), const SizedBox(width: FaiSpace.sm), - if (isLog || isConfig) ...[ + if (isLog) ...[ OutlinedButton.icon( - onPressed: () => isConfig - ? showFaiConfigViewer(context, path: path, title: label) - : showFaiLogViewer(context, path: path, title: label), + onPressed: () => + showFaiLogViewer(context, path: path, title: label), icon: const Icon(Icons.visibility_outlined, size: 14), label: Text(l.buttonView), style: OutlinedButton.styleFrom( @@ -491,11 +482,6 @@ class _DaemonActionsCard extends StatefulWidget { class _DaemonActionsCardState extends State<_DaemonActionsCard> { bool _busy = false; String? _output; - - /// True when the last daemon action failed because no `fai` - /// binary could be located. Swaps the raw output line for the - /// actionable [FaiBinaryRecovery] block. - bool _binaryMissing = false; ChannelStatusSnapshot? _channels; @override @@ -525,19 +511,13 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { final r = await action(); if (!mounted) return; final l = AppLocalizations.of(context)!; - final binaryMissing = !r.ok && r.stderr.trim() == kFaiBinaryNotFound; setState(() { _busy = false; - _binaryMissing = binaryMissing; - // When the binary is missing we render the actionable - // recovery block instead of a raw output line — the - // sentinel is not a user-facing string. - _output = binaryMissing - ? null - : (r.ok - ? '${l.daemonActionResultOk(label)}\n${r.stdout}' - : '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}') - .trim(); + _output = + (r.ok + ? '${l.daemonActionResultOk(label)}\n${r.stdout}' + : '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}') + .trim(); }); // Refresh the status pill — restart / stop / start all // change the running flag. @@ -679,15 +659,6 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { ], ), ], - if (_binaryMissing) ...[ - const SizedBox(height: FaiSpace.md), - FaiBinaryRecovery( - onLocated: () => _run( - l.daemonActionStatus, - () => SystemActions.faiDaemon(['status']), - ), - ), - ], if (_output != null) ...[ const SizedBox(height: FaiSpace.md), Container( diff --git a/lib/pages/federation.dart b/lib/pages/federation.dart deleted file mode 100644 index 3f744aa..0000000 --- a/lib/pages/federation.dart +++ /dev/null @@ -1,334 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../data/hub.dart'; -import '../l10n/app_localizations.dart'; -import '../theme/theme.dart'; -import '../theme/tokens.dart'; -import '../widgets/widgets.dart'; -import 'welcome.dart' show showFaiDoc; - -/// Federation panel: the satellites connected to this hub (primary -/// side) and a one-step "add satellite" flow that issues a bootstrap -/// token + the primary CA as a ready-to-paste config. -class FederationPage extends StatefulWidget { - const FederationPage({super.key}); - - @override - State createState() => _FederationPageState(); -} - -class _FederationPageState extends State { - late Future> _future; - - @override - void initState() { - super.initState(); - _refresh(); - } - - void _refresh() { - setState(() { - _future = HubService.instance.listSatellites(); - }); - } - - Future _addSatellite() async { - final name = await _promptName(context); - if (name == null || name.isEmpty) return; - if (!mounted) return; - final l = AppLocalizations.of(context)!; - try { - final enrollment = await HubService.instance.issueSatelliteToken(name); - if (!mounted) return; - await showDialog( - context: context, - builder: (_) => _EnrollmentDialog(name: name, enrollment: enrollment), - ); - _refresh(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.federationIssueFailed(e.toString()))), - ); - } - } - - Future _promptName(BuildContext context) async { - final controller = TextEditingController(); - final l = AppLocalizations.of(context)!; - return showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(l.federationAddDialogTitle), - content: TextField( - controller: controller, - autofocus: true, - decoration: InputDecoration( - labelText: l.federationNameLabel, - hintText: 'satellite-a', - border: const OutlineInputBorder(), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, null), - child: Text(l.buttonCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, controller.text.trim()), - child: Text(l.federationIssueButton), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - appBar: AppBar( - title: Text(l.federationTitle), - actions: [ - IconButton( - icon: const Icon(Icons.help_outline, size: 18), - tooltip: l.helpTooltip, - onPressed: () => showFaiDoc(context, 'federation'), - ), - IconButton( - icon: const Icon(Icons.refresh, size: 18), - tooltip: l.federationReloadTooltip, - onPressed: _refresh, - ), - const SizedBox(width: FaiSpace.sm), - ], - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: _addSatellite, - icon: const Icon(Icons.add_link), - label: Text(l.federationAddSatellite), - ), - body: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return FaiEmptyState( - icon: Icons.cloud_off_outlined, - iconColor: theme.colorScheme.error, - title: l.hubUnreachable, - hint: l.hubUnreachableHint, - action: FilledButton.tonal( - onPressed: _refresh, - child: Text(l.buttonRetry), - ), - ); - } - final sats = snapshot.data ?? []; - if (sats.isEmpty) { - return FaiEmptyState( - icon: Icons.hub_outlined, - title: l.federationEmptyTitle, - hint: l.federationEmptyHint, - action: FilledButton.icon( - onPressed: _addSatellite, - icon: const Icon(Icons.add_link, size: 16), - label: Text(l.federationAddSatellite), - ), - ); - } - return ListView.separated( - padding: const EdgeInsets.all(FaiSpace.xl), - itemCount: sats.length, - separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), - itemBuilder: (context, i) => _SatelliteCard(satellite: sats[i]), - ); - }, - ), - ); - } -} - -class _SatelliteCard extends StatelessWidget { - final Satellite satellite; - const _SatelliteCard({required this.satellite}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - return FaiCard( - accentTop: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - FaiPill( - label: l.federationPillConnected, - tone: FaiPillTone.success, - icon: Icons.link, - ), - const SizedBox(width: FaiSpace.sm), - Expanded( - child: Text( - satellite.displayName, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - FaiPill( - label: satellite.region.isEmpty - ? l.federationRegionNone - : satellite.region, - tone: FaiPillTone.neutral, - icon: Icons.public, - ), - ], - ), - const SizedBox(height: FaiSpace.md), - Wrap( - spacing: FaiSpace.md, - runSpacing: FaiSpace.xs, - children: [ - _meta(theme, 'v${satellite.hubVersion}'), - _meta(theme, 'wire ${satellite.wireVersion}'), - _meta(theme, satellite.hubId), - ], - ), - if (satellite.capabilities.isNotEmpty) ...[ - const SizedBox(height: FaiSpace.md), - Text( - l.federationCapabilities(satellite.capabilities.length), - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 0.6, - ), - ), - const SizedBox(height: 4), - Wrap( - spacing: FaiSpace.xs, - runSpacing: FaiSpace.xs, - children: satellite.capabilities - .map( - (c) => FaiPill( - label: c, - tone: FaiPillTone.accent, - icon: Icons.extension_outlined, - ), - ) - .toList(), - ), - ], - ], - ), - ); - } - - Widget _meta(ThemeData theme, String text) => Text( - text, - style: FaiTheme.mono(size: 10, color: theme.colorScheme.onSurfaceVariant), - ); -} - -/// Shows the freshly issued token + a ready-to-paste satellite config -/// (with the primary CA inlined) the operator copies over. -class _EnrollmentDialog extends StatelessWidget { - final String name; - final SatelliteEnrollment enrollment; - const _EnrollmentDialog({required this.name, required this.enrollment}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - final config = enrollment.toConfigYaml(name); - return AlertDialog( - title: Text(l.federationEnrollmentTitle(name)), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560, maxHeight: 520), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(l.federationTokenLabel, style: theme.textTheme.labelSmall), - const SizedBox(height: 4), - _CopyableBlock(text: enrollment.token, copiedMsg: l.federationCopied), - const SizedBox(height: FaiSpace.md), - Text(l.federationConfigLabel, style: theme.textTheme.labelSmall), - const SizedBox(height: 4), - _CopyableBlock(text: config, copiedMsg: l.federationCopied), - const SizedBox(height: FaiSpace.md), - Text( - l.federationEnrollmentHint, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l.buttonClose), - ), - ], - ); - } -} - -class _CopyableBlock extends StatelessWidget { - final String text; - final String copiedMsg; - const _CopyableBlock({required this.text, required this.copiedMsg}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Stack( - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB( - FaiSpace.md, - FaiSpace.md, - 40, - FaiSpace.md, - ), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: SelectableText( - text, - style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface), - ), - ), - Positioned( - top: 2, - right: 2, - child: IconButton( - icon: const Icon(Icons.copy, size: 16), - tooltip: copiedMsg, - onPressed: () { - Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(copiedMsg))); - }, - ), - ), - ], - ); - } -} diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 05a85ab..e017406 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -1792,7 +1792,7 @@ class _FeaturedTile extends StatelessWidget { } } -class _StoreCard extends StatefulWidget { +class _StoreCard extends StatelessWidget { final StoreItem item; final String locale; @@ -1810,19 +1810,6 @@ class _StoreCard extends StatefulWidget { required this.onInstall, }); - @override - State<_StoreCard> createState() => _StoreCardState(); -} - -class _StoreCardState extends State<_StoreCard> { - bool _hovered = false; - - StoreItem get item => widget.item; - String get locale => widget.locale; - String? get installedVersion => widget.installedVersion; - VoidCallback get onTap => widget.onTap; - VoidCallback get onInstall => widget.onInstall; - /// True iff the operator has this module installed AND the /// store-index offers a strictly-newer best_version. Uses a /// dotted-numeric semver-light compare so 0.10.5 > 0.9.99. @@ -1849,167 +1836,146 @@ class _StoreCardState extends State<_StoreCard> { final notInstallable = !installable && !item.installed; return Opacity( opacity: notInstallable ? 0.6 : 1.0, - // Hover-lift: a 2px rise plus shadow bump signals the card - // is clickable without a heavy hover fill. Resting state - // carries the low elevation so cards read as physical. - child: MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: FaiMotion.base, - curve: FaiMotion.easing, - transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(FaiRadius.md), - boxShadow: _hovered - ? FaiElevation.medium(theme.brightness) - : FaiElevation.low(theme.brightness), - ), - child: Material( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(FaiRadius.md), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(FaiRadius.md), - child: Padding( - padding: const EdgeInsets.all(FaiSpace.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Material( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.md), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(FaiRadius.md), + child: Padding( + padding: const EdgeInsets.all(FaiSpace.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Row( - children: [ - CircleAvatar( - radius: 18, - backgroundColor: theme.colorScheme.primary.withValues( - alpha: 0.12, - ), - child: Icon( - _iconForCategory(item.category), - size: 18, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(width: FaiSpace.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - item.category.isEmpty - ? '—' - : _categoryDisplayName( - context, - item.category, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - if (_hasUpdate) - Tooltip( - message: l.storeUpdateTooltip( - installedVersion ?? '?', - item.bestVersion, - ), - child: FaiPill( - label: l.storePillUpdate, - tone: FaiPillTone.warning, - icon: Icons.system_update_alt, - ), - ) - else if (item.installed) - FaiPill( - label: l.storePillInstalled, - tone: FaiPillTone.success, - icon: Icons.check, - ) - else if (item.status.isNotEmpty) - FaiPill( - label: _statusDisplayName(context, item.status), - tone: _toneForStatus(item.status), - ), - ], - ), - const SizedBox(height: FaiSpace.sm), - Expanded( - child: Text( - tagline.isEmpty ? l.storeNoTagline : tagline, - style: theme.textTheme.bodySmall, - maxLines: 3, - overflow: TextOverflow.ellipsis, + CircleAvatar( + radius: 18, + backgroundColor: theme.colorScheme.primary.withValues( + alpha: 0.12, + ), + child: Icon( + _iconForCategory(item.category), + size: 18, + color: theme.colorScheme.primary, ), ), - const SizedBox(height: FaiSpace.sm), - Row( - children: [ - if (item.bestVersion.isNotEmpty) ...[ - Tooltip( - message: item.isFederated - ? l.storeAdvisoryVersionTooltip - : '', - child: FaiPill( - label: item.isFederated - ? '~v${item.bestVersion}' - : 'v${item.bestVersion}', - tone: FaiPillTone.neutral, - monospace: true, + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + item.category.isEmpty + ? '—' + : _categoryDisplayName(context, item.category), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), ), - const SizedBox(width: FaiSpace.xs), ], - _ProvenancePill(item: item), - const Spacer(), - if (_hasUpdate) - FilledButton.icon( - onPressed: onInstall, - icon: const Icon(Icons.system_update_alt, size: 14), - label: Text(l.storeUpdateButton(item.bestVersion)), - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ) - else if (item.installed) - TextButton.icon( - onPressed: onTap, - icon: const Icon(Icons.info_outline, size: 14), - label: Text(l.buttonDetails), - ) - else if (installable) - FilledButton.icon( - onPressed: onInstall, - icon: const Icon(Icons.download, size: 14), - label: Text(l.buttonInstall), - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ) - else - OutlinedButton.icon( - onPressed: onTap, - icon: const Icon(Icons.info_outline, size: 14), - label: Text(l.buttonDetails), - style: OutlinedButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ], + ), ), + if (_hasUpdate) + Tooltip( + message: l.storeUpdateTooltip( + installedVersion ?? '?', + item.bestVersion, + ), + child: FaiPill( + label: l.storePillUpdate, + tone: FaiPillTone.warning, + icon: Icons.system_update_alt, + ), + ) + else if (item.installed) + FaiPill( + label: l.storePillInstalled, + tone: FaiPillTone.success, + icon: Icons.check, + ) + else if (item.status.isNotEmpty) + FaiPill( + label: _statusDisplayName(context, item.status), + tone: _toneForStatus(item.status), + ), ], ), - ), + const SizedBox(height: FaiSpace.sm), + Expanded( + child: Text( + tagline.isEmpty ? l.storeNoTagline : tagline, + style: theme.textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: FaiSpace.sm), + Row( + children: [ + if (item.bestVersion.isNotEmpty) ...[ + Tooltip( + message: item.isFederated + ? l.storeAdvisoryVersionTooltip + : '', + child: FaiPill( + label: item.isFederated + ? '~v${item.bestVersion}' + : 'v${item.bestVersion}', + tone: FaiPillTone.neutral, + monospace: true, + ), + ), + const SizedBox(width: FaiSpace.xs), + ], + _ProvenancePill(item: item), + const Spacer(), + if (_hasUpdate) + FilledButton.icon( + onPressed: onInstall, + icon: const Icon(Icons.system_update_alt, size: 14), + label: Text(l.storeUpdateButton(item.bestVersion)), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ) + else if (item.installed) + TextButton.icon( + onPressed: onTap, + icon: const Icon(Icons.info_outline, size: 14), + label: Text(l.buttonDetails), + ) + else if (installable) + FilledButton.icon( + onPressed: onInstall, + icon: const Icon(Icons.download, size: 14), + label: Text(l.buttonInstall), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ) + else + OutlinedButton.icon( + onPressed: onTap, + icon: const Icon(Icons.info_outline, size: 14), + label: Text(l.buttonDetails), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ], ), ), ), @@ -2037,7 +2003,6 @@ class _StoreDetailSheet extends StatefulWidget { context: context, isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainer, - elevation: 8, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), ), diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 1782d45..d5c6c29 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -28,11 +28,6 @@ class WelcomePage extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; - // The shell polls hub health on a 5 s tick. When the hub is - // down a brand-new operator otherwise sees a dead, all- - // unchecked onboarding checklist with no obvious recovery — - // so we lead with a "start the hub" hero instead. - final hubDown = StudioShellState.of(context)?.connected == false; return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar(titleSpacing: FaiSpace.xl, title: Text(l.navWelcome)), @@ -57,27 +52,22 @@ class WelcomePage extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 960), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _Hero(), - const SizedBox(height: FaiSpace.xxl), - // When the hub is down the onboarding checklist - // would render dead (all-unchecked, nothing to - // probe). Lead with a "start the hub" card - // instead; restore the checklist once connected. - if (hubDown) ...[ - const _HubDownHero(), - const SizedBox(height: FaiSpace.xxl), - ] else - const _OnboardingChecklist(), - const _PillarRow(), - const SizedBox(height: FaiSpace.xxl), - const _SectionLabel(textKey: _SectionLabelKey.trust), - const SizedBox(height: FaiSpace.md), - const _TrustPosture(), - const SizedBox(height: FaiSpace.xxl), - const _SectionLabel(textKey: _SectionLabelKey.docs), - const SizedBox(height: FaiSpace.md), - const _DocsRow(), + children: const [ + _Hero(), + SizedBox(height: FaiSpace.xxl), + // Checklist comes second — operator-actionable + // path beats educational content. Auto-hides + // once the operator dismisses it. + _OnboardingChecklist(), + _PillarRow(), + SizedBox(height: FaiSpace.xxl), + _SectionLabel(textKey: _SectionLabelKey.trust), + SizedBox(height: FaiSpace.md), + _TrustPosture(), + SizedBox(height: FaiSpace.xxl), + _SectionLabel(textKey: _SectionLabelKey.docs), + SizedBox(height: FaiSpace.md), + _DocsRow(), ], ), ), @@ -159,113 +149,6 @@ class _Hero extends StatelessWidget { } } -/// Prominent recovery hero shown on the Welcome page when the -/// hub is not reachable. Headline + one-line explanation + a big -/// "Start hub" CTA wired to the shell's daemon-start path, with -/// a secondary link to the install guide for the case where no -/// `fai` binary can be located. -class _HubDownHero extends StatefulWidget { - const _HubDownHero(); - - @override - State<_HubDownHero> createState() => _HubDownHeroState(); -} - -class _HubDownHeroState extends State<_HubDownHero> { - bool _starting = false; - - Future _start() async { - final shell = StudioShellState.of(context); - if (shell == null) return; - setState(() => _starting = true); - await shell.startDaemon(); - if (!mounted) return; - setState(() => _starting = false); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - // No `fai` binary → "Start hub" cannot work; lead the - // operator to the install guide instead. - final canStart = SystemActions.faiBinaryExists(); - return Container( - width: double.infinity, - padding: const EdgeInsets.all(FaiSpace.xl), - decoration: BoxDecoration( - color: theme.colorScheme.errorContainer.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(FaiRadius.md), - border: Border.all( - color: theme.colorScheme.error.withValues(alpha: 0.4), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.cloud_off_outlined, - size: 24, - color: theme.colorScheme.error, - ), - const SizedBox(width: FaiSpace.md), - Expanded( - child: Text( - l.welcomeHubDownTitle, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - const SizedBox(height: FaiSpace.md), - Text( - l.welcomeHubDownBody, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - height: 1.45, - ), - ), - const SizedBox(height: FaiSpace.xs), - Text( - l.welcomeHubDownEndpoint(HubService.instance.endpointLabel), - style: FaiTheme.mono( - size: 11, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: FaiSpace.lg), - if (canStart) - FilledButton.icon( - onPressed: _starting ? null : _start, - icon: _starting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.play_arrow), - label: Text( - _starting ? l.welcomeHubDownStarting : l.welcomeHubDownStart, - ), - ) - else - const FaiBinaryRecovery(), - const SizedBox(height: FaiSpace.sm), - TextButton.icon( - icon: const Icon(Icons.open_in_new, size: 16), - label: Text(l.welcomeHubDownDocsLink), - onPressed: () => SystemActions.openInOs(kFaiInstallDocsUrl), - ), - ], - ), - ); - } -} - enum _SectionLabelKey { trust, docs } class _SectionLabel extends StatelessWidget { @@ -1021,82 +904,58 @@ class _DocsRow extends StatelessWidget { } } -class _DocCard extends StatefulWidget { +class _DocCard extends StatelessWidget { final _DocEntry entry; const _DocCard({required this.entry}); - @override - State<_DocCard> createState() => _DocCardState(); -} - -class _DocCardState extends State<_DocCard> { - bool _hovered = false; - @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; - final entry = widget.entry; - // Hover-lift mirrors the Store cards: a gentle 2px rise plus - // shadow bump. Resting state stays flat-with-border so the - // doc strip reads calm until the cursor invites a click. - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: FaiMotion.base, - curve: FaiMotion.easing, - transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(FaiRadius.md), - boxShadow: _hovered ? FaiElevation.medium(theme.brightness) : null, - ), - child: Material( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(FaiRadius.md), - child: InkWell( - onTap: () => _DocReaderSheet.show(context, entry), + return Material( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.md), + child: InkWell( + onTap: () => _DocReaderSheet.show(context, entry), + borderRadius: BorderRadius.circular(FaiRadius.md), + child: Container( + padding: const EdgeInsets.all(FaiSpace.lg), + decoration: BoxDecoration( borderRadius: BorderRadius.circular(FaiRadius.md), - child: Container( - padding: const EdgeInsets.all(FaiSpace.lg), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(FaiRadius.md), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(entry.icon, size: 22, color: theme.colorScheme.primary), - const SizedBox(width: FaiSpace.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - entry.title(l), - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - entry.blurb(l), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - height: 1.4, - ), - ), - ], + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(entry.icon, size: 22, color: theme.colorScheme.primary), + const SizedBox(width: FaiSpace.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.title(l), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), - ), - Icon( - Icons.chevron_right, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - ], + const SizedBox(height: 4), + Text( + entry.blurb(l), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + ), ), - ), + Icon( + Icons.chevron_right, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + ], ), ), ), @@ -1124,15 +983,6 @@ class _DocReaderError { String get forgejoUrl => 'https://git.flemming.ai/fai/platform/src/branch/main/docs/architecture/$slug.md'; - /// Localized, operator-facing failure body. Use this for any - /// on-screen rendering; [toString] stays English for logs. - String localizedBody(AppLocalizations l) => l.welcomeDocLoadFailedBody( - slug, - locale, - attempted.join(', '), - underlying, - ); - @override String toString() => 'Could not load the bundled doc "$slug" ($locale).\n' @@ -1156,7 +1006,6 @@ class _DocReaderSheet extends StatefulWidget { context: context, isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainer, - elevation: 8, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), ), @@ -1286,18 +1135,7 @@ class _DocReaderSheetState extends State<_DocReaderSheet> { ), ), const SizedBox(height: FaiSpace.sm), - if (structured != null) - FaiErrorBox( - text: structured.localizedBody(l), - isError: true, - maxHeight: 200, - ) - else - FaiErrorBox( - error: err, - isError: true, - maxHeight: 200, - ), + FaiErrorBox(error: err, isError: true, maxHeight: 200), if (structured != null) ...[ const SizedBox(height: FaiSpace.md), TextButton.icon( diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 2036fdc..67fb7bd 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -2,75 +2,55 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'tokens.dart'; class FaiTheme { FaiTheme._(); - /// Bundled font families. Declared in pubspec.yaml `fonts:` and - /// shipped as TTF assets under `assets/fonts/` — NOT fetched at - /// runtime via google_fonts. This keeps the app working fully - /// offline / air-gapped (KRITIS) and avoids the font-swap flash - /// google_fonts shows on first paint. - static const String _uiFamily = 'Inter'; - static const String _monoFamily = 'JetBrains Mono'; - /// Inter for UI, JetBrains Mono for technical strings (IDs, - /// paths, capability refs). Both bundled (see [_uiFamily]). + /// paths, capability refs). Both via google_fonts so they + /// bundle on first run; for production we'd ship them as + /// app assets. static TextTheme _textTheme(Brightness b) { final base = b == Brightness.dark ? Typography.whiteCupertino : Typography.blackCupertino; - return base.apply(fontFamily: _uiFamily).copyWith( - displaySmall: const TextStyle( - fontFamily: _uiFamily, + return GoogleFonts.interTextTheme(base).copyWith( + displaySmall: GoogleFonts.inter( fontSize: 28, fontWeight: FontWeight.w600, letterSpacing: -0.4, ), - headlineSmall: const TextStyle( - fontFamily: _uiFamily, + headlineSmall: GoogleFonts.inter( fontSize: 20, fontWeight: FontWeight.w600, letterSpacing: -0.2, ), - titleMedium: const TextStyle( - fontFamily: _uiFamily, + titleMedium: GoogleFonts.inter( fontSize: 15, fontWeight: FontWeight.w600, letterSpacing: 0, ), - titleSmall: const TextStyle( - fontFamily: _uiFamily, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - bodyLarge: const TextStyle( - fontFamily: _uiFamily, + titleSmall: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500), + bodyLarge: GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w400, height: 1.45, ), - bodyMedium: const TextStyle( - fontFamily: _uiFamily, + bodyMedium: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w400, height: 1.4, ), - bodySmall: const TextStyle( - fontFamily: _uiFamily, + bodySmall: GoogleFonts.inter( fontSize: 12, fontWeight: FontWeight.w400, height: 1.4, ), - labelMedium: const TextStyle( - fontFamily: _uiFamily, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - labelSmall: const TextStyle( - fontFamily: _uiFamily, + labelMedium: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w500), + labelSmall: GoogleFonts.inter( fontSize: 11, fontWeight: FontWeight.w500, letterSpacing: 0.2, @@ -79,14 +59,12 @@ class FaiTheme { } /// JetBrains Mono for monospaced technical content. Used by - /// FaiMono helper. Only Regular (400) and Medium (500) weights - /// are bundled — heavier requests fall back to the nearest. + /// FaiMono helper. static TextStyle mono({ double size = 12, FontWeight weight = FontWeight.w400, Color? color, - }) => TextStyle( - fontFamily: _monoFamily, + }) => GoogleFonts.jetBrainsMono( fontSize: size, fontWeight: weight, color: color, @@ -243,8 +221,8 @@ class FaiTheme { scrolledUnderElevation: 0, centerTitle: false, // Bake the foreground colour straight into the title - // style. `headlineSmall` is built from the bundled Inter - // family with no color slot, and Material's "merge foreground + // style. `headlineSmall` is built from GoogleFonts.inter + // with no color slot, and Material's "merge foreground // colour at draw time" path leaves it null in some // light-mode builds — the title rendered white-on-white. titleTextStyle: textTheme.headlineSmall?.copyWith( diff --git a/lib/theme/tokens.dart b/lib/theme/tokens.dart index dbbc84b..845cedc 100644 --- a/lib/theme/tokens.dart +++ b/lib/theme/tokens.dart @@ -61,50 +61,6 @@ class FaiRadius { static const md = 10.0; } -/// Elevation tokens. Subtle, Linear/Raycast-style depth — never -/// heavy drop-shadows. Two resting levels plus a hover bump. -/// Brightness-aware: dark themes lean on deeper, lower-alpha -/// shadows (they read as ambient occlusion against near-black -/// surfaces); light themes use softer, slightly larger spreads. -class FaiElevation { - FaiElevation._(); - - /// Resting elevation for cards and sheets. Just enough to lift - /// a surface off the canvas without announcing itself. - static List low(Brightness b) => b == Brightness.dark - ? const [ - BoxShadow( - color: Color(0x33000000), - blurRadius: 6, - offset: Offset(0, 2), - ), - ] - : const [ - BoxShadow( - color: Color(0x14000000), - blurRadius: 8, - offset: Offset(0, 2), - ), - ]; - - /// Raised elevation for hover-lifted cards and floating sheets. - static List medium(Brightness b) => b == Brightness.dark - ? const [ - BoxShadow( - color: Color(0x4D000000), - blurRadius: 16, - offset: Offset(0, 6), - ), - ] - : const [ - BoxShadow( - color: Color(0x1F000000), - blurRadius: 20, - offset: Offset(0, 8), - ), - ]; -} - /// Animation durations. Keep them under 300ms for power-user feel. class FaiMotion { FaiMotion._(); diff --git a/lib/widgets/fai_binary_recovery.dart b/lib/widgets/fai_binary_recovery.dart deleted file mode 100644 index d92aa99..0000000 --- a/lib/widgets/fai_binary_recovery.dart +++ /dev/null @@ -1,95 +0,0 @@ -// Recovery affordance shown when Studio cannot locate the `fai` -// binary on the machine. Replaces the old CLI-jargon dead-end -// ("set FAI_BIN to its full path") with two acts a non-CLI -// operator can actually take: -// -// 1. Locate the binary with a native file picker, persisting -// the choice via SystemActions.setFaiBinaryPath. -// 2. Open the install guide in the OS browser. - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; - -import '../data/system_actions.dart'; -import '../l10n/app_localizations.dart'; -import '../theme/tokens.dart'; - -class FaiBinaryRecovery extends StatelessWidget { - /// Fired after the operator successfully picks a `fai` binary, - /// so the host can retry whatever action hit the missing - /// binary (e.g. re-run the daemon start / status probe). - final VoidCallback? onLocated; - - const FaiBinaryRecovery({super.key, this.onLocated}); - - Future _locate(BuildContext context) async { - final l = AppLocalizations.of(context)!; - final messenger = ScaffoldMessenger.of(context); - final picked = await FilePicker.pickFiles( - dialogTitle: l.faiBinaryPickerTitle, - ); - final path = picked?.files.single.path; - if (path == null) return; - final ok = await SystemActions.setFaiBinaryPath(path); - messenger.showSnackBar( - SnackBar( - content: Text(ok ? l.faiBinarySetOk(path) : l.faiBinaryNotFound), - ), - ); - if (ok) onLocated?.call(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - return Container( - width: double.infinity, - padding: const EdgeInsets.all(FaiSpace.md), - decoration: BoxDecoration( - color: theme.colorScheme.errorContainer.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border.all( - color: theme.colorScheme.error.withValues(alpha: 0.4), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.faiBinaryNotFound, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.error, - ), - ), - const SizedBox(height: FaiSpace.xs), - Text( - l.faiBinaryNotFoundHint, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: FaiSpace.md), - Wrap( - spacing: FaiSpace.sm, - runSpacing: FaiSpace.sm, - children: [ - FilledButton.tonalIcon( - onPressed: () => _locate(context), - icon: const Icon(Icons.folder_open, size: 16), - label: Text(l.faiBinaryLocateButton), - ), - OutlinedButton.icon( - onPressed: () => - SystemActions.openInOs(kFaiInstallDocsUrl), - icon: const Icon(Icons.open_in_new, size: 16), - label: Text(l.faiBinaryInstallDocsButton), - ), - ], - ), - ], - ), - ); - } -} diff --git a/lib/widgets/fai_empty_state.dart b/lib/widgets/fai_empty_state.dart index cf393b9..10816da 100644 --- a/lib/widgets/fai_empty_state.dart +++ b/lib/widgets/fai_empty_state.dart @@ -1,17 +1,9 @@ // FaiEmptyState — gracious empty / loading / error placeholder. // Replaces "(no data)" strings with one tone, one icon, one tip. -// -// The blank surface is where an app most easily feels unfinished, -// so this state is deliberately *designed*: the F∆I delta (∆) -// sits as a soft watermark behind the icon badge, the badge -// carries a whisper of elevation, and the copy breathes. The -// result reads "nothing here yet, on purpose" rather than -// "something is broken". import 'package:flutter/material.dart'; import '../theme/tokens.dart'; -import 'fai_delta_mark.dart'; class FaiEmptyState extends StatelessWidget { final IconData icon; @@ -38,67 +30,40 @@ class FaiEmptyState extends StatelessWidget { final color = iconColor ?? theme.colorScheme.onSurfaceVariant; return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 440), + constraints: const BoxConstraints(maxWidth: 420), child: Padding( padding: const EdgeInsets.all(FaiSpace.xxl), child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - // Brand hero: an oversized, low-opacity ∆ watermark - // anchors the composition while the contextual icon - // badge sits crisply on top — so every empty surface - // still carries the F∆I signature, never a bare icon. - SizedBox( - width: 112, - height: 112, - child: Stack( - alignment: Alignment.center, - children: [ - Opacity( - opacity: 0.10, - child: FaiDeltaMark( - color: theme.colorScheme.primary, - size: 112, - ), - ), - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - shape: BoxShape.circle, - border: Border.all( - color: color.withValues(alpha: 0.18), - ), - boxShadow: FaiElevation.low(theme.brightness), - ), - child: Icon(icon, size: 26, color: color), - ), - ], + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + shape: BoxShape.circle, ), + child: Icon(icon, size: 24, color: color), ), - const SizedBox(height: FaiSpace.xl), + const SizedBox(height: FaiSpace.lg), Text( title, textAlign: TextAlign.center, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + style: theme.textTheme.titleMedium, ), if (hint != null) ...[ const SizedBox(height: FaiSpace.sm), Text( hint!, textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( + style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, - height: 1.5, ), ), ], if (action != null) ...[ - const SizedBox(height: FaiSpace.xl), + const SizedBox(height: FaiSpace.lg), action!, ], ], diff --git a/lib/widgets/fai_log_viewer.dart b/lib/widgets/fai_log_viewer.dart index a4b67eb..c4fd243 100644 --- a/lib/widgets/fai_log_viewer.dart +++ b/lib/widgets/fai_log_viewer.dart @@ -36,8 +36,6 @@ Future showFaiLogViewer( required String path, String? title, int tailLines = 500, - bool fromTop = false, - bool plain = false, }) { return showDialog( context: context, @@ -46,51 +44,20 @@ Future showFaiLogViewer( path: path, title: title ?? path.split(Platform.pathSeparator).last, tailLines: tailLines, - fromTop: fromTop, - plain: plain, ), ); } -/// Open a config / plain-text file (e.g. `config.yaml`) inside the -/// same modal viewer, read top-down with no log-level colouring. -/// A thin wrapper over [showFaiLogViewer] so call sites read by -/// intent. -Future showFaiConfigViewer( - BuildContext context, { - required String path, - String? title, - int maxLines = 2000, -}) { - return showFaiLogViewer( - context, - path: path, - title: title, - tailLines: maxLines, - fromTop: true, - plain: true, - ); -} - class FaiLogViewer extends StatefulWidget { final String path; final String title; final int tailLines; - /// Read from the top of the file (config) vs the tail (logs). - final bool fromTop; - - /// Skip log-level colouring and render every line neutral - /// (for config / plain-text files). - final bool plain; - const FaiLogViewer({ super.key, required this.path, required this.title, this.tailLines = 500, - this.fromTop = false, - this.plain = false, }); @override @@ -110,11 +77,7 @@ class _FaiLogViewerState extends State { Future _reload() async { setState(() => _loading = true); - final lines = await _readLines( - widget.path, - widget.tailLines, - fromTop: widget.fromTop, - ); + final lines = await _readTail(widget.path, widget.tailLines); if (!mounted) return; setState(() { _lines = lines; @@ -178,7 +141,7 @@ class _FaiLogViewerState extends State { ), ), ) - : _LogBody(lines: _lines, plain: widget.plain), + : _LogBody(lines: _lines), ), ], ), @@ -286,9 +249,8 @@ class _Header extends StatelessWidget { class _LogBody extends StatelessWidget { final List lines; - final bool plain; - const _LogBody({required this.lines, this.plain = false}); + const _LogBody({required this.lines}); @override Widget build(BuildContext context) { @@ -303,7 +265,6 @@ class _LogBody extends StatelessWidget { text: lines[i], gutterWidth: gutterWidth, theme: theme, - plain: plain, ), ), ); @@ -315,14 +276,12 @@ class _LogLine extends StatelessWidget { final String text; final double gutterWidth; final ThemeData theme; - final bool plain; const _LogLine({ required this.lineNumber, required this.text, required this.gutterWidth, required this.theme, - this.plain = false, }); @override @@ -348,12 +307,7 @@ class _LogLine extends StatelessWidget { const SizedBox(width: FaiSpace.sm), Expanded( child: SelectableText.rich( - plain - ? TextSpan( - text: text, - style: TextStyle(color: theme.colorScheme.onSurface), - ) - : _highlightLine(text, theme), + _highlightLine(text, theme), style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurface, @@ -466,43 +420,22 @@ TextSpan _highlightTraceLine(String line, ThemeData theme) { ); } -/// Matches ANSI / VT100 escape sequences (colour codes, cursor -/// moves) plus stray control characters. Daemon logs written to a -/// file by older `fai` builds carried `\x1b[..m` colour codes that -/// rendered as garbled "special characters" in the viewer; we -/// strip them defensively so existing log files render clean even -/// though current builds no longer write ANSI to files. -final RegExp _ansiAndControl = RegExp( - r'\x1B\[[0-9;?]*[ -/]*[@-~]|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', -); - -String _sanitizeLine(String line) => line.replaceAll(_ansiAndControl, ''); - -/// Read up to [maxLines] of [path], sanitising each line of ANSI / -/// control characters. When [fromTop] is true the *first* -/// [maxLines] are returned (config files, read top-down); -/// otherwise the *last* [maxLines] are returned (logs, tail-first). -/// Returns an empty list on any I/O failure — the viewer renders -/// an empty-state hint rather than throwing. -Future> _readLines( - String path, - int maxLines, { - bool fromTop = false, -}) async { +/// Read the last [maxLines] of [path]. Returns an empty list on +/// any I/O failure — the viewer renders an empty-state hint in +/// that case rather than throwing. +Future> _readTail(String path, int maxLines) async { try { final f = File(path); if (!await f.exists()) return const []; final raw = await f.readAsString(); - final lines = raw.split('\n').map(_sanitizeLine).toList(); + final lines = raw.split('\n'); // Drop the trailing blank line that `split` leaves behind // when the file ends with `\n`. if (lines.isNotEmpty && lines.last.isEmpty) { lines.removeLast(); } if (lines.length <= maxLines) return lines; - return fromTop - ? lines.sublist(0, maxLines) - : lines.sublist(lines.length - maxLines); + return lines.sublist(lines.length - maxLines); } catch (_) { return const []; } diff --git a/lib/widgets/fai_module_sheet.dart b/lib/widgets/fai_module_sheet.dart index 5ae7d36..65a7974 100644 --- a/lib/widgets/fai_module_sheet.dart +++ b/lib/widgets/fai_module_sheet.dart @@ -26,7 +26,6 @@ class FaiModuleSheet extends StatefulWidget { context: context, backgroundColor: Theme.of(context).colorScheme.surfaceContainer, isScrollControlled: true, - elevation: 8, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), ), diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 8510e17..9ad7460 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -293,12 +293,11 @@ class _FaiSettingsDialogState extends State { }); final r = await SystemActions.faiChannelSwitch(name); if (!mounted) return; - final l = AppLocalizations.of(context)!; setState(() { _saving = false; _channelToast = r.ok - ? '${l.channelSwitchOk(name)}\n${r.stdout.trim()}' - : l.channelSwitchFailed(r.stderr.isEmpty ? r.stdout : r.stderr); + ? 'Switched active channel to "$name". Daemon restarted.\n${r.stdout.trim()}' + : 'Channel switch failed: ${r.stderr.isEmpty ? r.stdout : r.stderr}'; }); if (r.ok) await _loadChannels(); } diff --git a/lib/widgets/fai_system_ai_editor.dart b/lib/widgets/fai_system_ai_editor.dart index 77c3589..57da186 100644 --- a/lib/widgets/fai_system_ai_editor.dart +++ b/lib/widgets/fai_system_ai_editor.dart @@ -86,26 +86,6 @@ class _ProviderPreset { static _ProviderPreset byWire(String wire) { return all.firstWhere((p) => p.wire == wire, orElse: () => all.first); } - - /// Localized help body for this provider. Falls back to the - /// const English [description] for any future wire that has no - /// ARB key yet. - String descriptionFor(AppLocalizations l) { - switch (wire) { - case 'ollama': - return l.providerDescOllama; - case 'openai': - return l.providerDescOpenai; - case 'lmstudio': - return l.providerDescLmstudio; - case 'vllm': - return l.providerDescVllm; - case 'custom': - return l.providerDescCustom; - default: - return description; - } - } } class FaiSystemAiEditor extends StatefulWidget { @@ -392,7 +372,7 @@ class _FaiSystemAiEditorState extends State { _ProviderDropdown(value: _preset, onChanged: _onProviderChanged), const SizedBox(height: 4), Text( - _preset.descriptionFor(l), + _preset.description, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -654,10 +634,10 @@ class _TestResultPanel extends StatelessWidget { ok ? l.systemAiReplyPrefix(result.text) : result.text, style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface), ), - if (!ok && result.fixHint(l).isNotEmpty) ...[ + if (!ok && result.fixHint.isNotEmpty) ...[ const SizedBox(height: FaiSpace.xs), Text( - result.fixHint(l), + result.fixHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface, ), diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 6f294c0..b9554ed 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -4,7 +4,6 @@ // primitives directly — no Material `Card` / generic `Container` // soup in page code. -export 'fai_binary_recovery.dart'; export 'fai_card.dart'; export 'fai_data_row.dart'; export 'fai_delta_mark.dart'; diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f16b4c3..df8d2f7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.lock b/pubspec.lock index 21128e8..3c891f9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85" + url: "https://pub.dev" + source: hosted + version: "1.2.0" collection: dependency: transitive description: @@ -227,6 +235,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" + url: "https://pub.dev" + source: hosted + version: "8.1.0" google_identity_services_web: dependency: transitive description: @@ -267,6 +283,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + url: "https://pub.dev" + source: hosted + version: "2.0.0" http: dependency: transitive description: @@ -299,6 +323,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" leak_tracker: dependency: transitive description: @@ -339,6 +379,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" markdown: dependency: transitive description: @@ -379,6 +427,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: "direct main" description: @@ -387,6 +451,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -443,6 +531,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" scrollable_positioned_list: dependency: transitive description: @@ -698,4 +802,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 4a794ad..931dc4e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.70.0 +version: 0.69.0 environment: sdk: ^3.11.0-200.1.beta @@ -19,6 +19,7 @@ dependencies: # Sibling package; will move to Forgejo path once published. fai_client_sdk: path: ../fai_client_sdk_dart + google_fonts: ^8.1.0 shared_preferences: ^2.5.5 # Inline README rendering inside the Store detail sheet. # `flutter_markdown` is discontinued; `flutter_markdown_plus` @@ -67,24 +68,3 @@ flutter: # Air-gap-friendly — no network calls to read these. assets: - assets/docs/ - # Bundled fonts — Inter (UI) + JetBrains Mono (technical - # strings). Shipped as assets rather than fetched at runtime - # via google_fonts so the app works fully offline / air-gapped - # (KRITIS) and never shows a font-swap flash on first paint. - fonts: - - family: Inter - fonts: - - asset: assets/fonts/Inter-Regular.ttf - weight: 400 - - asset: assets/fonts/Inter-Medium.ttf - weight: 500 - - asset: assets/fonts/Inter-SemiBold.ttf - weight: 600 - - asset: assets/fonts/Inter-Bold.ttf - weight: 700 - - family: JetBrains Mono - fonts: - - asset: assets/fonts/JetBrainsMono-Regular.ttf - weight: 400 - - asset: assets/fonts/JetBrainsMono-Medium.ttf - weight: 500 diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 88b22e5..a962892 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)