When a saved flow is missing modules, each capability pill in the "Needs:" row is now clickable: tap one and a per-item progress dialog walks the bare capability name through installModule, then the flows page refreshes. When more than one capability is missing, an extra "Install all (N)" button runs the same dialog over the whole list sequentially so a fresh operator can take an unrunnable flow and one click later have its dependencies resolved. Also adds a pencil icon next to Run that hands the YAML path to the OS via SystemActions.openInOs — the operator's default editor for .yaml decides what opens. Makes the path next to the flow name actionable instead of decorative. The install dialog renders one row per spec with a status icon (pending circle / spinner / check / error), shows the installed version on success, and surfaces the full error in a copyable FaiErrorBox on failure so the operator can paste it back. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1099 lines
35 KiB
Dart
1099 lines
35 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 '../data/system_actions.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<_FlowsBundle> _future;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_future = _load();
|
|
}
|
|
|
|
/// Pulls the saved flows AND the installed-modules list in
|
|
/// parallel so the page can compute "is this flow runnable"
|
|
/// per row from the same data the Run button gates on.
|
|
Future<_FlowsBundle> _load() async {
|
|
final results = await Future.wait([
|
|
HubService.instance.listFlows(),
|
|
HubService.instance.listModules().catchError((_) => <ModuleSummary>[]),
|
|
]);
|
|
return _FlowsBundle(
|
|
flows: results[0] as List<SavedFlow>,
|
|
installedCapabilities: (results[1] as List<ModuleSummary>)
|
|
.expand((m) => m.capabilities)
|
|
.toSet(),
|
|
);
|
|
}
|
|
|
|
void _refresh() => setState(() {
|
|
_future = _load();
|
|
});
|
|
|
|
/// Returns the verbatim `use:` strings whose bare capability
|
|
/// name is not provided by any installed module. The bare
|
|
/// name is everything left of the first `@`; version
|
|
/// constraints are tolerated at this stage — the hub does
|
|
/// the full resolution at run time.
|
|
List<String> _missing(SavedFlow flow, Set<String> installed) {
|
|
final out = <String>[];
|
|
for (final cap in flow.requiredCapabilities) {
|
|
final at = cap.indexOf('@');
|
|
final name = at >= 0 ? cap.substring(0, at) : cap;
|
|
if (!installed.contains(name)) out.add(cap);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Walk through one or more capability specs (e.g.
|
|
/// `text.extract@^0`) sequentially, showing per-item progress.
|
|
/// Refreshes the flows page on dismiss so the row updates if
|
|
/// any install actually changed the installed-set.
|
|
Future<void> _installDeps(List<String> specs) async {
|
|
final changed = await showDialog<bool>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => _InstallDependenciesDialog(specs: specs),
|
|
);
|
|
if (changed == true && mounted) _refresh();
|
|
}
|
|
|
|
/// Hand the YAML file to the OS — `open` on macOS, `xdg-open`
|
|
/// on Linux, `start` on Windows. The operator's default editor
|
|
/// for `.yaml` decides what actually opens.
|
|
Future<void> _openInEditor(SavedFlow flow) async {
|
|
final r = await SystemActions.openInOs(flow.path);
|
|
if (!mounted || r.ok) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.flowsOpenInEditorFailed(r.stderr))),
|
|
);
|
|
}
|
|
|
|
@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<_FlowsBundle>(
|
|
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 bundle = snapshot.data ?? const _FlowsBundle.empty();
|
|
final flows = bundle.flows;
|
|
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) {
|
|
final flow = flows[i];
|
|
final missing = _missing(flow, bundle.installedCapabilities);
|
|
return _FlowCard(
|
|
flow: flow,
|
|
missingCapabilities: missing,
|
|
onRun: missing.isEmpty ? () => _runFlow(flow) : null,
|
|
onInstallSingle: (spec) => _installDeps([spec]),
|
|
onInstallAll: missing.length > 1
|
|
? () => _installDeps(missing)
|
|
: null,
|
|
onOpenInEditor: () => _openInEditor(flow),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Snapshot of everything the Flows page needs in one render
|
|
/// pass: the saved flows themselves and the set of installed
|
|
/// capability names. Stored as a single Future so the
|
|
/// FutureBuilder transitions atomically (no flicker between
|
|
/// "flows loaded but modules pending").
|
|
class _FlowsBundle {
|
|
final List<SavedFlow> flows;
|
|
final Set<String> installedCapabilities;
|
|
const _FlowsBundle({
|
|
required this.flows,
|
|
required this.installedCapabilities,
|
|
});
|
|
const _FlowsBundle.empty()
|
|
: flows = const [],
|
|
installedCapabilities = const {};
|
|
}
|
|
|
|
class _FlowCard extends StatelessWidget {
|
|
final SavedFlow flow;
|
|
final List<String> missingCapabilities;
|
|
/// Null when the flow can't run (missing modules); the
|
|
/// Run button greys out + a tooltip points the operator
|
|
/// at the install path. Non-null otherwise.
|
|
final VoidCallback? onRun;
|
|
/// Install a single capability spec (verbatim `use:` string,
|
|
/// version constraint included — the dialog strips the `@`
|
|
/// suffix before calling installModule).
|
|
final ValueChanged<String> onInstallSingle;
|
|
/// Install every missing capability in sequence. Null when
|
|
/// only one is missing (the single-install pill is enough).
|
|
final VoidCallback? onInstallAll;
|
|
/// Hand the flow YAML to the OS so the operator's default
|
|
/// editor opens it.
|
|
final VoidCallback onOpenInEditor;
|
|
|
|
const _FlowCard({
|
|
required this.flow,
|
|
required this.missingCapabilities,
|
|
required this.onRun,
|
|
required this.onInstallSingle,
|
|
required this.onInstallAll,
|
|
required this.onOpenInEditor,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final isRunnable = missingCapabilities.isEmpty;
|
|
return FaiCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
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.sm),
|
|
IconButton(
|
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
|
tooltip: l.flowsOpenInEditorTooltip,
|
|
onPressed: onOpenInEditor,
|
|
),
|
|
const SizedBox(width: FaiSpace.xs),
|
|
Tooltip(
|
|
message: isRunnable ? '' : l.flowsRunDisabledTooltip,
|
|
child: FilledButton.icon(
|
|
onPressed: onRun,
|
|
icon: const Icon(Icons.play_arrow, size: 16),
|
|
label: Text(l.flowsRunButton),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (!isRunnable) ...[
|
|
const SizedBox(height: FaiSpace.sm),
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 32),
|
|
child: Wrap(
|
|
spacing: FaiSpace.xs,
|
|
runSpacing: FaiSpace.xs,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
Text(
|
|
l.flowsMissingModulesLabel,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.error,
|
|
letterSpacing: 0.4,
|
|
),
|
|
),
|
|
for (final cap in missingCapabilities)
|
|
_ClickablePill(
|
|
label: cap,
|
|
tooltip: l.flowsInstallPillTooltip,
|
|
onTap: () => onInstallSingle(cap),
|
|
),
|
|
if (onInstallAll != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: FaiSpace.sm),
|
|
child: FilledButton.tonalIcon(
|
|
onPressed: onInstallAll,
|
|
icon: const Icon(Icons.download_outlined, size: 14),
|
|
label: Text(
|
|
l.flowsInstallAllButton(missingCapabilities.length),
|
|
),
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: FaiSpace.md,
|
|
vertical: 0,
|
|
),
|
|
minimumSize: const Size(0, 28),
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Tappable variant of `FaiPill` used for the missing-capability
|
|
/// row. Wraps the pill in `InkWell` + `Tooltip` so the operator
|
|
/// gets a click affordance and a "Click to install" hint.
|
|
class _ClickablePill extends StatelessWidget {
|
|
final String label;
|
|
final String tooltip;
|
|
final VoidCallback onTap;
|
|
|
|
const _ClickablePill({
|
|
required this.label,
|
|
required this.tooltip,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
onTap: onTap,
|
|
child: FaiPill(
|
|
label: label,
|
|
tone: FaiPillTone.danger,
|
|
icon: Icons.download_outlined,
|
|
monospace: true,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Per-spec install state for the dependencies dialog. The
|
|
/// dialog walks the list sequentially, advancing each row from
|
|
/// `pending` → `installing` → `done` | `failed` so the operator
|
|
/// sees one row light up at a time.
|
|
enum _InstallStatus { pending, installing, done, failed }
|
|
|
|
class _InstallItemState {
|
|
/// Verbatim spec from the flow's `use:` line, e.g.
|
|
/// `text.extract@^0`. Shown as-is so the operator recognises
|
|
/// the same string they saw on the flow card.
|
|
final String spec;
|
|
|
|
/// Bare capability name (everything left of `@`). What the
|
|
/// hub's `installModule` actually resolves through the store
|
|
/// index — the version constraint is dropped here because
|
|
/// the index already picks a matching version.
|
|
final String bareName;
|
|
|
|
_InstallStatus status;
|
|
String? installedVersion;
|
|
String? error;
|
|
|
|
_InstallItemState(this.spec)
|
|
: bareName = _stripVersion(spec),
|
|
status = _InstallStatus.pending;
|
|
|
|
static String _stripVersion(String spec) {
|
|
final at = spec.indexOf('@');
|
|
return at >= 0 ? spec.substring(0, at) : spec;
|
|
}
|
|
}
|
|
|
|
/// Sequential installer for a list of capability specs. Pops
|
|
/// with `true` if at least one install completed successfully
|
|
/// (so the parent knows to refresh its installed-modules view),
|
|
/// `false` otherwise.
|
|
class _InstallDependenciesDialog extends StatefulWidget {
|
|
final List<String> specs;
|
|
|
|
const _InstallDependenciesDialog({required this.specs});
|
|
|
|
@override
|
|
State<_InstallDependenciesDialog> createState() =>
|
|
_InstallDependenciesDialogState();
|
|
}
|
|
|
|
class _InstallDependenciesDialogState
|
|
extends State<_InstallDependenciesDialog> {
|
|
late final List<_InstallItemState> _items;
|
|
bool _running = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_items = widget.specs.map(_InstallItemState.new).toList();
|
|
// Kick off the install sequence after first frame so any
|
|
// initState-time setState calls inside the chain hit a
|
|
// mounted widget.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _runAll());
|
|
}
|
|
|
|
Future<void> _runAll() async {
|
|
for (final item in _items) {
|
|
if (!mounted) return;
|
|
setState(() => item.status = _InstallStatus.installing);
|
|
try {
|
|
final r = await HubService.instance.installModule(
|
|
source: item.bareName,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
item.status = _InstallStatus.done;
|
|
item.installedVersion = r.version;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
item.status = _InstallStatus.failed;
|
|
item.error = e.toString();
|
|
});
|
|
}
|
|
}
|
|
if (!mounted) return;
|
|
setState(() => _running = false);
|
|
}
|
|
|
|
bool get _anySucceeded =>
|
|
_items.any((i) => i.status == _InstallStatus.done);
|
|
|
|
int get _doneCount =>
|
|
_items.where((i) => i.status == _InstallStatus.done).length;
|
|
|
|
int get _failedCount =>
|
|
_items.where((i) => i.status == _InstallStatus.failed).length;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return AlertDialog(
|
|
title: Text(l.flowsInstallDepsTitle),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
|
),
|
|
content: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 480),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.flowsInstallDepsBody,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.md),
|
|
for (final item in _items) ...[
|
|
_InstallRow(item: item),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
],
|
|
if (!_running) ...[
|
|
const SizedBox(height: FaiSpace.xs),
|
|
Text(
|
|
_failedCount == 0
|
|
? l.flowsInstallDepsAllDone
|
|
: l.flowsInstallDepsAnyFailed(_doneCount, _failedCount),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: _failedCount == 0
|
|
? null
|
|
: theme.colorScheme.error,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _running
|
|
? null
|
|
: () => Navigator.pop(context, _anySucceeded),
|
|
child: Text(l.buttonClose),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// One row in the install-dependencies dialog. Status icon +
|
|
/// capability name + per-state detail line (version on success,
|
|
/// truncated error on failure).
|
|
class _InstallRow extends StatelessWidget {
|
|
final _InstallItemState item;
|
|
|
|
const _InstallRow({required this.item});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: Center(child: _statusIcon(theme)),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.spec,
|
|
style: FaiTheme.mono(
|
|
size: 12,
|
|
weight: FontWeight.w600,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
_detailLine(l),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: item.status == _InstallStatus.failed
|
|
? theme.colorScheme.error
|
|
: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
if (item.status == _InstallStatus.failed &&
|
|
item.error != null) ...[
|
|
const SizedBox(height: FaiSpace.xs),
|
|
FaiErrorBox(
|
|
text: item.error!,
|
|
isError: true,
|
|
maxHeight: 120,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _statusIcon(ThemeData theme) {
|
|
switch (item.status) {
|
|
case _InstallStatus.pending:
|
|
return Icon(
|
|
Icons.radio_button_unchecked,
|
|
size: 16,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
);
|
|
case _InstallStatus.installing:
|
|
return const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
);
|
|
case _InstallStatus.done:
|
|
return Icon(
|
|
Icons.check_circle,
|
|
size: 16,
|
|
color: theme.colorScheme.primary,
|
|
);
|
|
case _InstallStatus.failed:
|
|
return Icon(
|
|
Icons.error_outline,
|
|
size: 16,
|
|
color: theme.colorScheme.error,
|
|
);
|
|
}
|
|
}
|
|
|
|
String _detailLine(AppLocalizations l) {
|
|
switch (item.status) {
|
|
case _InstallStatus.pending:
|
|
return l.flowsInstallStatusPending;
|
|
case _InstallStatus.installing:
|
|
return l.flowsInstallStatusInstalling;
|
|
case _InstallStatus.done:
|
|
return l.flowsInstallStatusDone(item.installedVersion ?? '?');
|
|
case _InstallStatus.failed:
|
|
return l.flowsInstallStatusFailed;
|
|
}
|
|
}
|
|
}
|