import 'dart:io'; import 'dart:typed_data'; import 'package:fai_dart_sdk/fai_dart_sdk.dart' show FlowInputDef; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; class FlowsPage extends StatefulWidget { const FlowsPage({super.key}); @override State createState() => _FlowsPageState(); } class _FlowsPageState extends State { late Future> _future; @override void initState() { super.initState(); _future = HubService.instance.listFlows(); } void _refresh() => setState(() { _future = HubService.instance.listFlows(); }); 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, ), ); } @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>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return FaiEmptyState( icon: Icons.cloud_off_outlined, iconColor: Theme.of(context).colorScheme.error, title: l.hubUnreachable, hint: l.hubUnreachableHint, action: FilledButton.tonal( onPressed: _refresh, child: Text(l.buttonRetry), ), ); } final flows = snapshot.data ?? []; if (flows.isEmpty) { return FaiEmptyState( icon: Icons.account_tree_outlined, title: l.flowsNoneTitle, hint: l.flowsNoneHint, ); } return ListView.separated( padding: const EdgeInsets.all(FaiSpace.xl), itemCount: flows.length, separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), itemBuilder: (context, i) => _FlowCard(flow: flows[i], onRun: () => _runFlow(flows[i])), ); }, ), ); } } class _FlowCard extends StatelessWidget { final SavedFlow flow; final VoidCallback onRun; const _FlowCard({required this.flow, required this.onRun}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return FaiCard( child: Row( children: [ Icon( Icons.account_tree_outlined, size: 20, color: theme.colorScheme.primary, ), const SizedBox(width: FaiSpace.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( flow.name, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( flow.path, style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), FaiPill( label: '${flow.sizeBytes} B', tone: FaiPillTone.neutral, monospace: true, ), const SizedBox(width: FaiSpace.md), FilledButton.icon( onPressed: onRun, icon: const Icon(Icons.play_arrow, size: 16), label: Text(AppLocalizations.of(context)!.flowsRunButton), ), ], ), ); } } /// Inputs collected by `_FlowInputDialog` and forwarded to /// the run dialog. Text and file values arrive in separate /// maps so the SDK can wrap each as the appropriate Payload /// variant. class _FlowRunInputs { final Map textInputs; final Map fileInputs; const _FlowRunInputs({ required this.textInputs, required this.fileInputs, }); } class _PickedFile { final String name; final Uint8List bytes; const _PickedFile({required this.name, required this.bytes}); } /// Run-flow input dialog. Fetches the flow's declared input /// schema first via `getFlowDefinition`, then renders one /// type-aware field per declared input: /// /// * `bytes` / `file` โ†’ "Choose file" button + picked-file /// readout. Routes through the OS /// file dialog so sandboxed Studio /// still gets read access to the /// chosen file. `withData: true` so /// the bytes flow through the /// FilePicker result instead of a /// path that may be unreadable. /// * everything else โ†’ TextField. Opaque type tag is /// shown as a pill next to the input /// name so a flow author who picks /// a less-common type still gets a /// hint. /// /// The Run button enables only when every input has a value /// โ€” required-field validation happens client-side before /// the hub sees an empty inputs map. class _FlowInputDialog extends StatefulWidget { final SavedFlow flow; const _FlowInputDialog({required this.flow}); static Future<_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 = {}; for (final d in defs) { if (_isBytesType(d.type)) { final picked = _pickedFiles[d.name]; if (picked != null) fileInputs[d.name] = picked.bytes; } else { final c = _textControllers[d.name]; if (c != null) textInputs[d.name] = c.text; } } return _FlowRunInputs(textInputs: textInputs, fileInputs: fileInputs); } Future _pickFile(String inputName) async { final result = await FilePicker.platform.pickFiles(withData: true); if (result == null || result.files.isEmpty) return; final f = result.files.first; var bytes = f.bytes; if (bytes == null && f.path != null) { // Some Linux configurations don't honour `withData: true` // for large files; fall back to reading via path. try { bytes = await File(f.path!).readAsBytes(); } catch (e) { if (!mounted) return; final l = AppLocalizations.of(context)!; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.flowsFileReadFailed(f.path!, '$e'))), ); return; } } if (bytes == null || !mounted) return; setState(() { _pickedFiles[inputName] = _PickedFile(name: f.name, bytes: bytes!); }); } /// Lazily ensure each text-shaped input has a controller + /// a setState-triggering listener so the Run button reflects /// validity as the operator types. void _ensureTextControllers(List defs) { for (final d in defs) { if (_isBytesType(d.type)) continue; _textControllers.putIfAbsent(d.name, () { final c = TextEditingController(); c.addListener(() { if (mounted) setState(() {}); }); return c; }); } } @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( text: snap.error.toString(), isError: true, maxHeight: 200, ), ], ); } final defs = snap.data ?? const []; _ensureTextControllers(defs); return SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.flowsRunDialogBody, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.md), if (defs.isEmpty) Text( l.flowsNoInputs, style: theme.textTheme.bodyMedium, ), for (final d in defs) ...[ _InputField( def: d, isBytes: _isBytesType(d.type), textController: _textControllers[d.name], pickedFile: _pickedFiles[d.name], onPick: () => _pickFile(d.name), ), const SizedBox(height: FaiSpace.md), ], ], ), ); }, ), ), 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} ยท ' '${l.flowsTypeBytesSize(pickedFile!.bytes.length)}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), overflow: TextOverflow.ellipsis, ), ), ], ) else TextField( controller: textController, decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, ), ), ], ); } } class _FlowRunDialog extends StatefulWidget { final SavedFlow flow; final Map textInputs; final Map 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> _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>( 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), ), ], ); } }