Full rewrite of the editor surface, layered on top of the
FlowGraph foundation. One in-memory flow drives three tabs
that the operator can flip between freely:
- Graph: a drag-and-drop canvas. Nodes are step cards with
port dots on their left (one per `with:` field) and a
combined output port on the right. Pinned inputs and
outputs pseudo-nodes sit at the left and right edges so
every flow has a visually obvious source and sink. Pan +
zoom via InteractiveViewer; drag a node by its body to
reposition it (positions persisted to a sidecar JSON file
under ~/.fai/data/flows/.layout/<name>.json — kept OUT of
the YAML so `fai run` stays byte-stable).
- Text: the existing YAML CodeField with expands:true so
line 1 anchors at the top edge. YAML-aware syntax
highlighting picks up the theme's primary / secondary /
tertiary palette for keys / strings / numbers.
- Run: an inputs form (text fields + file-pick), a Start
button that calls the host's FlowRunDriver, a live step
list driven by the driver's event stream (matches the
`fai run` CLI rendering — ◻ pending, · running, ✔ done +
duration, ✗ failed, ⏸ awaiting approval), and the typed
outputs once the run resolves.
Source of truth = the YAML text. Graph edits emit fresh YAML
into the shared CodeController; text edits re-parse the
graph on a 350 ms debounce. Layout sidecar persists drag
positions only.
New public API (lib/fai_studio_flow_editor.dart):
FlowEditorPage(
initialFlowName: ...,
locale: ...,
runDriver: FlowRunDriver?, // NEW — host bridge
availableCapabilities: List<String>, // NEW — for the
// capability picker
// dialog when adding
// a step
)
The host (Studio) implements FlowRunDriver to bridge the
hub's gRPC SDK into the editor's event vocabulary. The
StepStarted/Completed/Failed/AwaitingApproval events are
shared verbatim with the CLI's run_progress renderer so
both surfaces speak the same visual language.
Files in this commit:
- lib/src/editor_controller.dart — shared state +
debounced reparse loop
- lib/src/run_driver.dart — host bridge
interface + event types
- lib/src/widgets/flow_canvas.dart — pan / zoom / drag /
port-to-port connection drawing
- lib/src/widgets/flow_node.dart — node card primitive
(module / approval / inputs / outputs variants)
- lib/src/widgets/edge_painter.dart — single CustomPainter
for every edge + draft drag line, cubic bezier with
arrow-head caps
- lib/src/widgets/properties_panel.dart — right-side editor
when a step is selected (rename id, change capability, add
/ remove / rename with-fields, delete step)
- lib/src/widgets/capability_picker.dart — searchable list
dialog used by Add-step
- lib/src/widgets/run_tab.dart — inputs form +
live step progress + outputs renderer
- lib/src/flow_editor_page.dart — host scaffolding,
toolbar, file list, three-tab body, keyboard shortcuts
- lib/src/l10n.dart — EN + DE strings for
every new label
- lib/fai_studio_flow_editor.dart — exports the new
public types (FlowRunDriver, FlowRunEvent variants,
FlowOutputValue variants)
flutter analyze: 0 issues. flutter test: 7/7 green.
Signed-off-by: flemming-it <sf@flemming.it>
834 lines
24 KiB
Dart
834 lines
24 KiB
Dart
// FlowEditorPage — the public-facing widget Studio (and any
|
|
// other host) embeds as its flow surface.
|
|
//
|
|
// Layout:
|
|
//
|
|
// ┌─ Toolbar ─────────────────────────────────────────────┐
|
|
// │ [back] file.yaml ● [+ Step] [Save] [Run] │
|
|
// ├──────┬────────────────────────────────────────────────┤
|
|
// │ FLOWS│ [Graph][Text][Run] │
|
|
// │ ⟲ │ │
|
|
// │ │ (active tab content) │
|
|
// │ list │ │
|
|
// │ │ │
|
|
// └──────┴────────────────────────────────────────────────┘
|
|
// └ Properties (overlay)
|
|
//
|
|
// All three tabs read state from a single [FlowEditorController].
|
|
// The graph tab and text tab keep YAML in lockstep — graph
|
|
// edits emit fresh YAML; text edits re-parse the graph on
|
|
// debounce. The run tab reads the saved-on-disk version
|
|
// because that's what the hub's runSavedFlow consumes.
|
|
|
|
library;
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
|
|
|
import 'editor_controller.dart';
|
|
import 'l10n.dart';
|
|
import 'model/flow_graph.dart';
|
|
import 'run_driver.dart';
|
|
import 'tokens.dart';
|
|
import 'widgets.dart';
|
|
import 'widgets/capability_picker.dart';
|
|
import 'widgets/flow_canvas.dart';
|
|
import 'widgets/properties_panel.dart';
|
|
import 'widgets/run_tab.dart';
|
|
|
|
/// Compute the flows directory under the operator's home.
|
|
String _defaultFlowsDir() {
|
|
final home =
|
|
Platform.environment['HOME'] ??
|
|
Platform.environment['USERPROFILE'] ??
|
|
'.';
|
|
return '$home/.fai/data/flows';
|
|
}
|
|
|
|
class FlowEditorPage extends StatefulWidget {
|
|
/// Pre-load this flow on open. When null, the editor starts
|
|
/// empty and the operator picks from the file list.
|
|
final String? initialFlowName;
|
|
|
|
/// Language for inline strings.
|
|
final FlowEditorLocale locale;
|
|
|
|
/// Bridge between editor and host's hub. When null, the
|
|
/// Run tab is read-only.
|
|
final FlowRunDriver? runDriver;
|
|
|
|
/// Capabilities the operator can drop into a new step.
|
|
/// Studio supplies the list from `HubService.listCapabilities()`.
|
|
/// Empty list = the picker shows a free-form text field.
|
|
final List<String> availableCapabilities;
|
|
|
|
const FlowEditorPage({
|
|
super.key,
|
|
this.initialFlowName,
|
|
this.locale = FlowEditorLocale.en,
|
|
this.runDriver,
|
|
this.availableCapabilities = const [],
|
|
});
|
|
|
|
@override
|
|
State<FlowEditorPage> createState() => _FlowEditorPageState();
|
|
}
|
|
|
|
class _FlowEditorPageState extends State<FlowEditorPage>
|
|
with TickerProviderStateMixin {
|
|
late final FlowEditorController _controller;
|
|
late final FlowEditorStrings _l;
|
|
late Future<List<_FlowFile>> _files;
|
|
late final TabController _tabs;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_l = FlowEditorStrings(widget.locale);
|
|
_controller = FlowEditorController();
|
|
_controller.addListener(_onCtrlChanged);
|
|
_tabs = TabController(length: 3, vsync: this);
|
|
_files = _listFiles();
|
|
final initial = widget.initialFlowName;
|
|
if (initial != null && initial.isNotEmpty) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
await _openByName(initial);
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.removeListener(_onCtrlChanged);
|
|
_controller.dispose();
|
|
_tabs.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onCtrlChanged() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
// --- file ops ---
|
|
|
|
Future<List<_FlowFile>> _listFiles() async {
|
|
final dir = Directory(_defaultFlowsDir());
|
|
if (!dir.existsSync()) return <_FlowFile>[];
|
|
final entries = await dir
|
|
.list()
|
|
.where((e) => e is File && e.path.endsWith('.yaml'))
|
|
.cast<File>()
|
|
.toList();
|
|
final files =
|
|
entries
|
|
.map(
|
|
(f) => _FlowFile(
|
|
name: f.uri.pathSegments.last.replaceAll(
|
|
RegExp(r'\.yaml$'),
|
|
'',
|
|
),
|
|
path: f.path,
|
|
sizeBytes: f.statSync().size,
|
|
),
|
|
)
|
|
.toList()
|
|
..sort((a, b) => a.name.compareTo(b.name));
|
|
return files;
|
|
}
|
|
|
|
Future<void> _openByName(String name) async {
|
|
final path = '${_defaultFlowsDir()}/$name.yaml';
|
|
final file = File(path);
|
|
if (!file.existsSync()) return;
|
|
final text = await file.readAsString();
|
|
if (!mounted) return;
|
|
_controller.openFlow(name, text);
|
|
}
|
|
|
|
Future<void> _openFile(_FlowFile f) async {
|
|
if (_controller.isDirty) {
|
|
final keep = await _confirmDiscard();
|
|
if (keep == false || !mounted) return;
|
|
}
|
|
final text = await File(f.path).readAsString();
|
|
if (!mounted) return;
|
|
_controller.openFlow(f.name, text);
|
|
}
|
|
|
|
Future<bool?> _confirmDiscard() {
|
|
return showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(_l.discardTitle),
|
|
content: Text(_l.discardBody),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(_l.discardKeep),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(_l.discardThrow),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
final name = _controller.activeName;
|
|
if (name == null) return;
|
|
_controller.saving = true;
|
|
try {
|
|
final file = File('${_defaultFlowsDir()}/$name.yaml');
|
|
await file.writeAsString(
|
|
_controller.codeController.fullText,
|
|
flush: true,
|
|
);
|
|
if (!mounted) return;
|
|
_controller.markSaved();
|
|
_files = _listFiles();
|
|
setState(() {});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(e.toString())));
|
|
} finally {
|
|
_controller.saving = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _newFlow() async {
|
|
final name = await showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) => _NewFlowDialog(strings: _l),
|
|
);
|
|
if (name == null || name.isEmpty || !mounted) return;
|
|
final template =
|
|
'''# ${_l.newTemplateComment(name)}
|
|
|
|
name: $name
|
|
|
|
inputs:
|
|
text:
|
|
type: text
|
|
|
|
steps:
|
|
- id: echo
|
|
use: debug.echo@^0
|
|
with:
|
|
message: \$inputs.text
|
|
|
|
outputs:
|
|
result: \$echo.echoed
|
|
''';
|
|
try {
|
|
final dir = Directory(_defaultFlowsDir());
|
|
if (!dir.existsSync()) await dir.create(recursive: true);
|
|
final file = File('${dir.path}/$name.yaml');
|
|
if (file.existsSync()) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(_l.alreadyExists(name))));
|
|
return;
|
|
}
|
|
await file.writeAsString(template, flush: true);
|
|
if (!mounted) return;
|
|
_controller.openFlow(name, template);
|
|
_files = _listFiles();
|
|
setState(() {});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(e.toString())));
|
|
}
|
|
}
|
|
|
|
Future<void> _refreshFiles() async {
|
|
setState(() => _files = _listFiles());
|
|
}
|
|
|
|
Future<void> _addStep() async {
|
|
final picked = await CapabilityPicker.show(
|
|
context,
|
|
capabilities: widget.availableCapabilities,
|
|
strings: _l,
|
|
);
|
|
if (picked == null || !mounted) return;
|
|
final graph = _controller.graph;
|
|
// Generate a fresh id — base on the capability's local
|
|
// name, deduplicating against existing step ids.
|
|
final localPart = picked.split('/').last.split('@').first;
|
|
final baseId = localPart.split('.').last;
|
|
var id = baseId;
|
|
var i = 1;
|
|
while (graph.steps.any((s) => s.id == id)) {
|
|
id = '${baseId}_$i';
|
|
i++;
|
|
}
|
|
_controller.applyGraphEdit(
|
|
graph.withStepAdded(FlowStep(id: id, use: picked, with_: const {})),
|
|
);
|
|
_controller.selectStep(id);
|
|
_tabs.animateTo(0);
|
|
}
|
|
|
|
// --- build ---
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Keyboard shortcuts: Cmd/Ctrl+S to save, Cmd/Ctrl+Enter
|
|
// to start a run. The text tab also keeps these but the
|
|
// global wrapper covers the graph + run tabs too.
|
|
return Shortcuts(
|
|
shortcuts: <ShortcutActivator, Intent>{
|
|
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyS):
|
|
const _SaveIntent(),
|
|
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
|
|
const _SaveIntent(),
|
|
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.enter):
|
|
const _RunIntent(),
|
|
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter):
|
|
const _RunIntent(),
|
|
},
|
|
child: Actions(
|
|
actions: <Type, Action<Intent>>{
|
|
_SaveIntent: CallbackAction<_SaveIntent>(
|
|
onInvoke: (_) {
|
|
if (_controller.activeName != null) _save();
|
|
return null;
|
|
},
|
|
),
|
|
_RunIntent: CallbackAction<_RunIntent>(
|
|
onInvoke: (_) {
|
|
if (_controller.activeName != null) _tabs.animateTo(2);
|
|
return null;
|
|
},
|
|
),
|
|
},
|
|
child: Focus(autofocus: true, child: _buildScaffold()),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildScaffold() {
|
|
final theme = Theme.of(context);
|
|
return Scaffold(
|
|
body: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_Toolbar(
|
|
strings: _l,
|
|
activeName: _controller.activeName,
|
|
dirty: _controller.isDirty,
|
|
saving: _controller.isSaving,
|
|
running: _controller.isRunning,
|
|
onBack: Navigator.of(context).canPop()
|
|
? () => Navigator.of(context).maybePop()
|
|
: null,
|
|
onSave: _controller.activeName != null ? _save : null,
|
|
onAddStep: _controller.activeName != null ? _addStep : null,
|
|
onNew: _newFlow,
|
|
onRun: _controller.activeName != null
|
|
? () => _tabs.animateTo(2)
|
|
: null,
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SizedBox(
|
|
width: 240,
|
|
child: _FileList(
|
|
filesFuture: _files,
|
|
activeName: _controller.activeName,
|
|
strings: _l,
|
|
onOpen: _openFile,
|
|
onRefresh: _refreshFiles,
|
|
),
|
|
),
|
|
const VerticalDivider(width: 1),
|
|
Expanded(
|
|
child: _controller.activeName == null
|
|
? _EmptyState(strings: _l)
|
|
: _tabbedBody(theme),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _tabbedBody(ThemeData theme) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Container(
|
|
color: theme.colorScheme.surfaceContainerLowest,
|
|
child: TabBar(
|
|
controller: _tabs,
|
|
isScrollable: true,
|
|
tabAlignment: TabAlignment.start,
|
|
tabs: [
|
|
Tab(
|
|
text: _l.tabGraph,
|
|
icon: const Icon(Icons.account_tree, size: 16),
|
|
),
|
|
Tab(text: _l.tabText, icon: const Icon(Icons.notes, size: 16)),
|
|
Tab(
|
|
text: _l.tabRun,
|
|
icon: const Icon(Icons.play_arrow, size: 16),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabs,
|
|
// Disable the lateral swipe gesture so it doesn't
|
|
// race with the canvas's pan handlers.
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
children: [
|
|
_graphTab(theme),
|
|
_textTab(theme),
|
|
RunTab(
|
|
controller: _controller,
|
|
strings: _l,
|
|
driver: widget.runDriver,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _graphTab(ThemeData theme) {
|
|
// When a step is selected, show the properties panel on
|
|
// the right as a fixed sidebar. Selection is owned by
|
|
// the controller and ChangeNotifier rebuilds drive the
|
|
// panel show / hide.
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(child: FlowCanvas(controller: _controller)),
|
|
if (_controller.selectedStepId != null) ...[
|
|
const VerticalDivider(width: 1),
|
|
SizedBox(
|
|
width: 320,
|
|
child: PropertiesPanel(
|
|
controller: _controller,
|
|
strings: _l,
|
|
availableCapabilities: widget.availableCapabilities,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _textTab(ThemeData theme) {
|
|
final mono = TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 13,
|
|
height: 1.45,
|
|
color: theme.colorScheme.onSurface,
|
|
);
|
|
return CodeTheme(
|
|
data: CodeThemeData(styles: _yamlStyle(theme)),
|
|
child: CodeField(
|
|
controller: _controller.codeController,
|
|
textStyle: mono,
|
|
expands: true,
|
|
minLines: null,
|
|
maxLines: null,
|
|
gutterStyle: GutterStyle(
|
|
textStyle: mono.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
background: theme.colorScheme.surfaceContainer,
|
|
showLineNumbers: true,
|
|
),
|
|
background: theme.colorScheme.surface,
|
|
),
|
|
);
|
|
}
|
|
|
|
Map<String, TextStyle> _yamlStyle(ThemeData theme) {
|
|
final cs = theme.colorScheme;
|
|
final monoBase = TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 13,
|
|
height: 1.45,
|
|
);
|
|
return {
|
|
'root': monoBase.copyWith(color: cs.onSurface),
|
|
// YAML key — bright primary so the structure pops at a
|
|
// glance. attr is the highlight grammar's key token.
|
|
'attr': monoBase.copyWith(color: cs.primary, fontWeight: FontWeight.w600),
|
|
'string': monoBase.copyWith(color: cs.tertiary),
|
|
'number': monoBase.copyWith(color: cs.secondary),
|
|
'bullet': monoBase.copyWith(color: cs.onSurfaceVariant),
|
|
'literal': monoBase.copyWith(color: cs.secondary),
|
|
'comment': monoBase.copyWith(
|
|
color: cs.onSurfaceVariant,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
'meta': monoBase.copyWith(color: cs.secondary),
|
|
};
|
|
}
|
|
}
|
|
|
|
// --- shortcuts ---
|
|
|
|
class _SaveIntent extends Intent {
|
|
const _SaveIntent();
|
|
}
|
|
|
|
class _RunIntent extends Intent {
|
|
const _RunIntent();
|
|
}
|
|
|
|
// --- toolbar ---
|
|
|
|
class _Toolbar extends StatelessWidget {
|
|
final FlowEditorStrings strings;
|
|
final String? activeName;
|
|
final bool dirty;
|
|
final bool saving;
|
|
final bool running;
|
|
final VoidCallback? onBack;
|
|
final VoidCallback? onSave;
|
|
final VoidCallback? onAddStep;
|
|
final VoidCallback onNew;
|
|
final VoidCallback? onRun;
|
|
const _Toolbar({
|
|
required this.strings,
|
|
required this.activeName,
|
|
required this.dirty,
|
|
required this.saving,
|
|
required this.running,
|
|
required this.onBack,
|
|
required this.onSave,
|
|
required this.onAddStep,
|
|
required this.onNew,
|
|
required this.onRun,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: FaiSpace.md,
|
|
vertical: FaiSpace.sm,
|
|
),
|
|
color: theme.colorScheme.surfaceContainer,
|
|
child: Row(
|
|
children: [
|
|
if (onBack != null) ...[
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_back, size: 18),
|
|
tooltip: strings.backTooltip,
|
|
onPressed: onBack,
|
|
),
|
|
const SizedBox(width: FaiSpace.xs),
|
|
],
|
|
Text(
|
|
activeName == null ? strings.emptyTitle : '${activeName!}.yaml',
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontFamily: 'monospace',
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
if (dirty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: FaiSpace.xs),
|
|
child: Icon(
|
|
Icons.circle,
|
|
size: 8,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
FilledButton.tonalIcon(
|
|
onPressed: onNew,
|
|
icon: const Icon(Icons.add, size: 16),
|
|
label: Text(strings.newFlow),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
if (onAddStep != null)
|
|
FilledButton.tonalIcon(
|
|
onPressed: onAddStep,
|
|
icon: const Icon(Icons.add_box_outlined, size: 16),
|
|
label: Text(strings.addStep),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
FilledButton.tonalIcon(
|
|
onPressed: saving ? null : onSave,
|
|
icon: saving
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.save_outlined, size: 16),
|
|
label: Text(strings.save),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
FilledButton.icon(
|
|
onPressed: onRun,
|
|
icon: running
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.play_arrow, size: 18),
|
|
label: Text(strings.run),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- file list ---
|
|
|
|
class _FlowFile {
|
|
final String name;
|
|
final String path;
|
|
final int sizeBytes;
|
|
const _FlowFile({
|
|
required this.name,
|
|
required this.path,
|
|
required this.sizeBytes,
|
|
});
|
|
}
|
|
|
|
class _FileList extends StatelessWidget {
|
|
final Future<List<_FlowFile>> filesFuture;
|
|
final String? activeName;
|
|
final FlowEditorStrings strings;
|
|
final void Function(_FlowFile) onOpen;
|
|
final VoidCallback onRefresh;
|
|
|
|
const _FileList({
|
|
required this.filesFuture,
|
|
required this.activeName,
|
|
required this.strings,
|
|
required this.onOpen,
|
|
required this.onRefresh,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Container(
|
|
color: theme.colorScheme.surface,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: FaiSpace.md,
|
|
vertical: FaiSpace.xs,
|
|
),
|
|
decoration: BoxDecoration(
|
|
border: Border(bottom: BorderSide(color: theme.dividerColor)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
strings.listHeader,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: onRefresh,
|
|
tooltip: strings.refresh,
|
|
icon: const Icon(Icons.refresh, size: 16),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(
|
|
minWidth: 28,
|
|
minHeight: 28,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(child: _buildBody(context, theme)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(BuildContext context, ThemeData theme) {
|
|
return FutureBuilder<List<_FlowFile>>(
|
|
future: filesFuture,
|
|
builder: (context, snap) {
|
|
if (snap.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snap.hasError) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(FaiSpace.md),
|
|
child: FaiErrorBox(error: snap.error, isError: true),
|
|
);
|
|
}
|
|
final files = snap.data ?? <_FlowFile>[];
|
|
if (files.isEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(FaiSpace.md),
|
|
child: FaiEmptyState(
|
|
icon: Icons.folder_outlined,
|
|
title: strings.listEmptyTitle,
|
|
hint: strings.listEmptyBody,
|
|
),
|
|
);
|
|
}
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
|
itemCount: files.length,
|
|
itemBuilder: (_, i) {
|
|
final f = files[i];
|
|
final isActive = f.name == activeName;
|
|
return InkWell(
|
|
onTap: () => onOpen(f),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: FaiSpace.md,
|
|
vertical: FaiSpace.sm,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isActive ? theme.colorScheme.secondaryContainer : null,
|
|
border: Border(
|
|
left: BorderSide(
|
|
width: 3,
|
|
color: isActive
|
|
? theme.colorScheme.primary
|
|
: Colors.transparent,
|
|
),
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
f.name,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
Text(
|
|
'${f.sizeBytes} B',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- empty state ---
|
|
|
|
class _EmptyState extends StatelessWidget {
|
|
final FlowEditorStrings strings;
|
|
const _EmptyState({required this.strings});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Container(
|
|
color: theme.colorScheme.surface,
|
|
child: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(FaiSpace.xl),
|
|
child: FaiEmptyState(
|
|
icon: Icons.description_outlined,
|
|
title: strings.emptyTitle,
|
|
hint: strings.emptyBody,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- new flow dialog ---
|
|
|
|
class _NewFlowDialog extends StatefulWidget {
|
|
final FlowEditorStrings strings;
|
|
const _NewFlowDialog({required this.strings});
|
|
|
|
@override
|
|
State<_NewFlowDialog> createState() => _NewFlowDialogState();
|
|
}
|
|
|
|
class _NewFlowDialogState extends State<_NewFlowDialog> {
|
|
final _controller = TextEditingController();
|
|
String? _error;
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _submit() {
|
|
final name = _controller.text.trim();
|
|
if (!RegExp(r'^[a-z0-9_-]+$').hasMatch(name)) {
|
|
setState(() => _error = widget.strings.newDialogHelper);
|
|
return;
|
|
}
|
|
Navigator.pop(context, name);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(widget.strings.newDialogTitle),
|
|
content: TextField(
|
|
autofocus: true,
|
|
controller: _controller,
|
|
decoration: InputDecoration(
|
|
labelText: widget.strings.newDialogLabel,
|
|
helperText: widget.strings.newDialogHelper,
|
|
errorText: _error,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
onSubmitted: (_) => _submit(),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(widget.strings.newDialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: _submit,
|
|
child: Text(widget.strings.newDialogCreate),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|