import 'package:flutter/material.dart'; import '../data/hub.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, inputs: inputs), ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( title: const Text('Flows'), actions: [ IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: 'Reload', 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: 'Hub unreachable', hint: 'Start the hub with `fai serve`.', action: FilledButton.tonal( onPressed: _refresh, child: const Text('Retry'), ), ); } final flows = snapshot.data ?? []; if (flows.isEmpty) { return const FaiEmptyState( icon: Icons.account_tree_outlined, title: 'No saved flows', hint: 'Save a flow with `fai admin flows save ` and it shows up here.', ); } 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: const Text('Run'), ), ], ), ); } } class _FlowInputDialog extends StatefulWidget { final SavedFlow flow; const _FlowInputDialog({required this.flow}); static Future?> show( BuildContext context, SavedFlow flow, ) { return showDialog>( context: context, builder: (_) => _FlowInputDialog(flow: flow), ); } @override State<_FlowInputDialog> createState() => _FlowInputDialogState(); } class _FlowInputDialogState extends State<_FlowInputDialog> { // The hub does not yet expose the flow's input schema over // gRPC, so this dialog accepts free-form key=value pairs. // Operator pastes pairs separated by newlines; we split on // the first `=` per line. final _ctrl = TextEditingController(); @override void dispose() { _ctrl.dispose(); super.dispose(); } Map _parsePairs() { final out = {}; for (final raw in _ctrl.text.split('\n')) { final line = raw.trim(); if (line.isEmpty) continue; final eq = line.indexOf('='); if (eq < 0) continue; out[line.substring(0, eq).trim()] = line.substring(eq + 1).trim(); } return out; } @override Widget build(BuildContext context) { final theme = Theme.of(context); return AlertDialog( title: Text('Run ${widget.flow.name}'), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(FaiRadius.md), ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Inputs as key=value, one per line. All values are sent as text payloads. For binary inputs use the `fai run` CLI.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.md), Expanded( child: TextField( controller: _ctrl, maxLines: null, expands: true, textAlignVertical: TextAlignVertical.top, style: FaiTheme.mono(size: 12), decoration: const InputDecoration( hintText: 'name=World\ntarget_language=English\nsummary_style=three bullet points', border: OutlineInputBorder(), isDense: true, ), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, null), child: const Text('Cancel'), ), FilledButton.icon( icon: const Icon(Icons.play_arrow, size: 16), label: const Text('Run'), onPressed: () => Navigator.pop(context, _parsePairs()), ), ], ); } } class _FlowRunDialog extends StatefulWidget { final SavedFlow flow; final Map inputs; const _FlowRunDialog({required this.flow, required this.inputs}); @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.inputs, ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return AlertDialog( title: Text('Running ${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( 'Flow running…', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ); } if (snapshot.hasError) { return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.error_outline, color: theme.colorScheme.error, size: 32, ), const SizedBox(height: FaiSpace.md), SelectableText( snapshot.error.toString(), style: FaiTheme.mono( size: 11, color: theme.colorScheme.error, ), ), ], ), ); } final outputs = snapshot.data ?? const {}; if (outputs.isEmpty) { return Text( 'Flow completed with no declared outputs.', 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: const Text('Close'), ), ], ); } }