chain-studio-flow-editor/lib/src/widgets/run_tab.dart
flemming-it ae09a55146 feat(run): host-injected native file picker for flow file inputs
The Run tab asked the operator to type an absolute path into a text
field — with no file dialog anywhere. FlowEditorPage now accepts an
onPickFile callback (PickedFileData: name + bytes) that hosts wire
to their native picker; the path-entry dialog remains only as the
no-host fallback. 0.23.0.

Signed-off-by: flemming-it <sf@flemming.it>
2026-07-17 01:04:40 +02:00

1002 lines
31 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// RunTab — the third tab. Shows:
//
// 1. A form for the flow's declared inputs (one field per
// `inputs:` entry). Text inputs use a TextField; bytes
// inputs use a "Choose file…" button + filename badge.
//
// 2. A Start button that calls the host's FlowRunDriver
// with the collected inputs.
//
// 3. A live step list driven by the driver's event stream,
// identical visually to the CLI's `fai run` block:
// ◻ pending, · running, ✔ done + duration, ✗ failed,
// ⏸ awaiting approval.
//
// 4. The flow's outputs once the run resolves.
//
// The tab requires the file to be saved on disk — the hub's
// runSavedFlow reads from `~/.chain/data/flows/<name>.yaml`,
// not from the in-memory buffer. The dirty banner reminds
// the operator to save before running so they don't run a
// stale version by surprise.
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../editor_controller.dart';
import '../l10n.dart';
import '../model/flow_graph.dart';
import '../run_driver.dart';
import '../tokens.dart';
class RunTab extends StatefulWidget {
final FlowEditorController controller;
final FlowEditorStrings strings;
final FlowRunDriver? driver;
/// Host-side native file picker; null keeps the manual
/// path-entry fallback dialog.
final PickFileCallback? onPickFile;
const RunTab({
super.key,
required this.controller,
required this.strings,
this.driver,
this.onPickFile,
});
@override
State<RunTab> createState() => _RunTabState();
}
class _RunTabState extends State<RunTab> {
final Map<String, TextEditingController> _textInputs = {};
final Map<String, _FilePick> _fileInputs = {};
StreamSubscription<FlowRunEvent>? _eventSub;
bool _running = false;
Map<String, FlowOutputValue>? _outputs;
Object? _error;
// Insertion-ordered: shows steps in the order the hub
// actually started them, matching the CLI rendering.
final Map<String, _StepState> _liveSteps = {};
String? _runFlowName;
@override
void initState() {
super.initState();
widget.controller.addListener(_onControllerChanged);
_syncInputs();
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChanged);
_eventSub?.cancel();
for (final c in _textInputs.values) {
c.dispose();
}
super.dispose();
}
void _onControllerChanged() {
if (!mounted) return;
_syncInputs();
setState(() {});
}
void _syncInputs() {
final graph = widget.controller.graph;
final keep = <String>{};
for (final entry in graph.inputs.entries) {
keep.add(entry.key);
if (entry.value.type == 'bytes' || entry.value.type == 'file') {
// bytes input — keep its file pick if already chosen.
_fileInputs.putIfAbsent(entry.key, () => const _FilePick.empty());
} else {
_textInputs.putIfAbsent(
entry.key,
() => TextEditingController(text: entry.value.defaultValue ?? ''),
);
}
}
// Drop controllers for inputs that no longer exist.
_textInputs.removeWhere((k, c) {
if (keep.contains(k)) return false;
c.dispose();
return true;
});
_fileInputs.removeWhere((k, _) => !keep.contains(k));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final controller = widget.controller;
final strings = widget.strings;
if (controller.activeName == null) {
return _empty(theme, strings.runNoFlow);
}
final graph = controller.graph;
return Container(
color: theme.colorScheme.surface,
child: ListView(
padding: const EdgeInsets.all(FaiSpace.lg),
children: [
_titleRow(theme),
const SizedBox(height: FaiSpace.md),
if (controller.isDirty)
Container(
padding: const EdgeInsets.all(FaiSpace.sm),
margin: const EdgeInsets.only(bottom: FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: theme.colorScheme.onTertiaryContainer,
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Text(
strings.runUnsavedBanner,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onTertiaryContainer,
),
),
),
],
),
),
_section(theme, strings.runInputs),
if (graph.inputs.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
child: Text(
'',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else
for (final entry in graph.inputs.entries)
_inputField(theme, entry.key, entry.value),
const SizedBox(height: FaiSpace.md),
_startButtonRow(theme, strings),
if (_liveSteps.isNotEmpty || _running) ...[
const SizedBox(height: FaiSpace.lg),
_section(
theme,
_running ? strings.runTitleRunning : strings.runTitleDone,
),
_stepList(theme),
],
if (_outputs != null) ...[
const SizedBox(height: FaiSpace.lg),
_section(theme, strings.runOutputs),
for (final entry in _outputs!.entries)
_outputRow(theme, entry.key, entry.value),
],
if (_error != null) ...[
const SizedBox(height: FaiSpace.lg),
_section(theme, strings.runTitleFailed),
_CopyableErrorBox(
text: _error.toString(),
strings: widget.strings,
),
],
],
),
);
}
Widget _titleRow(ThemeData theme) {
final title = _running
? widget.strings.runTitleRunning
: _error != null
? widget.strings.runTitleFailed
: _outputs != null
? widget.strings.runTitleDone
: widget.strings.runTitleIdle;
return Text(
title,
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
);
}
Widget _section(ThemeData theme, String label) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
);
}
Widget _empty(ThemeData theme, String hint) {
return Container(
color: theme.colorScheme.surface,
padding: const EdgeInsets.all(FaiSpace.lg),
child: Center(
child: Text(
hint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
Widget _inputField(ThemeData theme, String name, FlowInput input) {
final type = input.type;
if (type == 'bytes' || type == 'file') {
final pick = _fileInputs[name] ?? const _FilePick.empty();
return Padding(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
child: Row(
children: [
SizedBox(
width: 140,
child: Text(
'$name ($type)',
style: const TextStyle(fontFamily: 'monospace'),
),
),
const SizedBox(width: FaiSpace.sm),
OutlinedButton.icon(
onPressed: () => _pickFile(name),
icon: const Icon(Icons.attach_file, size: 16),
label: Text(
pick.fileName ?? widget.strings.runChooseFile,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
child: Row(
children: [
SizedBox(
width: 140,
child: Text(
'$name ($type)',
style: const TextStyle(fontFamily: 'monospace'),
),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: TextField(
controller: _textInputs[name],
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
decoration: InputDecoration(
hintText: input.hint,
isDense: true,
border: const OutlineInputBorder(),
),
),
),
],
),
);
}
Widget _stepList(ThemeData theme) {
final entries = _liveSteps.entries.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final entry in entries) _stepRow(theme, entry.key, entry.value),
],
);
}
Widget _stepRow(ThemeData theme, String id, _StepState state) {
final strings = widget.strings;
final IconData glyph;
final Color color;
String suffix = '';
switch (state.kind) {
case _StepKind.running:
glyph = Icons.refresh;
color = theme.colorScheme.primary;
case _StepKind.done:
glyph = Icons.check;
color = Colors.green.shade600;
suffix = ' ${(state.durationMs ?? 0) / 1000.0}s';
case _StepKind.error:
glyph = Icons.close;
color = theme.colorScheme.error;
case _StepKind.awaiting:
glyph = Icons.pause_circle_outline;
color = theme.colorScheme.tertiary;
suffix = ' ${strings.runAwaitingApproval}';
}
final isError = state.kind == _StepKind.error &&
state.error != null &&
state.error!.isNotEmpty;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(glyph, size: 16, color: color),
const SizedBox(width: FaiSpace.sm),
Flexible(
child: SelectableText(
'$id$suffix',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: color,
),
),
),
],
),
if (isError) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.only(left: 24),
child: _CopyableErrorBox(
text: state.error!,
strings: widget.strings,
),
),
],
if (state.kind == _StepKind.awaiting) ...[
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.only(left: 24),
child: _InlineApprovalCard(
stepId: id,
flowName: _runFlowName ?? '',
driver: widget.driver,
strings: widget.strings,
onDecided: () {
// After approve/reject the hub keeps polling
// and either resumes the flow or fails it; the
// event stream surfaces the outcome. Nothing to
// do here besides redraw so the buttons go away.
if (mounted) setState(() {});
},
),
),
],
],
),
);
}
/// Run button row. Disabled when the buffer carries analyzer
/// errors — an info chip next to the button names the count
/// and (when collapsed) the first message so the operator
/// sees the blocker without scrolling to the bottom strip.
Widget _startButtonRow(ThemeData theme, FlowEditorStrings strings) {
final errors = widget.controller.analyzerErrorCount;
final blocked = errors > 0;
return Row(
children: [
Tooltip(
message: blocked ? strings.runErrorsBlockTooltip(errors) : '',
child: FilledButton.icon(
onPressed: _running || widget.driver == null || blocked
? null
: _start,
icon: _running
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
blocked ? Icons.block : Icons.play_arrow,
size: 18,
),
label: Text(strings.runStart),
),
),
if (blocked) ...[
const SizedBox(width: FaiSpace.sm),
Flexible(
child: Text(
'${strings.runErrorsBlockRun(errors)} · '
'${strings.runErrorsBlockHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
overflow: TextOverflow.ellipsis,
),
),
],
],
);
}
Widget _outputRow(ThemeData theme, String name, FlowOutputValue value) {
return Padding(
padding: const EdgeInsets.only(bottom: FaiSpace.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
const SizedBox(height: 2),
_outputBody(theme, value),
],
),
);
}
Widget _outputBody(ThemeData theme, FlowOutputValue value) {
if (value is FlowOutputText) {
return SelectableText(
value.value,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
);
}
if (value is FlowOutputJson) {
return SelectableText(
value.value.toString(),
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
);
}
if (value is FlowOutputBytes) {
return Text(
'${value.value.length} bytes · ${value.mimeType}',
style: theme.textTheme.bodySmall,
);
}
return const SizedBox.shrink();
}
Future<void> _pickFile(String name) async {
// Host-injected native picker first — typing an absolute path
// by hand is a last resort, not an input method.
final hostPick = widget.onPickFile;
if (hostPick != null) {
final picked = await hostPick();
if (picked == null || !mounted) return;
setState(() {
_fileInputs[name] = _FilePick(
fileName: picked.fileName,
bytes: picked.bytes,
);
});
return;
}
// Fallback without a host picker: manual path entry.
final controller = TextEditingController();
final ctx = context;
final result = await showDialog<String>(
context: ctx,
builder: (_) => AlertDialog(
title: Text(widget.strings.runChooseFile),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: '/path/to/file',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, null),
child: Text(widget.strings.newDialogCancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
child: const Text('OK'),
),
],
),
);
if (result == null || result.isEmpty || !mounted) return;
try {
final file = File(result);
if (!file.existsSync()) return;
final bytes = await file.readAsBytes();
setState(() {
_fileInputs[name] = _FilePick(
fileName: file.uri.pathSegments.last,
bytes: Uint8List.fromList(bytes),
);
});
} catch (_) {
// swallow — file unreadable, leave the pick empty
}
}
Future<void> _start() async {
final driver = widget.driver;
if (driver == null) return;
final flowName = widget.controller.activeName;
if (flowName == null) return;
setState(() {
_running = true;
_outputs = null;
_error = null;
_liveSteps.clear();
_runFlowName = flowName;
});
widget.controller.running = true;
// Subscribe BEFORE submitting so the first event isn't
// lost to the broadcast's dead-letter.
_eventSub?.cancel();
_eventSub = driver.events().listen(_onEvent);
final textInputs = <String, String>{
for (final entry in _textInputs.entries) entry.key: entry.value.text,
};
final fileInputs = <String, Uint8List>{};
final fileMimes = <String, String>{};
for (final entry in _fileInputs.entries) {
final bytes = entry.value.bytes;
if (bytes == null) continue;
fileInputs[entry.key] = bytes;
fileMimes[entry.key] = _mimeFor(entry.value.fileName ?? '');
}
try {
final outputs = await driver.runFlow(
flowName: flowName,
textInputs: textInputs,
fileInputs: fileInputs,
fileMimes: fileMimes,
);
if (!mounted) return;
setState(() {
_running = false;
_outputs = outputs;
});
} catch (e) {
if (!mounted) return;
setState(() {
_running = false;
_error = e;
});
} finally {
widget.controller.running = false;
_eventSub?.cancel();
_eventSub = null;
}
}
void _onEvent(FlowRunEvent event) {
final flowName = _runFlowName;
if (flowName == null) return;
if (event.flowName != flowName) return;
if (!mounted) return;
setState(() {
switch (event) {
case StepStarted():
_liveSteps[event.stepId] = const _StepState(kind: _StepKind.running);
widget.controller.updateStepStatus(
event.stepId,
StepRunStatus.running,
);
case StepCompleted():
_liveSteps[event.stepId] = _StepState(
kind: _StepKind.done,
durationMs: event.durationMs,
);
widget.controller.updateStepStatus(event.stepId, StepRunStatus.done);
case StepFailed():
_liveSteps[event.stepId] = _StepState(
kind: _StepKind.error,
error: event.error,
);
widget.controller.updateStepStatus(
event.stepId,
StepRunStatus.failed,
);
case StepAwaitingApproval():
_liveSteps[event.stepId] = const _StepState(kind: _StepKind.awaiting);
widget.controller.updateStepStatus(
event.stepId,
StepRunStatus.awaiting,
);
}
});
}
String _mimeFor(String name) {
final lower = name.toLowerCase();
if (lower.endsWith('.pdf')) return 'application/pdf';
if (lower.endsWith('.docx')) {
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
}
if (lower.endsWith('.txt')) return 'text/plain';
if (lower.endsWith('.json')) return 'application/json';
return 'application/octet-stream';
}
}
class _FilePick {
final String? fileName;
final Uint8List? bytes;
const _FilePick({this.fileName, this.bytes});
const _FilePick.empty() : fileName = null, bytes = null;
}
enum _StepKind { running, done, error, awaiting }
class _StepState {
final _StepKind kind;
final int? durationMs;
final String? error;
const _StepState({required this.kind, this.durationMs, this.error});
}
/// Selectable error box used for the run-failure detail + the
/// per-step failure detail. SelectableText + an explicit Copy
/// button cover both keyboard and trackpad operators — the
/// implicit selection gesture is too easy to miss on a long
/// failure message. Matches the visual treatment Studio's
/// `ChainErrorBox` uses elsewhere; kept local to this package
/// so flow_editor doesn't depend on Studio internals.
class _CopyableErrorBox extends StatefulWidget {
final String text;
final FlowEditorStrings strings;
const _CopyableErrorBox({required this.text, required this.strings});
@override
State<_CopyableErrorBox> createState() => _CopyableErrorBoxState();
}
class _CopyableErrorBoxState extends State<_CopyableErrorBox> {
bool _justCopied = false;
Future<void> _copy() async {
await Clipboard.setData(ClipboardData(text: widget.text));
if (!mounted) return;
setState(() => _justCopied = true);
Future.delayed(const Duration(seconds: 2), () {
if (mounted) setState(() => _justCopied = false);
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(
FaiSpace.sm,
FaiSpace.xs,
FaiSpace.xs,
FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.4),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: Tooltip(
message:
_justCopied ? widget.strings.runCopied : widget.strings.runCopy,
child: IconButton(
icon: Icon(
_justCopied ? Icons.check : Icons.content_copy,
size: 14,
),
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
onPressed: _copy,
),
),
),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: Scrollbar(
child: SingleChildScrollView(
child: SelectableText(
widget.text,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontFamilyFallback: const [
'Menlo',
'Consolas',
'Courier New',
'monospace',
],
fontSize: 12,
color: theme.colorScheme.onErrorContainer,
),
),
),
),
),
],
),
);
}
}
/// Inline Approve / Reject card rendered under a step that
/// the hub has paused on `system.approval@^0`. Removes the
/// "go to the Approvals page" detour the operator otherwise
/// has to take — the same decision can happen in-context while
/// the rest of the run is on screen.
///
/// Lifecycle:
/// 1. Card mounts in awaiting-state with a fetch-in-flight
/// indicator.
/// 2. Driver returns the pending approval id for this
/// (flowName, stepId) pair. Card switches to the Approve /
/// Reject form.
/// 3. Operator submits a decision. Card disables further
/// interaction and waits — the next event (step.approved /
/// step.rejected) is what causes the hub to resume the
/// flow, and the StepStarted/StepFailed event will redraw
/// the parent step row + remove this card.
class _InlineApprovalCard extends StatefulWidget {
final String stepId;
final String flowName;
final FlowRunDriver? driver;
final FlowEditorStrings strings;
final VoidCallback onDecided;
const _InlineApprovalCard({
required this.stepId,
required this.flowName,
required this.driver,
required this.strings,
required this.onDecided,
});
@override
State<_InlineApprovalCard> createState() => _InlineApprovalCardState();
}
class _InlineApprovalCardState extends State<_InlineApprovalCard> {
String? _approvalId;
bool _fetching = true;
bool _submitting = false;
bool _submitted = false;
String? _error;
late final TextEditingController _reasonCtrl;
late final TextEditingController _reviewerCtrl;
@override
void initState() {
super.initState();
_reasonCtrl = TextEditingController();
_reviewerCtrl = TextEditingController(text: _defaultReviewer());
_fetch();
}
@override
void dispose() {
_reasonCtrl.dispose();
_reviewerCtrl.dispose();
super.dispose();
}
String _defaultReviewer() {
final user = Platform.environment['USER'] ??
Platform.environment['USERNAME'] ??
'studio';
return '$user@studio';
}
Future<void> _fetch() async {
final driver = widget.driver;
if (driver == null) {
setState(() => _fetching = false);
return;
}
// The hub creates the approval row when the flow hits the
// awaiting state. Race against the event arrival: poll up
// to 5 × 600 ms before giving up.
for (var i = 0; i < 5; i++) {
try {
final id = await driver.pendingApprovalIdForStep(
flowName: widget.flowName,
stepId: widget.stepId,
);
if (!mounted) return;
if (id != null) {
setState(() {
_approvalId = id;
_fetching = false;
});
return;
}
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_fetching = false;
});
return;
}
await Future.delayed(const Duration(milliseconds: 600));
}
if (mounted) setState(() => _fetching = false);
}
Future<void> _decide({required bool approve}) async {
final id = _approvalId;
final driver = widget.driver;
if (id == null || driver == null) return;
setState(() => _submitting = true);
try {
if (approve) {
await driver.approveApproval(
approvalId: id,
reviewer: _reviewerCtrl.text.trim(),
);
} else {
await driver.rejectApproval(
approvalId: id,
reviewer: _reviewerCtrl.text.trim(),
reason: _reasonCtrl.text.trim(),
);
}
if (!mounted) return;
setState(() {
_submitted = true;
_submitting = false;
});
widget.onDecided();
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_submitting = false;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final strings = widget.strings;
return Container(
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.tertiary.withValues(alpha: 0.4),
),
),
child: _fetching
? Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: FaiSpace.sm),
Text(strings.runAwaitingApproval),
],
)
: _submitted
? Row(
children: [
Icon(
Icons.check_circle_outline,
size: 16,
color: theme.colorScheme.tertiary,
),
const SizedBox(width: FaiSpace.sm),
Flexible(child: Text(strings.approvalDecided)),
],
)
: _approvalId == null
? Row(
children: [
Icon(
Icons.hourglass_empty,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
Flexible(
child: Text(
_error ?? strings.approvalNotFound,
style: theme.textTheme.bodySmall,
),
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _reviewerCtrl,
decoration: InputDecoration(
labelText: strings.approvalReviewerLabel,
border: const OutlineInputBorder(),
isDense: true,
),
),
),
const SizedBox(width: FaiSpace.md),
Expanded(
flex: 2,
child: TextField(
controller: _reasonCtrl,
decoration: InputDecoration(
labelText: strings.approvalReasonLabel,
border: const OutlineInputBorder(),
isDense: true,
),
),
),
],
),
if (_error != null) ...[
const SizedBox(height: FaiSpace.sm),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
const SizedBox(height: FaiSpace.sm),
Row(
children: [
FilledButton.icon(
onPressed: _submitting
? null
: () => _decide(approve: true),
icon: const Icon(Icons.check, size: 16),
label: Text(
_submitting
? strings.approvalSubmitting
: strings.approvalApprove,
),
),
const SizedBox(width: FaiSpace.sm),
OutlinedButton.icon(
onPressed: _submitting
? null
: () => _decide(approve: false),
icon: const Icon(Icons.close, size: 16),
label: Text(strings.approvalReject),
),
],
),
],
),
);
}
}