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:
commit
51f9a1d2b1
8 changed files with 1128 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
90
README.md
Normal 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.
|
||||||
17
lib/fai_studio_flow_editor.dart
Normal file
17
lib/fai_studio_flow_editor.dart
Normal file
|
|
@ -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;
|
||||||
798
lib/src/flow_editor_page.dart
Normal file
798
lib/src/flow_editor_page.dart
Normal 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
59
lib/src/l10n.dart
Normal 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
26
lib/src/tokens.dart
Normal 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
103
lib/src/widgets.dart
Normal 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
23
pubspec.yaml
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue