Compare commits
No commits in common. "d0cfa5df05fe565b8e9a8175dfd338bef15f3b86" and "751186777485f6578d9c5b4dd5cadbdbc013c0bd" have entirely different histories.
d0cfa5df05
...
7511867774
35 changed files with 399 additions and 2418 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -3,20 +3,6 @@
|
||||||
All notable changes to `fai_studio` recorded here. Pubspec
|
All notable changes to `fai_studio` recorded here. Pubspec
|
||||||
version + `kStudioVersion` in `lib/main.dart` stay in lockstep.
|
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
|
## 0.68.0 — 2026-06-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
@ -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
|
|
||||||
```
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -11,7 +11,6 @@ import 'package:fai_client_sdk/fai_client_sdk.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../l10n/app_localizations.dart';
|
|
||||||
import 'flow_output.dart';
|
import 'flow_output.dart';
|
||||||
import 'hub_auth_token.dart';
|
import 'hub_auth_token.dart';
|
||||||
export 'flow_output.dart';
|
export 'flow_output.dart';
|
||||||
|
|
@ -859,39 +858,6 @@ class HubService {
|
||||||
Future<void> reject(String id, String reviewer, String reason) =>
|
Future<void> reject(String id, String reviewer, String reason) =>
|
||||||
_client.reject(approvalId: id, reviewer: reviewer, reason: reason);
|
_client.reject(approvalId: id, reviewer: reviewer, reason: reason);
|
||||||
|
|
||||||
/// Federation satellites currently connected to this hub
|
|
||||||
/// (primary side). Empty when none are connected.
|
|
||||||
Future<List<Satellite>> 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<SatelliteEnrollment> 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
|
/// Composite "doctor" snapshot. One round-trip per piece, run
|
||||||
/// in parallel so the page populates fast.
|
/// in parallel so the page populates fast.
|
||||||
Future<DoctorSnapshot> doctor() async {
|
Future<DoctorSnapshot> doctor() async {
|
||||||
|
|
@ -1241,51 +1207,6 @@ class PendingApproval {
|
||||||
|
|
||||||
/// Full approval record including decided-side fields. Used by
|
/// Full approval record including decided-side fields. Used by
|
||||||
/// the History tab so operators can review what they (or the
|
/// 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<String> 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://<this-primary-host>:<federation-port>\n'
|
|
||||||
' hub_name: $name\n'
|
|
||||||
' bootstrap_token_env: FAI_BOOTSTRAP_TOKEN$ca';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// system) decided.
|
/// system) decided.
|
||||||
class ApprovalRecord {
|
class ApprovalRecord {
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -1385,25 +1306,24 @@ class AskAiResult {
|
||||||
|
|
||||||
/// One-line fix hint per error kind, mirroring
|
/// One-line fix hint per error kind, mirroring
|
||||||
/// `docs/architecture/system-ai.md`. Empty on success.
|
/// `docs/architecture/system-ai.md`. Empty on success.
|
||||||
/// Localized — pass the active [AppLocalizations].
|
String get fixHint {
|
||||||
String fixHint(AppLocalizations l) {
|
|
||||||
switch (errorKind) {
|
switch (errorKind) {
|
||||||
case '':
|
case '':
|
||||||
return '';
|
return '';
|
||||||
case 'disabled':
|
case 'disabled':
|
||||||
return l.sysAiFixDisabled;
|
return 'Configure System AI in Settings (Cmd+,) → System AI panel.';
|
||||||
case 'env_missing':
|
case 'env_missing':
|
||||||
return l.sysAiFixEnvMissing;
|
return 'The API-key env var is empty. `export <var>=<key>` in your shell, then `fai daemon restart`.';
|
||||||
case 'network':
|
case 'network':
|
||||||
return l.sysAiFixNetwork;
|
return 'Hub cannot reach the configured endpoint. Is your provider running?';
|
||||||
case 'http':
|
case 'http':
|
||||||
return l.sysAiFixHttp;
|
return 'Provider returned a non-2xx status. Verify endpoint, model name, and API key.';
|
||||||
case 'parse':
|
case 'parse':
|
||||||
return l.sysAiFixParse;
|
return 'Provider returned an unexpected response shape. Non-OpenAI providers may need a compatibility proxy.';
|
||||||
case 'empty_response':
|
case 'empty_response':
|
||||||
return l.sysAiFixEmptyResponse;
|
return 'Provider answered with no content. Try a different model or rephrase the prompt.';
|
||||||
default:
|
default:
|
||||||
return l.sysAiFixUnknown(errorKind);
|
return 'Unknown failure kind ($errorKind). See system-ai.md.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,61 +18,9 @@
|
||||||
|
|
||||||
import 'dart:io';
|
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 {
|
class SystemActions {
|
||||||
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<void> 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<bool> 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
|
/// Ask the OS to open [path] in the default handler. On macOS
|
||||||
/// this opens text files in TextEdit, configs in the registered
|
/// this opens text files in TextEdit, configs in the registered
|
||||||
/// editor, etc. Returns true on a clean spawn (process exited
|
/// editor, etc. Returns true on a clean spawn (process exited
|
||||||
|
|
@ -201,11 +149,13 @@ class SystemActions {
|
||||||
) async {
|
) async {
|
||||||
final exe = _faiExecutable();
|
final exe = _faiExecutable();
|
||||||
if (exe == null) {
|
if (exe == null) {
|
||||||
// Sentinel, not a user-facing string. Callers detect this
|
return (
|
||||||
// via `faiBinaryExists()` and render a localized,
|
ok: false,
|
||||||
// actionable recovery message (locate binary / install
|
stdout: '',
|
||||||
// guide) instead of CLI jargon like "set FAI_BIN".
|
stderr:
|
||||||
return (ok: false, stdout: '', stderr: kFaiBinaryNotFound);
|
'Could not locate the `fai` binary. Install via the '
|
||||||
|
'platform installer or set FAI_BIN to its full path.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final r = await Process.run(exe, args);
|
final r = await Process.run(exe, args);
|
||||||
|
|
@ -225,17 +175,9 @@ class SystemActions {
|
||||||
return (executable: 'xdg-open', args: const []);
|
return (executable: 'xdg-open', args: const []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Locate the `fai` binary. Order: operator override (set via
|
/// Locate the `fai` binary. Order: $FAI_BIN, PATH, fallback to
|
||||||
/// the file picker), $FAI_BIN, PATH, fallback to the canonical
|
/// the canonical install location under the user's home dir.
|
||||||
/// install location under the user's home dir.
|
|
||||||
static String? _faiExecutable() {
|
static String? _faiExecutable() {
|
||||||
final override = _faiBinaryOverride;
|
|
||||||
if (override != null &&
|
|
||||||
override.isNotEmpty &&
|
|
||||||
File(override).existsSync()) {
|
|
||||||
return override;
|
|
||||||
}
|
|
||||||
|
|
||||||
final fromEnv = Platform.environment['FAI_BIN'];
|
final fromEnv = Platform.environment['FAI_BIN'];
|
||||||
if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) {
|
if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) {
|
||||||
return fromEnv;
|
return fromEnv;
|
||||||
|
|
|
||||||
|
|
@ -1520,97 +1520,5 @@
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"error": {"type": "String"}
|
"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."
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1523,112 +1523,5 @@
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"error": {"type": "String"}
|
"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."
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4279,305 +4279,6 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Install failed: {error}'**
|
/// **'Install failed: {error}'**
|
||||||
String installFailed(String 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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2502,197 +2502,4 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String installFailed(String error) {
|
String installFailed(String error) {
|
||||||
return 'Installation fehlgeschlagen: $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.';
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2506,196 +2506,4 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String installFailed(String error) {
|
String installFailed(String error) {
|
||||||
return 'Install failed: $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.';
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
147
lib/main.dart
147
lib/main.dart
|
|
@ -16,7 +16,6 @@ import 'l10n/app_localizations.dart';
|
||||||
import 'pages/approvals.dart';
|
import 'pages/approvals.dart';
|
||||||
import 'pages/audit.dart';
|
import 'pages/audit.dart';
|
||||||
import 'pages/doctor.dart';
|
import 'pages/doctor.dart';
|
||||||
import 'pages/federation.dart';
|
|
||||||
import 'pages/flows.dart';
|
import 'pages/flows.dart';
|
||||||
import 'pages/store.dart';
|
import 'pages/store.dart';
|
||||||
import 'pages/welcome.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
|
/// Studio's own build version. Bump on every UI commit so the
|
||||||
/// running app self-identifies — visible in the sidebar header
|
/// running app self-identifies — visible in the sidebar header
|
||||||
/// and quick-glance proof that you're seeing the current build.
|
/// and quick-glance proof that you're seeing the current build.
|
||||||
const String kStudioVersion = '0.70.0';
|
const String kStudioVersion = '0.69.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -36,7 +35,6 @@ Future<void> main() async {
|
||||||
// (when installed) the operator's theme-plugin choice
|
// (when installed) the operator's theme-plugin choice
|
||||||
// before the first frame so nothing flickers on startup.
|
// before the first frame so nothing flickers on startup.
|
||||||
await HubService.instance.loadPersistedEndpoint();
|
await HubService.instance.loadPersistedEndpoint();
|
||||||
await SystemActions.loadFaiBinaryOverride();
|
|
||||||
final themeMode = await HubService.instance.loadThemeMode();
|
final themeMode = await HubService.instance.loadThemeMode();
|
||||||
final locale = await HubService.instance.loadLocale();
|
final locale = await HubService.instance.loadLocale();
|
||||||
final themePlugin = await loadActiveThemePlugin();
|
final themePlugin = await loadActiveThemePlugin();
|
||||||
|
|
@ -247,42 +245,6 @@ class StudioShellState extends State<StudioShell> {
|
||||||
String? _activeChannel; // local / dev / beta / production
|
String? _activeChannel; // local / dev / beta / production
|
||||||
Timer? _healthPoll;
|
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<void> 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
|
/// Switch the sidebar's selected page by its registered id
|
||||||
/// (`store`, `flows`, `audit`, etc.). Used by the welcome
|
/// (`store`, `flows`, `audit`, etc.). Used by the welcome
|
||||||
/// page's onboarding checklist to make each item a real
|
/// page's onboarding checklist to make each item a real
|
||||||
|
|
@ -349,12 +311,6 @@ class StudioShellState extends State<StudioShell> {
|
||||||
selectedIcon: Icons.inbox,
|
selectedIcon: Icons.inbox,
|
||||||
page: ApprovalsPage(),
|
page: ApprovalsPage(),
|
||||||
),
|
),
|
||||||
_NavPage(
|
|
||||||
id: 'federation',
|
|
||||||
icon: Icons.hub_outlined,
|
|
||||||
selectedIcon: Icons.hub,
|
|
||||||
page: FederationPage(),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Pending-approval count surfaced as a sidebar badge so the
|
/// Pending-approval count surfaced as a sidebar badge so the
|
||||||
|
|
@ -375,17 +331,7 @@ class StudioShellState extends State<StudioShell> {
|
||||||
Future<void> _checkHealth() async {
|
Future<void> _checkHealth() async {
|
||||||
final ok = await HubService.instance.healthy();
|
final ok = await HubService.instance.healthy();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final wasUnreachable = _hubUnreachable;
|
if (_connected != ok) setState(() => _connected = ok);
|
||||||
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 (ok) {
|
if (ok) {
|
||||||
try {
|
try {
|
||||||
final snap = await HubService.instance.channelStatus();
|
final snap = await HubService.instance.channelStatus();
|
||||||
|
|
@ -507,14 +453,6 @@ class StudioShellState extends State<StudioShell> {
|
||||||
pendingApprovals: _pendingApprovals,
|
pendingApprovals: _pendingApprovals,
|
||||||
),
|
),
|
||||||
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
if (_hubUnreachable)
|
|
||||||
_HubUnreachableBanner(
|
|
||||||
endpoint: HubService.instance.endpointLabel,
|
|
||||||
onOpenSettings: () => FaiSettingsDialog.show(context),
|
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: FaiMotion.base,
|
duration: FaiMotion.base,
|
||||||
|
|
@ -539,61 +477,6 @@ class StudioShellState extends State<StudioShell> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -670,12 +553,7 @@ class _SidebarState extends State<_Sidebar>
|
||||||
super.initState();
|
super.initState();
|
||||||
_ctrl = AnimationController(
|
_ctrl = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
// Expand uses the slow (~320 ms) token: the icon→label
|
duration: FaiMotion.fast,
|
||||||
// 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,
|
|
||||||
value: 0,
|
value: 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -687,14 +565,14 @@ class _SidebarState extends State<_Sidebar>
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startDaemon(BuildContext context) async {
|
Future<void> _startDaemon(BuildContext context) async {
|
||||||
final l = AppLocalizations.of(context)!;
|
|
||||||
final r = await SystemActions.faiDaemon(['start']);
|
final r = await SystemActions.faiDaemon(['start']);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
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
|
// the left. The bg highlight spans the rail's
|
||||||
// current width (typical nav-rail behaviour).
|
// current width (typical nav-rail behaviour).
|
||||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(color: bg),
|
||||||
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,
|
|
||||||
),
|
|
||||||
child: content,
|
child: content,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1404,8 +1273,6 @@ class _NavPage {
|
||||||
return l.navAudit;
|
return l.navAudit;
|
||||||
case 'approvals':
|
case 'approvals':
|
||||||
return l.navApprovals;
|
return l.navApprovals;
|
||||||
case 'federation':
|
|
||||||
return l.navFederation;
|
|
||||||
default:
|
default:
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -973,7 +973,7 @@ class _ExplanationPanel extends StatelessWidget {
|
||||||
: theme.colorScheme.error,
|
: theme.colorScheme.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (result!.fixHint(l).isNotEmpty) ...[
|
if (result!.fixHint.isNotEmpty) ...[
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
Text(
|
Text(
|
||||||
l.auditFixLabel,
|
l.auditFixLabel,
|
||||||
|
|
@ -984,7 +984,7 @@ class _ExplanationPanel extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
result!.fixHint(l),
|
result!.fixHint,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -402,15 +402,7 @@ class _PathRow extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final lower = path.toLowerCase();
|
final isLog = !isDirectory && path.toLowerCase().endsWith('.log');
|
||||||
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'));
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -436,11 +428,10 @@ class _PathRow extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: FaiSpace.sm),
|
const SizedBox(width: FaiSpace.sm),
|
||||||
if (isLog || isConfig) ...[
|
if (isLog) ...[
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => isConfig
|
onPressed: () =>
|
||||||
? showFaiConfigViewer(context, path: path, title: label)
|
showFaiLogViewer(context, path: path, title: label),
|
||||||
: showFaiLogViewer(context, path: path, title: label),
|
|
||||||
icon: const Icon(Icons.visibility_outlined, size: 14),
|
icon: const Icon(Icons.visibility_outlined, size: 14),
|
||||||
label: Text(l.buttonView),
|
label: Text(l.buttonView),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
|
|
@ -491,11 +482,6 @@ class _DaemonActionsCard extends StatefulWidget {
|
||||||
class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
String? _output;
|
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;
|
ChannelStatusSnapshot? _channels;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -525,16 +511,10 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
||||||
final r = await action();
|
final r = await action();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final binaryMissing = !r.ok && r.stderr.trim() == kFaiBinaryNotFound;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_busy = false;
|
_busy = false;
|
||||||
_binaryMissing = binaryMissing;
|
_output =
|
||||||
// When the binary is missing we render the actionable
|
(r.ok
|
||||||
// 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.daemonActionResultOk(label)}\n${r.stdout}'
|
||||||
: '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}')
|
: '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
@ -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) ...[
|
if (_output != null) ...[
|
||||||
const SizedBox(height: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
Container(
|
Container(
|
||||||
|
|
|
||||||
|
|
@ -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<FederationPage> createState() => _FederationPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FederationPageState extends State<FederationPage> {
|
|
||||||
late Future<List<Satellite>> _future;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _refresh() {
|
|
||||||
setState(() {
|
|
||||||
_future = HubService.instance.listSatellites();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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<void>(
|
|
||||||
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<String?> _promptName(BuildContext context) async {
|
|
||||||
final controller = TextEditingController();
|
|
||||||
final l = AppLocalizations.of(context)!;
|
|
||||||
return showDialog<String>(
|
|
||||||
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<List<Satellite>>(
|
|
||||||
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)));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1792,7 +1792,7 @@ class _FeaturedTile extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StoreCard extends StatefulWidget {
|
class _StoreCard extends StatelessWidget {
|
||||||
final StoreItem item;
|
final StoreItem item;
|
||||||
final String locale;
|
final String locale;
|
||||||
|
|
||||||
|
|
@ -1810,19 +1810,6 @@ class _StoreCard extends StatefulWidget {
|
||||||
required this.onInstall,
|
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
|
/// True iff the operator has this module installed AND the
|
||||||
/// store-index offers a strictly-newer best_version. Uses a
|
/// store-index offers a strictly-newer best_version. Uses a
|
||||||
/// dotted-numeric semver-light compare so 0.10.5 > 0.9.99.
|
/// dotted-numeric semver-light compare so 0.10.5 > 0.9.99.
|
||||||
|
|
@ -1849,22 +1836,6 @@ class _StoreCardState extends State<_StoreCard> {
|
||||||
final notInstallable = !installable && !item.installed;
|
final notInstallable = !installable && !item.installed;
|
||||||
return Opacity(
|
return Opacity(
|
||||||
opacity: notInstallable ? 0.6 : 1.0,
|
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(
|
child: Material(
|
||||||
color: theme.colorScheme.surfaceContainer,
|
color: theme.colorScheme.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
|
|
@ -1905,10 +1876,7 @@ class _StoreCardState extends State<_StoreCard> {
|
||||||
Text(
|
Text(
|
||||||
item.category.isEmpty
|
item.category.isEmpty
|
||||||
? '—'
|
? '—'
|
||||||
: _categoryDisplayName(
|
: _categoryDisplayName(context, item.category),
|
||||||
context,
|
|
||||||
item.category,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
|
@ -2012,8 +1980,6 @@ class _StoreCardState extends State<_StoreCard> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2037,7 +2003,6 @@ class _StoreDetailSheet extends StatefulWidget {
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
elevation: 8,
|
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,6 @@ class WelcomePage extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.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(
|
return Scaffold(
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
appBar: AppBar(titleSpacing: FaiSpace.xl, title: Text(l.navWelcome)),
|
appBar: AppBar(titleSpacing: FaiSpace.xl, title: Text(l.navWelcome)),
|
||||||
|
|
@ -57,27 +52,22 @@ class WelcomePage extends StatelessWidget {
|
||||||
constraints: const BoxConstraints(maxWidth: 960),
|
constraints: const BoxConstraints(maxWidth: 960),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: const [
|
||||||
const _Hero(),
|
_Hero(),
|
||||||
const SizedBox(height: FaiSpace.xxl),
|
SizedBox(height: FaiSpace.xxl),
|
||||||
// When the hub is down the onboarding checklist
|
// Checklist comes second — operator-actionable
|
||||||
// would render dead (all-unchecked, nothing to
|
// path beats educational content. Auto-hides
|
||||||
// probe). Lead with a "start the hub" card
|
// once the operator dismisses it.
|
||||||
// instead; restore the checklist once connected.
|
_OnboardingChecklist(),
|
||||||
if (hubDown) ...[
|
_PillarRow(),
|
||||||
const _HubDownHero(),
|
SizedBox(height: FaiSpace.xxl),
|
||||||
const SizedBox(height: FaiSpace.xxl),
|
_SectionLabel(textKey: _SectionLabelKey.trust),
|
||||||
] else
|
SizedBox(height: FaiSpace.md),
|
||||||
const _OnboardingChecklist(),
|
_TrustPosture(),
|
||||||
const _PillarRow(),
|
SizedBox(height: FaiSpace.xxl),
|
||||||
const SizedBox(height: FaiSpace.xxl),
|
_SectionLabel(textKey: _SectionLabelKey.docs),
|
||||||
const _SectionLabel(textKey: _SectionLabelKey.trust),
|
SizedBox(height: FaiSpace.md),
|
||||||
const SizedBox(height: FaiSpace.md),
|
_DocsRow(),
|
||||||
const _TrustPosture(),
|
|
||||||
const SizedBox(height: FaiSpace.xxl),
|
|
||||||
const _SectionLabel(textKey: _SectionLabelKey.docs),
|
|
||||||
const SizedBox(height: FaiSpace.md),
|
|
||||||
const _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<void> _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 }
|
enum _SectionLabelKey { trust, docs }
|
||||||
|
|
||||||
class _SectionLabel extends StatelessWidget {
|
class _SectionLabel extends StatelessWidget {
|
||||||
|
|
@ -1021,37 +904,15 @@ class _DocsRow extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DocCard extends StatefulWidget {
|
class _DocCard extends StatelessWidget {
|
||||||
final _DocEntry entry;
|
final _DocEntry entry;
|
||||||
const _DocCard({required this.entry});
|
const _DocCard({required this.entry});
|
||||||
|
|
||||||
@override
|
|
||||||
State<_DocCard> createState() => _DocCardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DocCardState extends State<_DocCard> {
|
|
||||||
bool _hovered = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final entry = widget.entry;
|
return Material(
|
||||||
// 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,
|
color: theme.colorScheme.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
|
@ -1098,8 +959,6 @@ class _DocCardState extends State<_DocCard> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1124,15 +983,6 @@ class _DocReaderError {
|
||||||
String get forgejoUrl =>
|
String get forgejoUrl =>
|
||||||
'https://git.flemming.ai/fai/platform/src/branch/main/docs/architecture/$slug.md';
|
'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
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'Could not load the bundled doc "$slug" ($locale).\n'
|
'Could not load the bundled doc "$slug" ($locale).\n'
|
||||||
|
|
@ -1156,7 +1006,6 @@ class _DocReaderSheet extends StatefulWidget {
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
elevation: 8,
|
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
||||||
),
|
),
|
||||||
|
|
@ -1286,18 +1135,7 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
if (structured != null)
|
FaiErrorBox(error: err, isError: true, maxHeight: 200),
|
||||||
FaiErrorBox(
|
|
||||||
text: structured.localizedBody(l),
|
|
||||||
isError: true,
|
|
||||||
maxHeight: 200,
|
|
||||||
)
|
|
||||||
else
|
|
||||||
FaiErrorBox(
|
|
||||||
error: err,
|
|
||||||
isError: true,
|
|
||||||
maxHeight: 200,
|
|
||||||
),
|
|
||||||
if (structured != null) ...[
|
if (structured != null) ...[
|
||||||
const SizedBox(height: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
|
|
|
||||||
|
|
@ -2,75 +2,55 @@
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import 'tokens.dart';
|
import 'tokens.dart';
|
||||||
|
|
||||||
class FaiTheme {
|
class FaiTheme {
|
||||||
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,
|
/// 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) {
|
static TextTheme _textTheme(Brightness b) {
|
||||||
final base = b == Brightness.dark
|
final base = b == Brightness.dark
|
||||||
? Typography.whiteCupertino
|
? Typography.whiteCupertino
|
||||||
: Typography.blackCupertino;
|
: Typography.blackCupertino;
|
||||||
return base.apply(fontFamily: _uiFamily).copyWith(
|
return GoogleFonts.interTextTheme(base).copyWith(
|
||||||
displaySmall: const TextStyle(
|
displaySmall: GoogleFonts.inter(
|
||||||
fontFamily: _uiFamily,
|
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: -0.4,
|
letterSpacing: -0.4,
|
||||||
),
|
),
|
||||||
headlineSmall: const TextStyle(
|
headlineSmall: GoogleFonts.inter(
|
||||||
fontFamily: _uiFamily,
|
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: -0.2,
|
letterSpacing: -0.2,
|
||||||
),
|
),
|
||||||
titleMedium: const TextStyle(
|
titleMedium: GoogleFonts.inter(
|
||||||
fontFamily: _uiFamily,
|
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
),
|
),
|
||||||
titleSmall: const TextStyle(
|
titleSmall: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500),
|
||||||
fontFamily: _uiFamily,
|
bodyLarge: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
bodyLarge: const TextStyle(
|
|
||||||
fontFamily: _uiFamily,
|
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
height: 1.45,
|
height: 1.45,
|
||||||
),
|
),
|
||||||
bodyMedium: const TextStyle(
|
bodyMedium: GoogleFonts.inter(
|
||||||
fontFamily: _uiFamily,
|
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
bodySmall: const TextStyle(
|
bodySmall: GoogleFonts.inter(
|
||||||
fontFamily: _uiFamily,
|
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
labelMedium: const TextStyle(
|
labelMedium: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w500),
|
||||||
fontFamily: _uiFamily,
|
labelSmall: GoogleFonts.inter(
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
labelSmall: const TextStyle(
|
|
||||||
fontFamily: _uiFamily,
|
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
letterSpacing: 0.2,
|
letterSpacing: 0.2,
|
||||||
|
|
@ -79,14 +59,12 @@ class FaiTheme {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JetBrains Mono for monospaced technical content. Used by
|
/// JetBrains Mono for monospaced technical content. Used by
|
||||||
/// FaiMono helper. Only Regular (400) and Medium (500) weights
|
/// FaiMono helper.
|
||||||
/// are bundled — heavier requests fall back to the nearest.
|
|
||||||
static TextStyle mono({
|
static TextStyle mono({
|
||||||
double size = 12,
|
double size = 12,
|
||||||
FontWeight weight = FontWeight.w400,
|
FontWeight weight = FontWeight.w400,
|
||||||
Color? color,
|
Color? color,
|
||||||
}) => TextStyle(
|
}) => GoogleFonts.jetBrainsMono(
|
||||||
fontFamily: _monoFamily,
|
|
||||||
fontSize: size,
|
fontSize: size,
|
||||||
fontWeight: weight,
|
fontWeight: weight,
|
||||||
color: color,
|
color: color,
|
||||||
|
|
@ -243,8 +221,8 @@ class FaiTheme {
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
// Bake the foreground colour straight into the title
|
// Bake the foreground colour straight into the title
|
||||||
// style. `headlineSmall` is built from the bundled Inter
|
// style. `headlineSmall` is built from GoogleFonts.inter
|
||||||
// family with no color slot, and Material's "merge foreground
|
// with no color slot, and Material's "merge foreground
|
||||||
// colour at draw time" path leaves it null in some
|
// colour at draw time" path leaves it null in some
|
||||||
// light-mode builds — the title rendered white-on-white.
|
// light-mode builds — the title rendered white-on-white.
|
||||||
titleTextStyle: textTheme.headlineSmall?.copyWith(
|
titleTextStyle: textTheme.headlineSmall?.copyWith(
|
||||||
|
|
|
||||||
|
|
@ -61,50 +61,6 @@ class FaiRadius {
|
||||||
static const md = 10.0;
|
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<BoxShadow> 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<BoxShadow> 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.
|
/// Animation durations. Keep them under 300ms for power-user feel.
|
||||||
class FaiMotion {
|
class FaiMotion {
|
||||||
FaiMotion._();
|
FaiMotion._();
|
||||||
|
|
|
||||||
|
|
@ -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<void> _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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +1,9 @@
|
||||||
// FaiEmptyState — gracious empty / loading / error placeholder.
|
// FaiEmptyState — gracious empty / loading / error placeholder.
|
||||||
// Replaces "(no data)" strings with one tone, one icon, one tip.
|
// 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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
import 'fai_delta_mark.dart';
|
|
||||||
|
|
||||||
class FaiEmptyState extends StatelessWidget {
|
class FaiEmptyState extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|
@ -38,67 +30,40 @@ class FaiEmptyState extends StatelessWidget {
|
||||||
final color = iconColor ?? theme.colorScheme.onSurfaceVariant;
|
final color = iconColor ?? theme.colorScheme.onSurfaceVariant;
|
||||||
return Center(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 440),
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(FaiSpace.xxl),
|
padding: const EdgeInsets.all(FaiSpace.xxl),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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(
|
Container(
|
||||||
width: 60,
|
width: 56,
|
||||||
height: 60,
|
height: 56,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
color: color.withValues(alpha: 0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
|
||||||
color: color.withValues(alpha: 0.18),
|
|
||||||
),
|
),
|
||||||
boxShadow: FaiElevation.low(theme.brightness),
|
child: Icon(icon, size: 24, color: color),
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 26, color: color),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: FaiSpace.xl),
|
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium,
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (hint != null) ...[
|
if (hint != null) ...[
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
Text(
|
Text(
|
||||||
hint!,
|
hint!,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
height: 1.5,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (action != null) ...[
|
if (action != null) ...[
|
||||||
const SizedBox(height: FaiSpace.xl),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
action!,
|
action!,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,6 @@ Future<void> showFaiLogViewer(
|
||||||
required String path,
|
required String path,
|
||||||
String? title,
|
String? title,
|
||||||
int tailLines = 500,
|
int tailLines = 500,
|
||||||
bool fromTop = false,
|
|
||||||
bool plain = false,
|
|
||||||
}) {
|
}) {
|
||||||
return showDialog<void>(
|
return showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -46,51 +44,20 @@ Future<void> showFaiLogViewer(
|
||||||
path: path,
|
path: path,
|
||||||
title: title ?? path.split(Platform.pathSeparator).last,
|
title: title ?? path.split(Platform.pathSeparator).last,
|
||||||
tailLines: tailLines,
|
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<void> 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 {
|
class FaiLogViewer extends StatefulWidget {
|
||||||
final String path;
|
final String path;
|
||||||
final String title;
|
final String title;
|
||||||
final int tailLines;
|
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({
|
const FaiLogViewer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.tailLines = 500,
|
this.tailLines = 500,
|
||||||
this.fromTop = false,
|
|
||||||
this.plain = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -110,11 +77,7 @@ class _FaiLogViewerState extends State<FaiLogViewer> {
|
||||||
|
|
||||||
Future<void> _reload() async {
|
Future<void> _reload() async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
final lines = await _readLines(
|
final lines = await _readTail(widget.path, widget.tailLines);
|
||||||
widget.path,
|
|
||||||
widget.tailLines,
|
|
||||||
fromTop: widget.fromTop,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_lines = lines;
|
_lines = lines;
|
||||||
|
|
@ -178,7 +141,7 @@ class _FaiLogViewerState extends State<FaiLogViewer> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: _LogBody(lines: _lines, plain: widget.plain),
|
: _LogBody(lines: _lines),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -286,9 +249,8 @@ class _Header extends StatelessWidget {
|
||||||
|
|
||||||
class _LogBody extends StatelessWidget {
|
class _LogBody extends StatelessWidget {
|
||||||
final List<String> lines;
|
final List<String> lines;
|
||||||
final bool plain;
|
|
||||||
|
|
||||||
const _LogBody({required this.lines, this.plain = false});
|
const _LogBody({required this.lines});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -303,7 +265,6 @@ class _LogBody extends StatelessWidget {
|
||||||
text: lines[i],
|
text: lines[i],
|
||||||
gutterWidth: gutterWidth,
|
gutterWidth: gutterWidth,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
plain: plain,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -315,14 +276,12 @@ class _LogLine extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
final double gutterWidth;
|
final double gutterWidth;
|
||||||
final ThemeData theme;
|
final ThemeData theme;
|
||||||
final bool plain;
|
|
||||||
|
|
||||||
const _LogLine({
|
const _LogLine({
|
||||||
required this.lineNumber,
|
required this.lineNumber,
|
||||||
required this.text,
|
required this.text,
|
||||||
required this.gutterWidth,
|
required this.gutterWidth,
|
||||||
required this.theme,
|
required this.theme,
|
||||||
this.plain = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -348,12 +307,7 @@ class _LogLine extends StatelessWidget {
|
||||||
const SizedBox(width: FaiSpace.sm),
|
const SizedBox(width: FaiSpace.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SelectableText.rich(
|
child: SelectableText.rich(
|
||||||
plain
|
_highlightLine(text, theme),
|
||||||
? TextSpan(
|
|
||||||
text: text,
|
|
||||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
|
||||||
)
|
|
||||||
: _highlightLine(text, theme),
|
|
||||||
style: FaiTheme.mono(
|
style: FaiTheme.mono(
|
||||||
size: 11,
|
size: 11,
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.onSurface,
|
||||||
|
|
@ -466,43 +420,22 @@ TextSpan _highlightTraceLine(String line, ThemeData theme) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Matches ANSI / VT100 escape sequences (colour codes, cursor
|
/// Read the last [maxLines] of [path]. Returns an empty list on
|
||||||
/// moves) plus stray control characters. Daemon logs written to a
|
/// any I/O failure — the viewer renders an empty-state hint in
|
||||||
/// file by older `fai` builds carried `\x1b[..m` colour codes that
|
/// that case rather than throwing.
|
||||||
/// rendered as garbled "special characters" in the viewer; we
|
Future<List<String>> _readTail(String path, int maxLines) async {
|
||||||
/// 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<List<String>> _readLines(
|
|
||||||
String path,
|
|
||||||
int maxLines, {
|
|
||||||
bool fromTop = false,
|
|
||||||
}) async {
|
|
||||||
try {
|
try {
|
||||||
final f = File(path);
|
final f = File(path);
|
||||||
if (!await f.exists()) return const <String>[];
|
if (!await f.exists()) return const <String>[];
|
||||||
final raw = await f.readAsString();
|
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
|
// Drop the trailing blank line that `split` leaves behind
|
||||||
// when the file ends with `\n`.
|
// when the file ends with `\n`.
|
||||||
if (lines.isNotEmpty && lines.last.isEmpty) {
|
if (lines.isNotEmpty && lines.last.isEmpty) {
|
||||||
lines.removeLast();
|
lines.removeLast();
|
||||||
}
|
}
|
||||||
if (lines.length <= maxLines) return lines;
|
if (lines.length <= maxLines) return lines;
|
||||||
return fromTop
|
return lines.sublist(lines.length - maxLines);
|
||||||
? lines.sublist(0, maxLines)
|
|
||||||
: lines.sublist(lines.length - maxLines);
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return const <String>[];
|
return const <String>[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ class FaiModuleSheet extends StatefulWidget {
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
elevation: 8,
|
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -293,12 +293,11 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
});
|
});
|
||||||
final r = await SystemActions.faiChannelSwitch(name);
|
final r = await SystemActions.faiChannelSwitch(name);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context)!;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_saving = false;
|
_saving = false;
|
||||||
_channelToast = r.ok
|
_channelToast = r.ok
|
||||||
? '${l.channelSwitchOk(name)}\n${r.stdout.trim()}'
|
? 'Switched active channel to "$name". Daemon restarted.\n${r.stdout.trim()}'
|
||||||
: l.channelSwitchFailed(r.stderr.isEmpty ? r.stdout : r.stderr);
|
: 'Channel switch failed: ${r.stderr.isEmpty ? r.stdout : r.stderr}';
|
||||||
});
|
});
|
||||||
if (r.ok) await _loadChannels();
|
if (r.ok) await _loadChannels();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,26 +86,6 @@ class _ProviderPreset {
|
||||||
static _ProviderPreset byWire(String wire) {
|
static _ProviderPreset byWire(String wire) {
|
||||||
return all.firstWhere((p) => p.wire == wire, orElse: () => all.first);
|
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 {
|
class FaiSystemAiEditor extends StatefulWidget {
|
||||||
|
|
@ -392,7 +372,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
||||||
_ProviderDropdown(value: _preset, onChanged: _onProviderChanged),
|
_ProviderDropdown(value: _preset, onChanged: _onProviderChanged),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
_preset.descriptionFor(l),
|
_preset.description,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
@ -654,10 +634,10 @@ class _TestResultPanel extends StatelessWidget {
|
||||||
ok ? l.systemAiReplyPrefix(result.text) : result.text,
|
ok ? l.systemAiReplyPrefix(result.text) : result.text,
|
||||||
style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface),
|
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),
|
const SizedBox(height: FaiSpace.xs),
|
||||||
Text(
|
Text(
|
||||||
result.fixHint(l),
|
result.fixHint,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
// primitives directly — no Material `Card` / generic `Container`
|
// primitives directly — no Material `Card` / generic `Container`
|
||||||
// soup in page code.
|
// soup in page code.
|
||||||
|
|
||||||
export 'fai_binary_recovery.dart';
|
|
||||||
export 'fai_card.dart';
|
export 'fai_card.dart';
|
||||||
export 'fai_data_row.dart';
|
export 'fai_data_row.dart';
|
||||||
export 'fai_delta_mark.dart';
|
export 'fai_delta_mark.dart';
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|
|
||||||
106
pubspec.lock
106
pubspec.lock
|
|
@ -57,6 +57,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
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:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -227,6 +235,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.0"
|
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:
|
google_identity_services_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -267,6 +283,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.3"
|
version: "2.2.3"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -299,6 +323,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.20.2"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -339,6 +379,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.1.0"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
markdown:
|
markdown:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -379,6 +427,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
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:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -387,6 +451,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -443,6 +531,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
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:
|
scrollable_positioned_list:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -698,4 +802,4 @@ packages:
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.0 <4.0.0"
|
dart: ">=3.11.0 <4.0.0"
|
||||||
flutter: ">=3.38.0"
|
flutter: ">=3.38.4"
|
||||||
|
|
|
||||||
24
pubspec.yaml
24
pubspec.yaml
|
|
@ -1,7 +1,7 @@
|
||||||
name: fai_studio
|
name: fai_studio
|
||||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.70.0
|
version: 0.69.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
@ -19,6 +19,7 @@ dependencies:
|
||||||
# Sibling package; will move to Forgejo path once published.
|
# Sibling package; will move to Forgejo path once published.
|
||||||
fai_client_sdk:
|
fai_client_sdk:
|
||||||
path: ../fai_client_sdk_dart
|
path: ../fai_client_sdk_dart
|
||||||
|
google_fonts: ^8.1.0
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
# Inline README rendering inside the Store detail sheet.
|
# Inline README rendering inside the Store detail sheet.
|
||||||
# `flutter_markdown` is discontinued; `flutter_markdown_plus`
|
# `flutter_markdown` is discontinued; `flutter_markdown_plus`
|
||||||
|
|
@ -67,24 +68,3 @@ flutter:
|
||||||
# Air-gap-friendly — no network calls to read these.
|
# Air-gap-friendly — no network calls to read these.
|
||||||
assets:
|
assets:
|
||||||
- assets/docs/
|
- 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
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue