From 51f9a1d2b1a292a109457ae29dd4e530397fd1c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=E2=88=86I=20Platform?= Date: Sat, 30 May 2026 14:33:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20scaffold=20=E2=80=94=20swappa?= =?UTF-8?q?ble=20flow=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 12 + README.md | 90 ++++ lib/fai_studio_flow_editor.dart | 17 + lib/src/flow_editor_page.dart | 798 ++++++++++++++++++++++++++++++++ lib/src/l10n.dart | 59 +++ lib/src/tokens.dart | 26 ++ lib/src/widgets.dart | 103 +++++ pubspec.yaml | 23 + 8 files changed, 1128 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 lib/fai_studio_flow_editor.dart create mode 100644 lib/src/flow_editor_page.dart create mode 100644 lib/src/l10n.dart create mode 100644 lib/src/tokens.dart create mode 100644 lib/src/widgets.dart create mode 100644 pubspec.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2c2267 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ +.idea/ +.vscode/ +*.iml +.DS_Store +pubspec.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c7e61b --- /dev/null +++ b/README.md @@ -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/.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. diff --git a/lib/fai_studio_flow_editor.dart b/lib/fai_studio_flow_editor.dart new file mode 100644 index 0000000..7a21f46 --- /dev/null +++ b/lib/fai_studio_flow_editor.dart @@ -0,0 +1,17 @@ +/// Inline YAML editor for F∆I 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; diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart new file mode 100644 index 0000000..0f3f301 --- /dev/null +++ b/lib/src/flow_editor_page.dart @@ -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> Function(String name)? onRun; + + const FlowEditorPage({ + super.key, + this.initialFlowName, + this.locale = FlowEditorLocale.en, + this.onRun, + }); + + @override + State createState() => _FlowEditorPageState(); +} + +class _FlowEditorPageState extends State { + late final CodeController _code; + late final FlowEditorStrings _l; + String? _activeName; + String _loadedText = ''; + late Future> _files; + Object? _runError; + Map? _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> _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() + .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 _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 _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 _confirmDiscard() async { + return showDialog( + 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 _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 _newFlow() async { + final name = await showDialog( + 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 _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 _refresh() async { + setState(() => _files = _listFiles()); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final shortcuts = { + 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: >{ + _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> 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>( + 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? 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? 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 _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, + ), + }; +} diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart new file mode 100644 index 0000000..45d1387 --- /dev/null +++ b/lib/src/l10n.dart @@ -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.', + ); +} diff --git a/lib/src/tokens.dart b/lib/src/tokens.dart new file mode 100644 index 0000000..882d4d7 --- /dev/null +++ b/lib/src/tokens.dart @@ -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); +} diff --git a/lib/src/widgets.dart b/lib/src/widgets.dart new file mode 100644 index 0000000..8e14ffc --- /dev/null +++ b/lib/src/widgets.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..e0f65d0 --- /dev/null +++ b/pubspec.yaml @@ -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