From f90d8cc7a7385673c62e5c4e79da8b677d5ae43c Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 9 May 2026 10:18:08 +0200 Subject: [PATCH] feat(studio): typed run-flow form, file picker, copyable errors (v0.41.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The free-form key=value run-flow dialog produced two unhelpful failures every time an operator hit Run: 1. Submit empty → "step references missing value '\$inputs.document'" from the hub. Studio sent a zero- entry inputs map because nothing told the operator the flow declared a required input. 2. Type `document=@/path/to/file` → "path not found". Dart's `File()` doesn't expand `~`, and on macOS sandboxed Studio can't read arbitrary paths anyway. Both failure modes are gone. The dialog is now a typed form: - On open, fetches `getFlowDefinition(name)` (new SDK v0.15.0 wrapper around the v0.10.89 hub RPC). While that resolves, a small spinner shows "Loading inputs…"; on failure, an inline `FaiErrorBox` with the exact RPC error and a copy button replaces the spinner. - Renders one form-field per declared input. The flow YAML's verbatim type tag drives the widget choice: `bytes` / `file` → "Choose file…" button + picked-file readout, plain TextField for everything else. The type tag is shown next to the input name as a pill so a flow author who picks a less-common type still gets a hint. - Bytes inputs route through `file_picker` with `withData: true`, which means the OS file dialog handles read access — sandboxed Studio gets the bytes inline rather than a path it can't open. Falls back to `File(path).readAsBytes()` on Linux configs that don't honour `withData` for large files; failures show a copyable SnackBar. - The Run button enables only when every declared input has a value (text non-empty / file picked). Empty submission is impossible; the cryptic hub-side "missing value" error stops surfacing. - The run-result dialog's error path uses `FaiErrorBox` instead of the previous plain `SelectableText`, so any hub-side flow error (permission denied, module crash, whatever) is one click to clipboard. 11 new ARB keys cover the form's labels, the file-picker states, the loading / failed-definition messages, the "This flow declares no inputs" empty state, and the run-error dialog title. `file_picker: ^8.0.0` added to pubspec.yaml. Signed-off-by: flemming-it --- lib/data/hub.dart | 9 + lib/l10n/app_de.arb | 19 +- lib/l10n/app_en.arb | 19 +- lib/l10n/app_localizations.dart | 56 +++- lib/l10n/app_localizations_de.dart | 35 ++- lib/l10n/app_localizations_en.dart | 34 ++- lib/main.dart | 2 +- lib/pages/flows.dart | 397 ++++++++++++++++++++++------- pubspec.lock | 34 ++- pubspec.yaml | 8 +- 10 files changed, 508 insertions(+), 105 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index a46d1b7..4b9aa88 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -546,6 +546,15 @@ class HubService { ..sort((a, b) => a.name.compareTo(b.name)); } + /// Return the parsed input schema of a saved flow. Used by + /// Studio's Flows page to render a typed run-flow form + /// before calling [runSavedFlow]. Each entry's `type` is + /// the verbatim type tag from the flow YAML (`text`, + /// `bytes`, `json`, …). + Future> getFlowDefinition(String name) async { + return _client.getFlowDefinition(name); + } + /// Run a saved flow with the supplied inputs. Text values /// land as text Payloads, byte values as bytes Payloads — /// flows that mix both (e.g. extract taking a `document: diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b77e00a..820c473 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -389,8 +389,23 @@ "flowsNoneHint": "Flow speichern mit `fai admin flows save `, dann taucht er hier auf.", "flowsRunDialogTitle": "{name} ausführen", "@flowsRunDialogTitle": { "placeholders": { "name": { "type": "String" } } }, - "flowsRunDialogBody": "Eingaben als key=value, eine pro Zeile. Klartext-Werte landen als Text. Wert mit `@` voranstellen liest die Datei als Bytes — wie `fai run --input document=@/pfad/zu/datei.pdf`.", - "flowsInputsHint": "name=Welt\ndocument=@/Users/ich/Dokumente/beispiel.pdf\nstil=drei Stichpunkte", + "flowsRunDialogBody": "Pro deklariertem Input ein Feld. Bytes-Inputs öffnen einen Datei-Picker; alle anderen sind Klartext. Der Starten-Button aktiviert sich, wenn alle Pflichtfelder gefüllt sind.", + "flowsLoadingDefinition": "Inputs werden geladen…", + "flowsDefinitionFailed": "Flow-Definition konnte nicht geladen werden", + "flowsNoInputs": "Dieser Flow deklariert keine Inputs. Starten klicken, um loszulegen.", + "flowsTypeBytesPick": "Datei wählen…", + "flowsTypeBytesChange": "Datei ändern", + "flowsTypeBytesNoFile": "Keine Datei ausgewählt", + "flowsTypeBytesSize": "{n} Bytes", + "@flowsTypeBytesSize": { "placeholders": { "n": { "type": "int" } } }, + "flowsRunErrorTitle": "Flow-Lauf fehlgeschlagen", + "flowsFileReadFailed": "„{path}\" konnte nicht gelesen werden: {error}", + "@flowsFileReadFailed": { + "placeholders": { + "path": { "type": "String" }, + "error": { "type": "String" } + } + }, "flowsRunButton": "Starten", "flowsRunningTitle": "{name} läuft", "@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } }, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 719a020..0d64634 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -390,8 +390,23 @@ "flowsNoneHint": "Save a flow with `fai admin flows save ` and it shows up here.", "flowsRunDialogTitle": "Run {name}", "@flowsRunDialogTitle": { "placeholders": { "name": { "type": "String" } } }, - "flowsRunDialogBody": "Inputs as key=value, one per line. Plain values send as text. Prefix the value with `@` to read a file as bytes — same as `fai run --input document=@/path/to/file.pdf`.", - "flowsInputsHint": "name=World\ndocument=@/Users/me/Documents/sample.pdf\nstyle=three bullet points", + "flowsRunDialogBody": "One field per declared input. Pick a file for bytes-shaped inputs; type plain text for the rest. The Run button enables when every required input is set.", + "flowsLoadingDefinition": "Loading inputs…", + "flowsDefinitionFailed": "Could not load flow definition", + "flowsNoInputs": "This flow declares no inputs. Click Run to start.", + "flowsTypeBytesPick": "Choose file…", + "flowsTypeBytesChange": "Change file", + "flowsTypeBytesNoFile": "No file selected", + "flowsTypeBytesSize": "{n} bytes", + "@flowsTypeBytesSize": { "placeholders": { "n": { "type": "int" } } }, + "flowsRunErrorTitle": "Flow run failed", + "flowsFileReadFailed": "Could not read \"{path}\": {error}", + "@flowsFileReadFailed": { + "placeholders": { + "path": { "type": "String" }, + "error": { "type": "String" } + } + }, "flowsRunButton": "Run", "flowsRunningTitle": "Running {name}", "@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } }, diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ba573ea..43a8970 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1823,14 +1823,62 @@ abstract class AppLocalizations { /// No description provided for @flowsRunDialogBody. /// /// In en, this message translates to: - /// **'Inputs as key=value, one per line. Plain values send as text. Prefix the value with `@` to read a file as bytes — same as `fai run --input document=@/path/to/file.pdf`.'** + /// **'One field per declared input. Pick a file for bytes-shaped inputs; type plain text for the rest. The Run button enables when every required input is set.'** String get flowsRunDialogBody; - /// No description provided for @flowsInputsHint. + /// No description provided for @flowsLoadingDefinition. /// /// In en, this message translates to: - /// **'name=World\ndocument=@/Users/me/Documents/sample.pdf\nstyle=three bullet points'** - String get flowsInputsHint; + /// **'Loading inputs…'** + String get flowsLoadingDefinition; + + /// No description provided for @flowsDefinitionFailed. + /// + /// In en, this message translates to: + /// **'Could not load flow definition'** + String get flowsDefinitionFailed; + + /// No description provided for @flowsNoInputs. + /// + /// In en, this message translates to: + /// **'This flow declares no inputs. Click Run to start.'** + String get flowsNoInputs; + + /// No description provided for @flowsTypeBytesPick. + /// + /// In en, this message translates to: + /// **'Choose file…'** + String get flowsTypeBytesPick; + + /// No description provided for @flowsTypeBytesChange. + /// + /// In en, this message translates to: + /// **'Change file'** + String get flowsTypeBytesChange; + + /// No description provided for @flowsTypeBytesNoFile. + /// + /// In en, this message translates to: + /// **'No file selected'** + String get flowsTypeBytesNoFile; + + /// No description provided for @flowsTypeBytesSize. + /// + /// In en, this message translates to: + /// **'{n} bytes'** + String flowsTypeBytesSize(int n); + + /// No description provided for @flowsRunErrorTitle. + /// + /// In en, this message translates to: + /// **'Flow run failed'** + String get flowsRunErrorTitle; + + /// No description provided for @flowsFileReadFailed. + /// + /// In en, this message translates to: + /// **'Could not read \"{path}\": {error}'** + String flowsFileReadFailed(String path, String error); /// No description provided for @flowsRunButton. /// diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 1aed18e..34594e7 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1025,11 +1025,40 @@ class AppLocalizationsDe extends AppLocalizations { @override String get flowsRunDialogBody => - 'Eingaben als key=value, eine pro Zeile. Klartext-Werte landen als Text. Wert mit `@` voranstellen liest die Datei als Bytes — wie `fai run --input document=@/pfad/zu/datei.pdf`.'; + 'Pro deklariertem Input ein Feld. Bytes-Inputs öffnen einen Datei-Picker; alle anderen sind Klartext. Der Starten-Button aktiviert sich, wenn alle Pflichtfelder gefüllt sind.'; @override - String get flowsInputsHint => - 'name=Welt\ndocument=@/Users/ich/Dokumente/beispiel.pdf\nstil=drei Stichpunkte'; + String get flowsLoadingDefinition => 'Inputs werden geladen…'; + + @override + String get flowsDefinitionFailed => + 'Flow-Definition konnte nicht geladen werden'; + + @override + String get flowsNoInputs => + 'Dieser Flow deklariert keine Inputs. Starten klicken, um loszulegen.'; + + @override + String get flowsTypeBytesPick => 'Datei wählen…'; + + @override + String get flowsTypeBytesChange => 'Datei ändern'; + + @override + String get flowsTypeBytesNoFile => 'Keine Datei ausgewählt'; + + @override + String flowsTypeBytesSize(int n) { + return '$n Bytes'; + } + + @override + String get flowsRunErrorTitle => 'Flow-Lauf fehlgeschlagen'; + + @override + String flowsFileReadFailed(String path, String error) { + return '„$path\" konnte nicht gelesen werden: $error'; + } @override String get flowsRunButton => 'Starten'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ddf7a66..7f42dab 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1040,11 +1040,39 @@ class AppLocalizationsEn extends AppLocalizations { @override String get flowsRunDialogBody => - 'Inputs as key=value, one per line. Plain values send as text. Prefix the value with `@` to read a file as bytes — same as `fai run --input document=@/path/to/file.pdf`.'; + 'One field per declared input. Pick a file for bytes-shaped inputs; type plain text for the rest. The Run button enables when every required input is set.'; @override - String get flowsInputsHint => - 'name=World\ndocument=@/Users/me/Documents/sample.pdf\nstyle=three bullet points'; + String get flowsLoadingDefinition => 'Loading inputs…'; + + @override + String get flowsDefinitionFailed => 'Could not load flow definition'; + + @override + String get flowsNoInputs => + 'This flow declares no inputs. Click Run to start.'; + + @override + String get flowsTypeBytesPick => 'Choose file…'; + + @override + String get flowsTypeBytesChange => 'Change file'; + + @override + String get flowsTypeBytesNoFile => 'No file selected'; + + @override + String flowsTypeBytesSize(int n) { + return '$n bytes'; + } + + @override + String get flowsRunErrorTitle => 'Flow run failed'; + + @override + String flowsFileReadFailed(String path, String error) { + return 'Could not read \"$path\": $error'; + } @override String get flowsRunButton => 'Run'; diff --git a/lib/main.dart b/lib/main.dart index d95f640..eb67ec6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,7 +26,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.40.0'; +const String kStudioVersion = '0.41.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index c7a9187..77e2ce7 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'dart:typed_data'; +import 'package:fai_dart_sdk/fai_dart_sdk.dart' show FlowInputDef; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import '../data/hub.dart'; @@ -32,43 +34,14 @@ class _FlowsPageState extends State { Future _runFlow(SavedFlow flow) async { final inputs = await _FlowInputDialog.show(context, flow); if (inputs == null) return; - // Split the dialog's text-shaped key=value pairs into text - // vs file payloads. Values starting with `@` mean "treat - // the rest as a path; read the file as bytes" — same - // syntax `fai run --input` uses on the CLI. File-read - // failures bubble up as the run dialog's error state so - // the operator sees a useful message. - final textInputs = {}; - final fileInputs = {}; - String? readError; - for (final entry in inputs.entries) { - final value = entry.value; - if (value.startsWith('@')) { - final path = value.substring(1).trim(); - try { - fileInputs[entry.key] = await File(path).readAsBytes(); - } catch (e) { - readError = '${entry.key}: $e'; - break; - } - } else { - textInputs[entry.key] = value; - } - } if (!mounted) return; - if (readError != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(readError)), - ); - return; - } showDialog( context: context, barrierDismissible: false, builder: (_) => _FlowRunDialog( flow: flow, - textInputs: textInputs, - fileInputs: fileInputs, + textInputs: inputs.textInputs, + fileInputs: inputs.fileInputs, ), ); } @@ -184,16 +157,56 @@ class _FlowCard extends StatelessWidget { } } +/// Inputs collected by `_FlowInputDialog` and forwarded to +/// the run dialog. Text and file values arrive in separate +/// maps so the SDK can wrap each as the appropriate Payload +/// variant. +class _FlowRunInputs { + final Map textInputs; + final Map fileInputs; + const _FlowRunInputs({ + required this.textInputs, + required this.fileInputs, + }); +} + +class _PickedFile { + final String name; + final Uint8List bytes; + const _PickedFile({required this.name, required this.bytes}); +} + +/// Run-flow input dialog. Fetches the flow's declared input +/// schema first via `getFlowDefinition`, then renders one +/// type-aware field per declared input: +/// +/// * `bytes` / `file` → "Choose file" button + picked-file +/// readout. Routes through the OS +/// file dialog so sandboxed Studio +/// still gets read access to the +/// chosen file. `withData: true` so +/// the bytes flow through the +/// FilePicker result instead of a +/// path that may be unreadable. +/// * everything else → TextField. Opaque type tag is +/// shown as a pill next to the input +/// name so a flow author who picks +/// a less-common type still gets a +/// hint. +/// +/// The Run button enables only when every input has a value +/// — required-field validation happens client-side before +/// the hub sees an empty inputs map. class _FlowInputDialog extends StatefulWidget { final SavedFlow flow; const _FlowInputDialog({required this.flow}); - static Future?> show( + static Future<_FlowRunInputs?> show( BuildContext context, SavedFlow flow, ) { - return showDialog>( + return showDialog<_FlowRunInputs>( context: context, builder: (_) => _FlowInputDialog(flow: flow), ); @@ -204,28 +217,92 @@ class _FlowInputDialog extends StatefulWidget { } class _FlowInputDialogState extends State<_FlowInputDialog> { - // The hub does not yet expose the flow's input schema over - // gRPC, so this dialog accepts free-form key=value pairs. - // Operator pastes pairs separated by newlines; we split on - // the first `=` per line. - final _ctrl = TextEditingController(); + late final Future> _defFuture; + final Map _textControllers = {}; + final Map _pickedFiles = {}; + + @override + void initState() { + super.initState(); + _defFuture = HubService.instance.getFlowDefinition(widget.flow.name); + } @override void dispose() { - _ctrl.dispose(); + for (final c in _textControllers.values) { + c.dispose(); + } super.dispose(); } - Map _parsePairs() { - final out = {}; - for (final raw in _ctrl.text.split('\n')) { - final line = raw.trim(); - if (line.isEmpty) continue; - final eq = line.indexOf('='); - if (eq < 0) continue; - out[line.substring(0, eq).trim()] = line.substring(eq + 1).trim(); + bool _isBytesType(String type) => type == 'bytes' || type == 'file'; + + bool _isComplete(List defs) { + for (final d in defs) { + if (_isBytesType(d.type)) { + if (_pickedFiles[d.name] == null) return false; + } else { + final v = _textControllers[d.name]?.text.trim() ?? ''; + if (v.isEmpty) return false; + } + } + return true; + } + + _FlowRunInputs _collect(List defs) { + final textInputs = {}; + final fileInputs = {}; + for (final d in defs) { + if (_isBytesType(d.type)) { + final picked = _pickedFiles[d.name]; + if (picked != null) fileInputs[d.name] = picked.bytes; + } else { + final c = _textControllers[d.name]; + if (c != null) textInputs[d.name] = c.text; + } + } + return _FlowRunInputs(textInputs: textInputs, fileInputs: fileInputs); + } + + Future _pickFile(String inputName) async { + final result = await FilePicker.platform.pickFiles(withData: true); + if (result == null || result.files.isEmpty) return; + final f = result.files.first; + var bytes = f.bytes; + if (bytes == null && f.path != null) { + // Some Linux configurations don't honour `withData: true` + // for large files; fall back to reading via path. + try { + bytes = await File(f.path!).readAsBytes(); + } catch (e) { + if (!mounted) return; + final l = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.flowsFileReadFailed(f.path!, '$e'))), + ); + return; + } + } + if (bytes == null || !mounted) return; + setState(() { + _pickedFiles[inputName] = _PickedFile(name: f.name, bytes: bytes!); + }); + } + + /// Lazily ensure each text-shaped input has a controller + + /// a setState-triggering listener so the Run button reflects + /// validity as the operator types. + void _ensureTextControllers(List defs) { + for (final d in defs) { + if (_isBytesType(d.type)) continue; + _textControllers.putIfAbsent(d.name, () { + final c = TextEditingController(); + c.addListener(() { + if (mounted) setState(() {}); + }); + return c; + }); } - return out; } @override @@ -238,33 +315,77 @@ class _FlowInputDialogState extends State<_FlowInputDialog> { borderRadius: BorderRadius.circular(FaiRadius.md), ), content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.flowsRunDialogBody, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + constraints: const BoxConstraints(maxWidth: 540, maxHeight: 540), + child: FutureBuilder>( + future: _defFuture, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: FaiSpace.sm), + Text(l.flowsLoadingDefinition), + ], + ); + } + if (snap.hasError) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.flowsDefinitionFailed, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + const SizedBox(height: FaiSpace.sm), + FaiErrorBox( + text: snap.error.toString(), + isError: true, + maxHeight: 200, + ), + ], + ); + } + final defs = snap.data ?? const []; + _ensureTextControllers(defs); + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.flowsRunDialogBody, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.md), + if (defs.isEmpty) + Text( + l.flowsNoInputs, + style: theme.textTheme.bodyMedium, + ), + for (final d in defs) ...[ + _InputField( + def: d, + isBytes: _isBytesType(d.type), + textController: _textControllers[d.name], + pickedFile: _pickedFiles[d.name], + onPick: () => _pickFile(d.name), + ), + const SizedBox(height: FaiSpace.md), + ], + ], ), - ), - const SizedBox(height: FaiSpace.md), - Expanded( - child: TextField( - controller: _ctrl, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - style: FaiTheme.mono(size: 12), - decoration: InputDecoration( - hintText: l.flowsInputsHint, - border: const OutlineInputBorder(), - isDense: true, - ), - ), - ), - ], + ); + }, ), ), actions: [ @@ -272,16 +393,106 @@ class _FlowInputDialogState extends State<_FlowInputDialog> { onPressed: () => Navigator.pop(context, null), child: Text(l.buttonCancel), ), - FilledButton.icon( - icon: const Icon(Icons.play_arrow, size: 16), - label: Text(l.flowsRunButton), - onPressed: () => Navigator.pop(context, _parsePairs()), + FutureBuilder>( + future: _defFuture, + builder: (context, snap) { + final defs = snap.data ?? const []; + final canRun = snap.hasData && _isComplete(defs); + return FilledButton.icon( + icon: const Icon(Icons.play_arrow, size: 16), + label: Text(l.flowsRunButton), + onPressed: canRun + ? () => Navigator.pop(context, _collect(defs)) + : null, + ); + }, ), ], ); } } +class _InputField extends StatelessWidget { + final FlowInputDef def; + final bool isBytes; + final TextEditingController? textController; + final _PickedFile? pickedFile; + final VoidCallback onPick; + + const _InputField({ + required this.def, + required this.isBytes, + required this.textController, + required this.pickedFile, + required this.onPick, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + def.name, + style: FaiTheme.mono( + size: 12, + weight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(width: FaiSpace.sm), + FaiPill( + label: def.type, + tone: isBytes ? FaiPillTone.warning : FaiPillTone.neutral, + monospace: true, + ), + ], + ), + const SizedBox(height: 6), + if (isBytes) + Row( + children: [ + OutlinedButton.icon( + onPressed: onPick, + icon: const Icon(Icons.attach_file, size: 14), + label: Text( + pickedFile == null + ? l.flowsTypeBytesPick + : l.flowsTypeBytesChange, + ), + ), + const SizedBox(width: FaiSpace.md), + Expanded( + child: Text( + pickedFile == null + ? l.flowsTypeBytesNoFile + : '${pickedFile!.name} · ' + '${l.flowsTypeBytesSize(pickedFile!.bytes.length)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ) + else + TextField( + controller: textController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), + ), + ], + ); + } +} + class _FlowRunDialog extends StatefulWidget { final SavedFlow flow; final Map textInputs; @@ -346,18 +557,28 @@ class _FlowRunDialogState extends State<_FlowRunDialog> { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.error_outline, - color: theme.colorScheme.error, - size: 32, + Row( + children: [ + Icon( + Icons.error_outline, + color: theme.colorScheme.error, + size: 20, + ), + const SizedBox(width: FaiSpace.sm), + Text( + l.flowsRunErrorTitle, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], ), const SizedBox(height: FaiSpace.md), - SelectableText( - snapshot.error.toString(), - style: FaiTheme.mono( - size: 11, - color: theme.colorScheme.error, - ), + FaiErrorBox( + text: snapshot.error.toString(), + isError: true, + maxHeight: 280, ), ], ), diff --git a/pubspec.lock b/pubspec.lock index 8e6662d..842d845 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -79,7 +87,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.13.0" + version: "0.15.0" fake_async: dependency: transitive description: @@ -104,6 +112,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" fixnum: dependency: transitive description: @@ -138,6 +154,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7+1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_test: dependency: "direct dev" description: flutter @@ -601,6 +625,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a004f60..6526799 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.40.0 +version: 0.41.0 environment: sdk: ^3.11.0-200.1.beta @@ -25,6 +25,12 @@ dependencies: # ~/.fai/today/active.yaml — see docs/today-pipeline.md. yaml: ^3.1.2 path: ^1.9.0 + # System-native file picker — used by the Flows run-flow + # form for `bytes`-shaped inputs (extract takes a PDF, the + # operator clicks "Choose file" and macOS / Linux / Windows + # routes through the OS dialog so sandboxed Studio still + # gets read access to the chosen file). + file_picker: ^8.0.0 dev_dependencies: flutter_test: