feat: initial scaffold — swappable flow editor

Extracted from fai/studio's lib/pages/flow_editor.dart so the
editor can be swapped out independently of the host.

Public surface kept minimal — a single FlowEditorPage widget
with three named parameters (initialFlowName, locale, onRun).
The package brings its own design tokens, empty/error
widgets, l10n table; no host-internal types leak through.

Studio depends on this repo via pubspec.yaml git reference.
Forks point Studio at a different URL and rebuild.

See README.md for the swap recipe.

Signed-off-by: F∆I Platform <platform@flemming.ai>
This commit is contained in:
F∆I Platform 2026-05-30 14:33:03 +02:00
commit 51f9a1d2b1
8 changed files with 1128 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
build/
.idea/
.vscode/
*.iml
.DS_Store
pubspec.lock

90
README.md Normal file
View file

@ -0,0 +1,90 @@
# fai_studio_flow_editor
Swappable inline YAML editor for F∆I Studio flows.
## Public surface
```dart
import 'package:fai_studio_flow_editor/fai_studio_flow_editor.dart';
// Studio embeds the page as one of its destinations:
FlowEditorPage(
initialFlowName: 'hello', // optional
locale: FlowEditorLocale.de, // or .en
onRun: (name) => // optional callback
HubService.instance.runSavedFlow(name: name),
)
```
- **`initialFlowName`** — when set, the page loads that flow
from `~/.fai/data/flows/<name>.yaml` on first build. Used
by Studio's Flows-page pencil → editor route push.
- **`locale`** — picks EN or DE for the editor's internal
string table. Studio passes its active locale through.
- **`onRun`** — async callback that runs the named flow
against the host hub and returns the typed outputs map.
When null, the Run button is disabled (e.g. when the
editor is embedded in a context without hub access).
## Behaviour
- File browser (left, 240 px) lists `*.yaml` in
`~/.fai/data/flows/`.
- Code field (right) ships YAML syntax highlighting via
`flutter_code_editor` + the highlight package's yaml
grammar.
- Save writes directly to disk via `dart:io`; the hub picks
up the change on its next `ListFlows` / `RunSavedFlow`
call.
- Cmd+S / Ctrl+S — save. Cmd+Enter / Ctrl+Enter — run.
## Swap it
Studio depends on this package via `pubspec.yaml` git
reference:
```yaml
dependencies:
fai_studio_flow_editor:
git:
url: https://git.flemming.ai/fai/studio-flow-editor
ref: main
```
To use a different editor:
1. Fork this repo (or write your own from scratch).
2. Keep the `FlowEditorPage` constructor signature
(`initialFlowName`, `locale`, `onRun`) — that's the
stable host contract.
3. Point Studio's pubspec at your fork (or local-path
during development):
```yaml
fai_studio_flow_editor:
git:
url: https://your-host/your/flow-editor
ref: main
```
4. Rebuild Studio. Done.
The contract is intentionally minimal: a single page widget
with three parameters. The package brings its own tokens,
its own empty/error widgets, its own l10n table, so the
host doesn't have to share internals. The trade-off is
visual drift if Studio's design tokens change — that's the
deal you accept for a swappable module.
## Develop
```bash
flutter pub get
flutter analyze
flutter test
```
The package's own widget tests live in `test/`. End-to-end
tests against a live hub stay in the host repo (Studio).
## License
Apache-2.0. Same as the rest of the F∆I platform.

View file

@ -0,0 +1,17 @@
/// Inline YAML editor for FI Studio flows swappable.
///
/// Studio depends on this package via pubspec.yaml. To use a
/// different editor, fork this repo, change the implementation,
/// point Studio's pubspec at the fork, rebuild Studio.
///
/// Public surface:
/// * [FlowEditorPage] the page widget Studio embeds as
/// its "Editor" destination + as a route push from the
/// Flows page's pencil icon.
/// * [FlowEditorLocale] the locale the package renders
/// its inline strings in. Studio passes its active
/// Locale through.
library;
export 'src/flow_editor_page.dart' show FlowEditorPage;
export 'src/l10n.dart' show FlowEditorLocale;

View file

@ -0,0 +1,798 @@
/// FlowEditorPage host-agnostic inline YAML editor.
///
/// The page reads + writes flow YAML directly under
/// `~/.fai/data/flows/` via dart:io. The hub picks up changes
/// on its next ListFlows / RunSavedFlow call.
///
/// Studio passes:
/// * [initialFlowName] preload a specific flow.
/// * [locale] render inline strings in EN or DE.
/// * [onRun] async callback that runs the named flow
/// against the host hub. Returns the typed outputs map.
/// When null, the Run button is disabled.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:highlight/languages/yaml.dart';
import 'l10n.dart';
import 'tokens.dart';
import 'widgets.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 {
final String? initialFlowName;
final FlowEditorLocale locale;
final Future<Map<String, Object>> Function(String name)? onRun;
const FlowEditorPage({
super.key,
this.initialFlowName,
this.locale = FlowEditorLocale.en,
this.onRun,
});
@override
State<FlowEditorPage> createState() => _FlowEditorPageState();
}
class _FlowEditorPageState extends State<FlowEditorPage> {
late final CodeController _code;
late final FlowEditorStrings _l;
String? _activeName;
String _loadedText = '';
late Future<List<_FlowFile>> _files;
Object? _runError;
Map<String, Object>? _runOutputs;
bool _running = false;
bool _saving = false;
@override
void initState() {
super.initState();
_l = FlowEditorStrings(widget.locale);
_code = CodeController(text: '', language: yaml);
_code.addListener(_onCodeChange);
_files = _listFiles();
final initial = widget.initialFlowName;
if (initial != null && initial.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _openByName(initial);
});
}
}
@override
void dispose() {
_code.removeListener(_onCodeChange);
_code.dispose();
super.dispose();
}
void _onCodeChange() {
if (mounted) setState(() {});
}
bool get _dirty => _activeName != null && _code.text != _loadedText;
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 dir = Directory(_defaultFlowsDir());
final path = '${dir.path}/$name.yaml';
final f = File(path);
if (!f.existsSync()) return;
final text = await f.readAsString();
if (!mounted) return;
setState(() {
_activeName = name;
_loadedText = text;
_code.text = text;
_runOutputs = null;
_runError = null;
});
}
Future<void> _openFile(_FlowFile f) async {
if (_dirty) {
final keep = await _confirmDiscard();
if (keep == false || !mounted) return;
}
final text = await File(f.path).readAsString();
setState(() {
_activeName = f.name;
_loadedText = text;
_code.text = text;
_runOutputs = null;
_runError = null;
});
}
Future<bool?> _confirmDiscard() async {
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 = _activeName;
if (name == null) return;
setState(() => _saving = true);
try {
final f = File('${_defaultFlowsDir()}/$name.yaml');
await f.writeAsString(_code.text, flush: true);
if (!mounted) return;
setState(() {
_loadedText = _code.text;
_files = _listFiles();
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
} finally {
if (mounted) setState(() => _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)}
inputs:
name:
type: text
steps:
- id: greet
use: debug.echo@^0
with:
text: "Hello, \${{ inputs.name }}!"
outputs:
greeting: \${{ steps.greet.result }}
''';
try {
final dir = Directory(_defaultFlowsDir());
if (!dir.existsSync()) await dir.create(recursive: true);
final f = File('${dir.path}/$name.yaml');
if (f.existsSync()) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_l.alreadyExists(name))),
);
return;
}
await f.writeAsString(template, flush: true);
if (!mounted) return;
setState(() {
_activeName = name;
_loadedText = template;
_code.text = template;
_files = _listFiles();
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}
}
Future<void> _run() async {
final name = _activeName;
final onRun = widget.onRun;
if (name == null || onRun == null) return;
if (_dirty) await _save();
setState(() {
_running = true;
_runOutputs = null;
_runError = null;
});
try {
final out = await onRun(name);
if (!mounted) return;
setState(() => _runOutputs = out);
} catch (e) {
if (!mounted) return;
setState(() => _runError = e);
} finally {
if (mounted) setState(() => _running = false);
}
}
Future<void> _refresh() async {
setState(() => _files = _listFiles());
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final 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(),
};
return Shortcuts(
shortcuts: shortcuts,
child: Actions(
actions: <Type, Action<Intent>>{
_SaveIntent: CallbackAction<_SaveIntent>(
onInvoke: (_) {
if (_activeName != null && !_saving) _save();
return null;
},
),
_RunIntent: CallbackAction<_RunIntent>(
onInvoke: (_) {
if (_activeName != null && !_running && widget.onRun != null) {
_run();
}
return null;
},
),
},
child: Focus(autofocus: true, child: _buildScaffold(theme)),
),
);
}
Widget _buildScaffold(ThemeData theme) {
return Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Toolbar(
strings: _l,
activeName: _activeName,
dirty: _dirty,
saving: _saving,
running: _running,
onBack: Navigator.of(context).canPop()
? () => Navigator.of(context).maybePop()
: null,
onSave: _activeName != null ? _save : null,
onRun: _activeName != null && !_running && widget.onRun != null
? _run
: null,
onNew: _newFlow,
onRefresh: _refresh,
),
const Divider(height: 1),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 240,
child: _FileList(
filesFuture: _files,
activeName: _activeName,
strings: _l,
onOpen: _openFile,
onRefresh: _refresh,
),
),
const VerticalDivider(width: 1),
Expanded(
child: _activeName == null
? _EmptyState(strings: _l)
: _EditorPane(
controller: _code,
theme: theme,
outputs: _runOutputs,
runError: _runError,
running: _running,
strings: _l,
),
),
],
),
),
],
),
);
}
}
class _SaveIntent extends Intent {
const _SaveIntent();
}
class _RunIntent extends Intent {
const _RunIntent();
}
class _FlowFile {
final String name;
final String path;
final int sizeBytes;
const _FlowFile({
required this.name,
required this.path,
required this.sizeBytes,
});
}
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? onRun;
final VoidCallback onNew;
final VoidCallback onRefresh;
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.onRun,
required this.onNew,
required this.onRefresh,
});
@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),
IconButton.outlined(
onPressed: onRefresh,
tooltip: strings.refresh,
icon: const Icon(Icons.refresh, size: 18),
),
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),
),
],
),
);
}
}
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: 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,
),
),
],
),
),
);
},
);
},
),
);
}
}
class _EditorPane extends StatelessWidget {
final CodeController controller;
final ThemeData theme;
final Map<String, Object>? outputs;
final Object? runError;
final bool running;
final FlowEditorStrings strings;
const _EditorPane({
required this.controller,
required this.theme,
required this.outputs,
required this.runError,
required this.running,
required this.strings,
});
@override
Widget build(BuildContext context) {
final mono = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 13,
height: 1.45,
color: theme.colorScheme.onSurface,
);
return Row(
children: [
Expanded(
flex: 3,
child: CodeTheme(
data: CodeThemeData(styles: _yamlStyle(theme)),
child: SingleChildScrollView(
child: CodeField(
controller: controller,
textStyle: mono,
expands: false,
gutterStyle: GutterStyle(
textStyle: mono.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
background: theme.colorScheme.surfaceContainer,
showLineNumbers: true,
),
background: theme.colorScheme.surface,
),
),
),
),
if (outputs != null || runError != null) ...[
const VerticalDivider(width: 1),
SizedBox(
width: 320,
child: _RunResult(
outputs: outputs,
error: runError,
running: running,
strings: strings,
),
),
],
],
);
}
}
class _RunResult extends StatelessWidget {
final Map<String, Object>? outputs;
final Object? error;
final bool running;
final FlowEditorStrings strings;
const _RunResult({
required this.outputs,
required this.error,
required this.running,
required this.strings,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
color: theme.colorScheme.surface,
padding: const EdgeInsets.all(FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
strings.runOutput,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
const SizedBox(height: FaiSpace.sm),
if (running) const LinearProgressIndicator(),
if (error != null)
FaiErrorBox(error: error, isError: true)
else if (outputs != null)
Expanded(
child: ListView(
children: [
for (final entry in outputs!.entries)
Padding(
padding: const EdgeInsets.only(bottom: FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.key,
style: theme.textTheme.titleSmall?.copyWith(
fontFamily: 'monospace',
),
),
const SizedBox(height: FaiSpace.xs),
SelectableText(
entry.value.toString(),
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: theme.colorScheme.onSurface,
),
),
],
),
),
],
),
),
],
),
);
}
}
class _EmptyState extends StatelessWidget {
final FlowEditorStrings strings;
const _EmptyState({required this.strings});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(FaiSpace.xxl),
child: FaiEmptyState(
icon: Icons.edit_note,
title: strings.emptyTitle,
hint: strings.emptyBody,
),
),
);
}
}
class _NewFlowDialog extends StatefulWidget {
final FlowEditorStrings strings;
const _NewFlowDialog({required this.strings});
@override
State<_NewFlowDialog> createState() => _NewFlowDialogState();
}
class _NewFlowDialogState extends State<_NewFlowDialog> {
final _ctrl = TextEditingController();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final s = widget.strings;
return AlertDialog(
title: Text(s.newDialogTitle),
content: TextField(
controller: _ctrl,
autofocus: true,
decoration: InputDecoration(
labelText: s.newDialogLabel,
hintText: 'my-flow',
helperText: s.newDialogHelper,
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[a-z0-9_\-]')),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(s.newDialogCancel),
),
FilledButton(
onPressed: () {
if (_ctrl.text.isNotEmpty) {
Navigator.pop(context, _ctrl.text);
}
},
child: Text(s.newDialogCreate),
),
],
);
}
}
Map<String, TextStyle> _yamlStyle(ThemeData theme) {
final base = TextStyle(
fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface,
);
return {
'root': base,
'attr': base.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
'string': base.copyWith(color: theme.colorScheme.tertiary),
'number': base.copyWith(color: theme.colorScheme.secondary),
'literal': base.copyWith(color: theme.colorScheme.secondary),
'comment': base.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
'meta': base.copyWith(color: theme.colorScheme.primary),
'subst': base.copyWith(
color: theme.colorScheme.error,
fontWeight: FontWeight.w600,
),
};
}

59
lib/src/l10n.dart Normal file
View file

@ -0,0 +1,59 @@
/// Internal locale-aware string table. Host passes its
/// active [FlowEditorLocale]; the editor renders the strings
/// it needs. Adding a string here is a non-breaking change;
/// adding a locale is a minor version bump.
library;
enum FlowEditorLocale { en, de }
class FlowEditorStrings {
final FlowEditorLocale locale;
const FlowEditorStrings(this.locale);
String _t(String en, String de) => locale == FlowEditorLocale.de ? de : en;
String get backTooltip => _t('Back', 'Zurück');
String get newFlow => _t('New flow', 'Neuer Flow');
String get save => _t('Save', 'Speichern');
String get run => _t('Run', 'Ausführen');
String get refresh => _t('Refresh file list', 'Datei-Liste neu laden');
String get copy => _t('Copy', 'Kopieren');
String get runOutput => _t('RUN OUTPUT', 'LAUF-ERGEBNIS');
String get emptyTitle => _t('No flow open', 'Kein Flow geöffnet');
String get emptyBody => _t(
'Pick one from the left, or click New flow to start a fresh one.',
'Wähle links einen aus oder klicke Neuer Flow um einen neuen anzulegen.',
);
String get listEmptyTitle =>
_t('No saved flows', 'Keine gespeicherten Flows');
String get listEmptyBody => _t(
'Click New flow to scaffold one from a template.',
'Klicke Neuer Flow um aus einer Vorlage zu starten.',
);
String get discardTitle => _t(
'Discard unsaved changes?',
'Ungespeicherte Änderungen verwerfen?',
);
String get discardBody => _t(
"Switching files will throw away the edits you haven't saved.",
'Beim Wechsel der Datei gehen alle noch nicht gespeicherten Änderungen verloren.',
);
String get discardKeep => _t('Keep editing', 'Weiterbearbeiten');
String get discardThrow => _t('Throw away', 'Verwerfen');
String get newDialogTitle => _t('New flow', 'Neuer Flow');
String get newDialogLabel => _t('Flow name', 'Flow-Name');
String get newDialogHelper => _t(
'lowercase letters, digits, hyphen, underscore',
'Kleinbuchstaben, Ziffern, Bindestrich, Unterstrich',
);
String get newDialogCancel => _t('Cancel', 'Abbrechen');
String get newDialogCreate => _t('Create', 'Anlegen');
String newTemplateComment(String name) => _t(
"Generated by Studio's Flow editor. Edit, save, run.",
'Von Studios Flow-Editor erstellt. Bearbeiten, speichern, ausführen.',
);
String alreadyExists(String name) => _t(
'A flow named "$name" already exists.',
'Ein Flow namens "$name" existiert bereits.',
);
}

26
lib/src/tokens.dart Normal file
View file

@ -0,0 +1,26 @@
/// Tokens duplicated from Studio's tokens.dart so the editor
/// stays a closed-set package. If Studio's tokens drift, the
/// editor stays visually consistent with itself rather than
/// pulling host-specific values that's part of the "swap-
/// the-package" contract.
library;
class FaiSpace {
static const double xs = 4;
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 20;
static const double xxl = 24;
}
class FaiRadius {
static const double sm = 6;
static const double md = 10;
static const double lg = 16;
}
class FaiMotion {
static const Duration fast = Duration(milliseconds: 120);
static const Duration base = Duration(milliseconds: 200);
}

103
lib/src/widgets.dart Normal file
View file

@ -0,0 +1,103 @@
/// Minimal stand-ins for the FaiEmptyState + FaiErrorBox
/// widgets Studio ships. The editor brings its own so the
/// package compiles standalone host-styled variants are
/// a future plugin-API extension.
library;
import 'package:flutter/material.dart';
import 'tokens.dart';
class FaiEmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String? hint;
const FaiEmptyState({
super.key,
required this.icon,
required this.title,
this.hint,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(FaiSpace.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
icon,
size: 40,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: FaiSpace.md),
Text(
title,
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (hint != null) ...[
const SizedBox(height: FaiSpace.sm),
Text(
hint!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
}
class FaiErrorBox extends StatelessWidget {
final Object? error;
final bool isError;
final double? maxHeight;
const FaiErrorBox({
super.key,
required this.error,
this.isError = true,
this.maxHeight,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fg = isError ? theme.colorScheme.error : theme.colorScheme.onSurface;
return Container(
constraints: maxHeight == null
? null
: BoxConstraints(maxHeight: maxHeight!),
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: isError
? theme.colorScheme.errorContainer.withValues(alpha: 0.4)
: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: isError
? theme.colorScheme.error.withValues(alpha: 0.4)
: theme.colorScheme.outlineVariant,
),
),
child: SingleChildScrollView(
child: SelectableText(
error?.toString() ?? '',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: fg,
),
),
),
);
}
}

23
pubspec.yaml Normal file
View file

@ -0,0 +1,23 @@
name: fai_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.1.0
publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor
environment:
sdk: ^3.11.0-200.1.beta
flutter: '>=3.30.0'
dependencies:
flutter:
sdk: flutter
# CodeMirror-style code editor + the highlight package's
# YAML grammar. Both are pure Dart so the editor stays
# WebView-free and ships in the same binary.
flutter_code_editor: ^0.3.5
highlight: ^0.7.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0