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 '../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 createState() => _FlowsPageState(); } class _FlowsPageState extends State { 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((_) => []), ]); return _FlowsBundle( flows: results[0] as List, installedCapabilities: (results[1] as List) .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 _missing(SavedFlow flow, Set installed) { final out = []; 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 _runFlow(SavedFlow flow) async { final inputs = await _FlowInputDialog.show(context, flow); if (inputs == null) return; if (!mounted) return; showDialog( 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 _installDeps(List specs) async { final changed = await showDialog( 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 _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 flows; final Set 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 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 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 textInputs; final Map 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 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> _defFuture; final Map _textControllers = {}; final Map _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 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 = {}; final fileMimeTypes = {}; 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 _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!, 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 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>( 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 []; _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>( 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} · ' '${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 textInputs; final Map fileInputs; final Map 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> _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>( 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>( 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 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 _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; } } }