// FlowsPage — Studio's flow surface. Thin host wrapper around // the swappable fai_studio_flow_editor package: loads the // hub's installed capabilities, hands them + a Studio-side // FlowRunDriver to the editor, and lets the editor own // everything else (file list, three-tab body, properties // panel, run progress, …). // // Pre-0.52 versions split file-listing and run-dialog state // across this page. v0.52 moves all of that into the editor // package so a swap is one pubspec change — Studio doesn't // need to know what's inside the editor any more. import 'package:fai_studio_flow_editor/fai_studio_flow_editor.dart'; import 'package:flutter/material.dart'; import '../data/flow_run_driver.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; class FlowsPage extends StatefulWidget { /// Pre-load this flow when the editor first builds. Studio /// keeps the parameter so the Cmd+K palette + welcome page /// can deep-link straight into a specific flow. final String? initialFlowName; const FlowsPage({super.key, this.initialFlowName}); @override State createState() => _FlowsPageState(); } class _FlowsPageState extends State { late Future> _capabilities; late final StudioFlowRunDriver _driver; /// Snapshot of bare capability names the public store can /// install. Refreshed once at open so the editor's analyzer /// can decide between "Install" (in store) and "Add source" /// (not in store) without a per-keystroke network call. List _storeCaps = const []; @override void initState() { super.initState(); _driver = StudioFlowRunDriver(); _capabilities = _loadCapabilities(); _loadStoreCapabilities(); } Future _loadStoreCapabilities() async { try { final items = await HubService.instance.searchStore(limit: 500); if (!mounted) return; // Store items carry a list of provided capabilities. We // ingest all of them flat so a single store entry // shipping multiple caps still reports each as installable. // Falls back to the item name for legacy entries. final caps = {}; for (final item in items) { if (item.requiresCapabilities.isNotEmpty) { caps.addAll(item.requiresCapabilities); } caps.add(item.name); } setState(() => _storeCaps = caps.toList()..sort()); } catch (_) { // Soft-fail: empty list disables the Install button on // unknown-cap fixes, but Add-source remains available. } } /// Pulls the capability identifiers the picker dialog /// offers when adding a step. `@` so /// the operator can see exactly what they'll be wiring in, /// matching the format the `use:` field expects. Soft-fails /// to an empty list if the hub is unreachable — the editor /// falls back to a free-form text field in that case. Future> _loadCapabilities() async { try { final caps = await HubService.instance.allCapabilities(); final entries = caps.map((c) => '${c.capability}@${c.version}').toSet().toList() ..sort(); return entries; } catch (_) { return const []; } } /// Quick-fix handler — invoked when the operator clicks /// `Install ` on an analyzer issue. Parses the /// editor-supplied `provider/name@version` spec into the /// Hub's install API and returns the refreshed capability /// list so the editor can re-analyze without waiting for the /// parent widget rebuild. Future?> _onInstallCapability(String capability) async { return _runInstall(source: capability); } /// `Add source for …` handler. Prompts the operator /// for a local path or URL, then routes through the same /// install RPC. Returns the refreshed capability list on /// success, null on cancel / failure. Future?> _onAddModuleSource(String capability) async { final source = await _AddModuleSourceDialog.show(context, capability); if (source == null || source.isEmpty) return null; return _runInstall(source: source); } Future?> _runInstall({required String source}) async { try { await HubService.instance.installModule(source: source); final caps = await HubService.instance.allCapabilities(); final updated = caps .map((c) => '${c.capability}@${c.version}') .toSet() .toList() ..sort(); if (mounted) { setState(() => _capabilities = Future.value(updated)); } return updated; } catch (e) { if (mounted) { final l = AppLocalizations.of(context); final msg = l != null ? l.installFailed(e.toString()) : 'Install failed: $e'; ScaffoldMessenger.of(context).removeCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(msg), behavior: SnackBarBehavior.floating, ), ); } return null; } } @override Widget build(BuildContext context) { final locale = Localizations.localeOf(context); final editorLocale = locale.languageCode == 'de' ? FlowEditorLocale.de : FlowEditorLocale.en; return FutureBuilder>( future: _capabilities, builder: (context, snap) { final caps = snap.data ?? const []; return FlowEditorPage( initialFlowName: widget.initialFlowName, locale: editorLocale, runDriver: _driver, availableCapabilities: caps, storeCapabilities: _storeCaps, onInstallCapability: _onInstallCapability, onAddModuleSource: _onAddModuleSource, ); }, ); } } /// Modal asking the operator where a missing capability can be /// installed from — either a local module directory (the /// `--link` install path) or a remote URL the hub can resolve. /// Returns the typed source string on submit, null on cancel. class _AddModuleSourceDialog extends StatefulWidget { final String capability; const _AddModuleSourceDialog({required this.capability}); static Future show(BuildContext context, String capability) { return showDialog( context: context, builder: (_) => _AddModuleSourceDialog(capability: capability), ); } @override State<_AddModuleSourceDialog> createState() => _AddModuleSourceDialogState(); } class _AddModuleSourceDialogState extends State<_AddModuleSourceDialog> { final TextEditingController _ctrl = TextEditingController(); String _source = ''; @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return AlertDialog( title: Text(l.addSourceTitle), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.addSourceIntro(widget.capability), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 16), TextField( controller: _ctrl, autofocus: true, onChanged: (v) => setState(() => _source = v.trim()), decoration: InputDecoration( labelText: l.addSourceField, hintText: l.addSourceHint, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: 12), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(6), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.addSourceHowItWorksTitle, style: theme.textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 6), Text( l.addSourceHowItWorksBody, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 6), Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(4), ), child: SelectableText( l.addSourceCliExample, style: TextStyle( fontFamily: 'JetBrains Mono', fontFamilyFallback: const [ 'Menlo', 'Consolas', 'Courier New', 'monospace', ], fontSize: 11, color: theme.colorScheme.onSurface, ), ), ), ], ), ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(l.addSourceCancel), ), FilledButton( onPressed: _source.isEmpty ? null : () => Navigator.of(context).pop(_source), child: Text(l.addSourceInstallButton), ), ], ); } }