Some checks failed
Security / Security check (push) Failing after 2s
Three operator-visible improvements per Stefan's review of the v0.50.0 editor. 1. Pencil icon on Flows page now opens Studio's own flow editor (route push) preloaded with the selected flow, replacing the previous "open in OS default editor" (SystemActions.openInOs) behaviour. The editor's AppBar gains a back arrow when reached via route push; reaching the editor via the nav rail leaves it bare. Tooltip string updated in EN + DE. 2. New FlowEditorPage `initialFlowName` parameter. When set, the page loads that flow on first frame (via post-frame callback so the BuildContext is mounted before _openByName runs). When null (the nav-rail path), the editor opens to its empty state as before. 3. Sidebar (_Sidebar) is now collapsed-by-default: shows just icons in a 72px-wide rail with per-destination tooltips. Hovering the rail expands it to 220px (the old width) with brand-mark + labels + the full footer row (theme toggle, language toggle, clock, settings). Collapsed footer shows the settings icon only. Brand- mark (FaiDeltaMark) stays visible in both states so the live/idle status dot is always glanceable. Side-effect: SystemActions import in flows.dart is no longer needed (the pencil no longer shells out) — removed. widget_test.dart: dropped the per-destination Text-presence asserts since labels are now Tooltips when collapsed. Hover-expand testing triggers RenderFlex-overflow mid- animation in widget tests (the AnimatedContainer's width transitions through a constraint slimmer than the Row's intrinsic min). The booting-without-throwing assertion remains; per-destination presence stays in the per-page test suites. Both arb files updated with the new strings (navFlowEditor already existed; added flowEditorBackTooltip + retouched flowsOpenInEditorTooltip). Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1224 lines
39 KiB
Dart
1224 lines
39 KiB
Dart
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:fai_client_sdk/fai_client_sdk.dart' show FlowInputDef;
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../data/format.dart';
|
|
import '../data/hub.dart';
|
|
import 'flow_editor.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 every capability the hub can
|
|
/// execute (WASM modules + built-ins like `system.approval` +
|
|
/// federated MCP/n8n tools) in parallel so the page can
|
|
/// compute "is this flow runnable" per row.
|
|
///
|
|
/// Using `allCapabilities()` instead of `listModules()` is
|
|
/// load-bearing: built-ins are not WASM modules, so they would
|
|
/// never appear in a module-grouped list — and a flow that
|
|
/// uses `system.approval@^0` would stay stuck on "missing
|
|
/// dependency" forever.
|
|
Future<_FlowsBundle> _load() async {
|
|
final results = await Future.wait([
|
|
HubService.instance.listFlows(),
|
|
HubService.instance
|
|
.allCapabilities()
|
|
.catchError((_) => <CapabilityInfo>[]),
|
|
]);
|
|
return _FlowsBundle(
|
|
flows: results[0] as List<SavedFlow>,
|
|
installedCapabilities: (results[1] as List<CapabilityInfo>)
|
|
.map((c) => c.capability)
|
|
.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,
|
|
fileMimeTypes: inputs.fileMimeTypes,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
/// Push Studio's built-in YAML editor as a route with the
|
|
/// selected flow preloaded. The editor's AppBar back arrow
|
|
/// returns to this page. Falls back to opening the file in
|
|
/// the OS's default editor only when explicitly asked (the
|
|
/// secondary "open in OS" entry, not the pencil).
|
|
Future<void> _openInEditor(SavedFlow flow) async {
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => FlowEditorPage(initialFlowName: flow.name),
|
|
),
|
|
);
|
|
// Refresh the list when we come back — operator may have
|
|
// saved + renamed, or deleted, or just edited capabilities.
|
|
if (mounted) _refresh();
|
|
}
|
|
|
|
@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: humanBytes(flow.sizeBytes),
|
|
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;
|
|
/// Parallel to [fileInputs]; same keys. Modules like
|
|
/// `text.extract` reject `application/octet-stream`, so we
|
|
/// derive a real MIME type from the picked file's extension
|
|
/// and forward it to the hub.
|
|
final Map<String, String> fileMimeTypes;
|
|
const _FlowRunInputs({
|
|
required this.textInputs,
|
|
required this.fileInputs,
|
|
required this.fileMimeTypes,
|
|
});
|
|
}
|
|
|
|
class _PickedFile {
|
|
final String name;
|
|
final Uint8List bytes;
|
|
/// MIME derived from the file extension at pick time. Empty
|
|
/// when the extension is unknown — the SDK then falls back to
|
|
/// `application/octet-stream`.
|
|
final String mimeType;
|
|
const _PickedFile({
|
|
required this.name,
|
|
required this.bytes,
|
|
required this.mimeType,
|
|
});
|
|
}
|
|
|
|
/// Minimal extension → MIME map covering the file types that
|
|
/// shipped modules actually accept (PDF, DOCX for text.extract;
|
|
/// plain text, json, csv for upcoming modules). Keeping it tiny
|
|
/// avoids pulling the `mime` package as a new dependency. Returns
|
|
/// empty string for unknown extensions so the SDK default
|
|
/// (`application/octet-stream`) still applies.
|
|
String _mimeForFilename(String filename) {
|
|
final dot = filename.lastIndexOf('.');
|
|
if (dot < 0 || dot == filename.length - 1) return '';
|
|
final ext = filename.substring(dot + 1).toLowerCase();
|
|
switch (ext) {
|
|
case 'pdf':
|
|
return 'application/pdf';
|
|
case 'docx':
|
|
return 'application/vnd.openxmlformats-officedocument'
|
|
'.wordprocessingml.document';
|
|
case 'doc':
|
|
return 'application/msword';
|
|
case 'txt':
|
|
return 'text/plain';
|
|
case 'md':
|
|
case 'markdown':
|
|
return 'text/markdown';
|
|
case 'json':
|
|
return 'application/json';
|
|
case 'csv':
|
|
return 'text/csv';
|
|
case 'xml':
|
|
return 'application/xml';
|
|
case 'yaml':
|
|
case 'yml':
|
|
return 'application/yaml';
|
|
case 'html':
|
|
case 'htm':
|
|
return 'text/html';
|
|
case 'png':
|
|
return 'image/png';
|
|
case 'jpg':
|
|
case 'jpeg':
|
|
return 'image/jpeg';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/// 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>{};
|
|
final fileMimeTypes = <String, String>{};
|
|
for (final d in defs) {
|
|
if (_isBytesType(d.type)) {
|
|
final picked = _pickedFiles[d.name];
|
|
if (picked != null) {
|
|
fileInputs[d.name] = picked.bytes;
|
|
if (picked.mimeType.isNotEmpty) {
|
|
fileMimeTypes[d.name] = picked.mimeType;
|
|
}
|
|
}
|
|
} else {
|
|
final c = _textControllers[d.name];
|
|
if (c != null) textInputs[d.name] = c.text;
|
|
}
|
|
}
|
|
return _FlowRunInputs(
|
|
textInputs: textInputs,
|
|
fileInputs: fileInputs,
|
|
fileMimeTypes: fileMimeTypes,
|
|
);
|
|
}
|
|
|
|
Future<void> _pickFile(String inputName) async {
|
|
final result = await FilePicker.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!,
|
|
mimeType: _mimeForFilename(f.name),
|
|
);
|
|
});
|
|
}
|
|
|
|
/// 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(
|
|
error: snap.error,
|
|
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} · '
|
|
'${humanBytes(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;
|
|
final Map<String, String> fileMimeTypes;
|
|
|
|
const _FlowRunDialog({
|
|
required this.flow,
|
|
required this.textInputs,
|
|
required this.fileInputs,
|
|
required this.fileMimeTypes,
|
|
});
|
|
|
|
@override
|
|
State<_FlowRunDialog> createState() => _FlowRunDialogState();
|
|
}
|
|
|
|
class _FlowRunDialogState extends State<_FlowRunDialog> {
|
|
late final Future<Map<String, FlowOutput>> _future;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_future = HubService.instance.runSavedFlow(
|
|
name: widget.flow.name,
|
|
textInputs: widget.textInputs,
|
|
fileInputs: widget.fileInputs,
|
|
fileMimeTypes: widget.fileMimeTypes,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return AlertDialog(
|
|
// Title tracks the future's state so the dialog header
|
|
// stops claiming "extract läuft" once the run is over.
|
|
// Recomputed on every snapshot change via FutureBuilder.
|
|
title: FutureBuilder<Map<String, FlowOutput>>(
|
|
future: _future,
|
|
builder: (context, snapshot) {
|
|
final String title;
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
title = l.flowsRunningTitle(widget.flow.name);
|
|
} else if (snapshot.hasError) {
|
|
title = l.flowsErrorTitle(widget.flow.name);
|
|
} else {
|
|
title = l.flowsResultTitle(widget.flow.name);
|
|
}
|
|
return Text(title);
|
|
},
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
|
),
|
|
content: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480),
|
|
child: FutureBuilder<Map<String, FlowOutput>>(
|
|
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(
|
|
error: snapshot.error,
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
FaiFlowOutput(output: entry.value),
|
|
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;
|
|
/// Original thrown object so the UI can run it through
|
|
/// [friendlyError] later instead of staring at a wall of gRPC
|
|
/// trailers.
|
|
Object? 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;
|
|
});
|
|
}
|
|
}
|
|
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;
|
|
|
|
/// True iff at least one failure carries the v0.10.91+ HTML-instead-
|
|
/// of-bundle signature emitted by `download_to_temp` when the
|
|
/// registry redirects an unauthenticated request to a signin page.
|
|
/// Used to surface a contextual "Open Settings" button so the
|
|
/// operator does not have to read the wall of error text first.
|
|
bool get _anyAuthWallFailure => _items.any(
|
|
(i) =>
|
|
i.status == _InstallStatus.failed && _isAuthWallError(i.error),
|
|
);
|
|
|
|
static bool _isAuthWallError(Object? err) {
|
|
if (err == null) return false;
|
|
final msg = err.toString();
|
|
return msg.contains('registry returned an HTML page') ||
|
|
msg.contains('no registry token configured');
|
|
}
|
|
|
|
@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: [
|
|
if (!_running && _anyAuthWallFailure)
|
|
FilledButton.icon(
|
|
icon: const Icon(Icons.vpn_key_outlined, size: 16),
|
|
label: Text(l.flowsInstallAuthWallButton),
|
|
onPressed: () async {
|
|
// Close install dialog first so the operator returns
|
|
// to the flow card on Settings dismiss — then they
|
|
// can re-click the pill to retry the install.
|
|
Navigator.pop(context, _anySucceeded);
|
|
await FaiSettingsDialog.show(context);
|
|
},
|
|
),
|
|
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(
|
|
error: 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;
|
|
}
|
|
}
|
|
}
|