chain-studio/lib/pages/flows.dart
flemming-it f90d8cc7a7 feat(studio): typed run-flow form, file picker, copyable errors (v0.41.0)
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 <stefan.a.flemming@googlemail.com>
2026-05-09 10:18:08 +02:00

641 lines
20 KiB
Dart

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';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class FlowsPage extends StatefulWidget {
const FlowsPage({super.key});
@override
State<FlowsPage> createState() => _FlowsPageState();
}
class _FlowsPageState extends State<FlowsPage> {
late Future<List<SavedFlow>> _future;
@override
void initState() {
super.initState();
_future = HubService.instance.listFlows();
}
void _refresh() => setState(() {
_future = HubService.instance.listFlows();
});
Future<void> _runFlow(SavedFlow flow) async {
final inputs = await _FlowInputDialog.show(context, flow);
if (inputs == null) return;
if (!mounted) return;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _FlowRunDialog(
flow: flow,
textInputs: inputs.textInputs,
fileInputs: inputs.fileInputs,
),
);
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: Text(l.flowsTitle),
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: l.flowsReloadTooltip,
onPressed: _refresh,
),
const SizedBox(width: FaiSpace.sm),
],
),
body: FutureBuilder<List<SavedFlow>>(
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.of(context).colorScheme.error,
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
action: FilledButton.tonal(
onPressed: _refresh,
child: Text(l.buttonRetry),
),
);
}
final flows = snapshot.data ?? [];
if (flows.isEmpty) {
return FaiEmptyState(
icon: Icons.account_tree_outlined,
title: l.flowsNoneTitle,
hint: l.flowsNoneHint,
);
}
return ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: flows.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) =>
_FlowCard(flow: flows[i], onRun: () => _runFlow(flows[i])),
);
},
),
);
}
}
class _FlowCard extends StatelessWidget {
final SavedFlow flow;
final VoidCallback onRun;
const _FlowCard({required this.flow, required this.onRun});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FaiCard(
child: Row(
children: [
Icon(
Icons.account_tree_outlined,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: FaiSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
flow.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
flow.path,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
FaiPill(
label: '${flow.sizeBytes} B',
tone: FaiPillTone.neutral,
monospace: true,
),
const SizedBox(width: FaiSpace.md),
FilledButton.icon(
onPressed: onRun,
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(AppLocalizations.of(context)!.flowsRunButton),
),
],
),
);
}
}
/// 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<String, String> textInputs;
final Map<String, Uint8List> 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<_FlowRunInputs?> show(
BuildContext context,
SavedFlow flow,
) {
return showDialog<_FlowRunInputs>(
context: context,
builder: (_) => _FlowInputDialog(flow: flow),
);
}
@override
State<_FlowInputDialog> createState() => _FlowInputDialogState();
}
class _FlowInputDialogState extends State<_FlowInputDialog> {
late final Future<List<FlowInputDef>> _defFuture;
final Map<String, TextEditingController> _textControllers = {};
final Map<String, _PickedFile> _pickedFiles = {};
@override
void initState() {
super.initState();
_defFuture = HubService.instance.getFlowDefinition(widget.flow.name);
}
@override
void dispose() {
for (final c in _textControllers.values) {
c.dispose();
}
super.dispose();
}
bool _isBytesType(String type) => type == 'bytes' || type == 'file';
bool _isComplete(List<FlowInputDef> 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<FlowInputDef> defs) {
final textInputs = <String, String>{};
final fileInputs = <String, Uint8List>{};
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<void> _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<FlowInputDef> 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;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.flowsRunDialogTitle(widget.flow.name)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540, maxHeight: 540),
child: FutureBuilder<List<FlowInputDef>>(
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 <FlowInputDef>[];
_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),
],
],
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: Text(l.buttonCancel),
),
FutureBuilder<List<FlowInputDef>>(
future: _defFuture,
builder: (context, snap) {
final defs = snap.data ?? const <FlowInputDef>[];
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<String, String> textInputs;
final Map<String, Uint8List> fileInputs;
const _FlowRunDialog({
required this.flow,
required this.textInputs,
required this.fileInputs,
});
@override
State<_FlowRunDialog> createState() => _FlowRunDialogState();
}
class _FlowRunDialogState extends State<_FlowRunDialog> {
late final Future<Map<String, String>> _future;
@override
void initState() {
super.initState();
_future = HubService.instance.runSavedFlow(
name: widget.flow.name,
textInputs: widget.textInputs,
fileInputs: widget.fileInputs,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.flowsRunningTitle(widget.flow.name)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480),
child: FutureBuilder<Map<String, String>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: FaiSpace.md),
const CircularProgressIndicator(),
const SizedBox(height: FaiSpace.md),
Text(
l.flowsRunning,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
if (snapshot.hasError) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
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),
FaiErrorBox(
text: snapshot.error.toString(),
isError: true,
maxHeight: 280,
),
],
),
);
}
final outputs = snapshot.data ?? const {};
if (outputs.isEmpty) {
return Text(
l.flowsNoOutputs,
style: theme.textTheme.bodyMedium,
);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
for (final entry in outputs.entries) ...[
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
entry.key.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
),
child: SelectableText(
entry.value,
style: FaiTheme.mono(size: 11),
),
),
const SizedBox(height: FaiSpace.md),
],
],
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l.buttonClose),
),
],
);
}
}