Some checks failed
Security / Security check (push) Failing after 2s
The Studio design system, widgets and helpers carried a Fai* / fai_ prefix (FaiSpace, FaiColors, FaiTheme, FaiLog, 17 fai_*.dart files, the faiBinary* l10n keys). Studio is the Ch∆In product, so rename them to Chain* / chain_ — carefully preserving English fail/failure/failed. Also fix stale references: the 'fai' binary in l10n strings -> 'chain', FAI_* env vars (FAI_BIN/DATA_DIR/MODULES_DIR/TODAY/BOOTSTRAP_TOKEN) -> CHAIN_*, fai_platform -> fai_chain, fai_hub -> chain_hub. Vendor security-hook tooling (FAI_BANNED_TERMS_FILE) + the .fai bundle ext left. flutter analyze + test: clean (20 passed). Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
872 lines
28 KiB
Dart
872 lines
28 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../data/hub.dart';
|
||
import '../l10n/app_localizations.dart';
|
||
import '../theme/theme.dart';
|
||
import '../theme/tokens.dart';
|
||
import '../widgets/widgets.dart';
|
||
import 'welcome.dart' show showFaiDoc;
|
||
|
||
class ApprovalsPage extends StatefulWidget {
|
||
const ApprovalsPage({super.key});
|
||
|
||
@override
|
||
State<ApprovalsPage> createState() => _ApprovalsPageState();
|
||
}
|
||
|
||
class _ApprovalsPageState extends State<ApprovalsPage>
|
||
with SingleTickerProviderStateMixin {
|
||
late final TabController _tab;
|
||
late Future<List<ApprovalRecord>> _pendingFuture;
|
||
late Future<List<ApprovalRecord>> _historyFuture;
|
||
|
||
/// IDs the operator currently has multi-selected for a
|
||
/// batch operation. Empty set hides the batch action bar
|
||
/// and falls back to per-row Approve / Reject buttons.
|
||
final Set<String> _selectedIds = <String>{};
|
||
bool _batchInFlight = false;
|
||
// Reviewer identity recorded in the audit log. Defaults to
|
||
// the OS user (closest stable identity Studio has without an
|
||
// auth backend); operators can override it per session.
|
||
late final String _reviewer = _defaultReviewer();
|
||
|
||
static String _defaultReviewer() {
|
||
final user =
|
||
Platform.environment['USER'] ??
|
||
Platform.environment['USERNAME'] ??
|
||
'studio';
|
||
return '$user@studio';
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_tab = TabController(length: 2, vsync: this);
|
||
_refresh();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_tab.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _refresh() {
|
||
setState(() {
|
||
_pendingFuture = HubService.instance.listApprovalsRecords(
|
||
statuses: const ['pending'],
|
||
);
|
||
_historyFuture = HubService.instance.listApprovalsRecords(
|
||
statuses: const ['approved', 'rejected', 'expired'],
|
||
limit: 200,
|
||
);
|
||
});
|
||
}
|
||
|
||
Future<void> _approve(ApprovalRecord a) async {
|
||
final l = AppLocalizations.of(context)!;
|
||
try {
|
||
await HubService.instance.approve(a.id, _reviewer);
|
||
_toast(l.approvalsApprovedToast(a.flowName, a.stepId));
|
||
_refresh();
|
||
} catch (e) {
|
||
_toast(l.approvalsApproveFailed(e.toString()));
|
||
}
|
||
}
|
||
|
||
Future<void> _reject(ApprovalRecord a) async {
|
||
final l = AppLocalizations.of(context)!;
|
||
final reason = await _promptReason(context);
|
||
if (reason == null || reason.isEmpty) return;
|
||
try {
|
||
await HubService.instance.reject(a.id, _reviewer, reason);
|
||
_toast(l.approvalsRejectedToast(a.flowName, a.stepId));
|
||
_refresh();
|
||
} catch (e) {
|
||
_toast(l.approvalsRejectFailed(e.toString()));
|
||
}
|
||
}
|
||
|
||
void _toggleSelection(String id) {
|
||
setState(() {
|
||
if (!_selectedIds.add(id)) _selectedIds.remove(id);
|
||
});
|
||
}
|
||
|
||
void _selectAll(Iterable<ApprovalRecord> all) {
|
||
setState(() {
|
||
_selectedIds
|
||
..clear()
|
||
..addAll(all.map((a) => a.id));
|
||
});
|
||
}
|
||
|
||
void _clearSelection() => setState(() => _selectedIds.clear());
|
||
|
||
/// Loops the picked records sequentially through the
|
||
/// per-record approve / reject calls so a partial failure
|
||
/// surfaces with a clear "X done, Y failed" toast rather
|
||
/// than a confusing all-or-nothing rollback. Selection
|
||
/// drains as items succeed.
|
||
Future<void> _batchApprove(List<ApprovalRecord> picked) async {
|
||
final l = AppLocalizations.of(context)!;
|
||
setState(() => _batchInFlight = true);
|
||
var ok = 0;
|
||
var failed = 0;
|
||
for (final a in picked) {
|
||
try {
|
||
await HubService.instance.approve(a.id, _reviewer);
|
||
ok += 1;
|
||
_selectedIds.remove(a.id);
|
||
} catch (_) {
|
||
failed += 1;
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _batchInFlight = false);
|
||
_toast(
|
||
failed == 0
|
||
? l.approvalsBatchApproveDoneToast(ok)
|
||
: l.approvalsBatchPartialFailure(ok, failed),
|
||
);
|
||
_refresh();
|
||
}
|
||
|
||
Future<void> _batchReject(List<ApprovalRecord> picked) async {
|
||
final l = AppLocalizations.of(context)!;
|
||
final reason = await _promptReason(context);
|
||
if (reason == null || reason.isEmpty) return;
|
||
setState(() => _batchInFlight = true);
|
||
var ok = 0;
|
||
var failed = 0;
|
||
for (final a in picked) {
|
||
try {
|
||
await HubService.instance.reject(a.id, _reviewer, reason);
|
||
ok += 1;
|
||
_selectedIds.remove(a.id);
|
||
} catch (_) {
|
||
failed += 1;
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _batchInFlight = false);
|
||
_toast(
|
||
failed == 0
|
||
? l.approvalsBatchRejectDoneToast(ok)
|
||
: l.approvalsBatchPartialFailure(ok, failed),
|
||
);
|
||
_refresh();
|
||
}
|
||
|
||
void _toast(String msg) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||
}
|
||
|
||
Future<String?> _promptReason(BuildContext context) async {
|
||
final controller = TextEditingController();
|
||
final l = AppLocalizations.of(context)!;
|
||
return showDialog<String>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(l.approvalsRejectDialogTitle),
|
||
content: TextField(
|
||
controller: controller,
|
||
autofocus: true,
|
||
decoration: InputDecoration(
|
||
labelText: l.approvalsRejectReasonLabel,
|
||
border: const OutlineInputBorder(),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, null),
|
||
child: Text(l.buttonCancel),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, controller.text),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
||
),
|
||
child: Text(l.approvalsRejectButton),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Scaffold(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
appBar: AppBar(
|
||
title: Text(AppLocalizations.of(context)!.approvalsTitle),
|
||
bottom: TabBar(
|
||
controller: _tab,
|
||
tabs: [
|
||
Tab(text: AppLocalizations.of(context)!.approvalsTabPending),
|
||
Tab(text: AppLocalizations.of(context)!.approvalsTabHistory),
|
||
],
|
||
),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.help_outline, size: 18),
|
||
tooltip: AppLocalizations.of(context)!.helpTooltip,
|
||
onPressed: () => showFaiDoc(context, 'approvals'),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh, size: 18),
|
||
tooltip: AppLocalizations.of(context)!.approvalsReloadTooltip,
|
||
onPressed: _refresh,
|
||
),
|
||
const SizedBox(width: ChainSpace.sm),
|
||
],
|
||
),
|
||
body: TabBarView(
|
||
controller: _tab,
|
||
children: [
|
||
_PendingList(
|
||
future: _pendingFuture,
|
||
selectedIds: _selectedIds,
|
||
batchInFlight: _batchInFlight,
|
||
onApprove: _approve,
|
||
onReject: _reject,
|
||
onToggle: _toggleSelection,
|
||
onSelectAll: _selectAll,
|
||
onClearSelection: _clearSelection,
|
||
onBatchApprove: _batchApprove,
|
||
onBatchReject: _batchReject,
|
||
onRetry: _refresh,
|
||
),
|
||
_HistoryList(future: _historyFuture, onRetry: _refresh),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PendingList extends StatelessWidget {
|
||
final Future<List<ApprovalRecord>> future;
|
||
final Set<String> selectedIds;
|
||
final bool batchInFlight;
|
||
final void Function(ApprovalRecord) onApprove;
|
||
final void Function(ApprovalRecord) onReject;
|
||
final void Function(String id) onToggle;
|
||
final void Function(Iterable<ApprovalRecord>) onSelectAll;
|
||
final VoidCallback onClearSelection;
|
||
final Future<void> Function(List<ApprovalRecord>) onBatchApprove;
|
||
final Future<void> Function(List<ApprovalRecord>) onBatchReject;
|
||
final VoidCallback onRetry;
|
||
|
||
const _PendingList({
|
||
required this.future,
|
||
required this.selectedIds,
|
||
required this.batchInFlight,
|
||
required this.onApprove,
|
||
required this.onReject,
|
||
required this.onToggle,
|
||
required this.onSelectAll,
|
||
required this.onClearSelection,
|
||
required this.onBatchApprove,
|
||
required this.onBatchReject,
|
||
required this.onRetry,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return FutureBuilder<List<ApprovalRecord>>(
|
||
future: future,
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (snapshot.hasError) {
|
||
return ChainEmptyState(
|
||
icon: Icons.cloud_off_outlined,
|
||
iconColor: theme.colorScheme.error,
|
||
title: l.hubUnreachable,
|
||
hint: l.hubUnreachableHint,
|
||
action: FilledButton.tonal(
|
||
onPressed: onRetry,
|
||
child: Text(l.buttonRetry),
|
||
),
|
||
);
|
||
}
|
||
final pending = snapshot.data ?? [];
|
||
if (pending.isEmpty) {
|
||
return ChainEmptyState(
|
||
icon: Icons.task_alt_outlined,
|
||
title: l.approvalsInboxZero,
|
||
hint: l.approvalsInboxHint,
|
||
);
|
||
}
|
||
final selected = pending
|
||
.where((a) => selectedIds.contains(a.id))
|
||
.toList();
|
||
return Stack(
|
||
children: [
|
||
ListView.separated(
|
||
padding: EdgeInsets.fromLTRB(
|
||
ChainSpace.xl,
|
||
ChainSpace.xl,
|
||
ChainSpace.xl,
|
||
// Keep the last card clear of the floating
|
||
// batch action bar.
|
||
selected.isEmpty ? ChainSpace.xl : 96.0,
|
||
),
|
||
itemCount: pending.length,
|
||
separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.md),
|
||
itemBuilder: (context, i) {
|
||
final a = pending[i];
|
||
final isSelected = selectedIds.contains(a.id);
|
||
return _ApprovalCard(
|
||
approval: a,
|
||
selected: isSelected,
|
||
onToggleSelected: () => onToggle(a.id),
|
||
onApprove: () => onApprove(a),
|
||
onReject: () => onReject(a),
|
||
);
|
||
},
|
||
),
|
||
if (selected.isNotEmpty)
|
||
Positioned(
|
||
left: ChainSpace.xl,
|
||
right: ChainSpace.xl,
|
||
bottom: ChainSpace.lg,
|
||
child: _BatchActionBar(
|
||
selectedCount: selected.length,
|
||
totalCount: pending.length,
|
||
inFlight: batchInFlight,
|
||
onSelectAll: () => onSelectAll(pending),
|
||
onClear: onClearSelection,
|
||
onApprove: () => onBatchApprove(selected),
|
||
onReject: () => onBatchReject(selected),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Floating action bar that surfaces when the operator
|
||
/// multi-selects pending approvals. Lets them approve or
|
||
/// reject the whole picked set in one round-trip per item;
|
||
/// the parent loops sequentially so a partial failure stays
|
||
/// visible per row.
|
||
class _BatchActionBar extends StatelessWidget {
|
||
final int selectedCount;
|
||
final int totalCount;
|
||
final bool inFlight;
|
||
final VoidCallback onSelectAll;
|
||
final VoidCallback onClear;
|
||
final VoidCallback onApprove;
|
||
final VoidCallback onReject;
|
||
|
||
const _BatchActionBar({
|
||
required this.selectedCount,
|
||
required this.totalCount,
|
||
required this.inFlight,
|
||
required this.onSelectAll,
|
||
required this.onClear,
|
||
required this.onApprove,
|
||
required this.onReject,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return Material(
|
||
elevation: 4,
|
||
borderRadius: BorderRadius.circular(ChainRadius.md),
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: ChainSpace.lg,
|
||
vertical: ChainSpace.md,
|
||
),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(ChainRadius.md),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.check_box_outlined,
|
||
size: 18,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
const SizedBox(width: ChainSpace.sm),
|
||
Text(
|
||
l.approvalsBatchSelected(selectedCount),
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(width: ChainSpace.md),
|
||
if (selectedCount < totalCount)
|
||
TextButton(
|
||
onPressed: inFlight ? null : onSelectAll,
|
||
child: Text(l.approvalsSelectAll),
|
||
),
|
||
TextButton(
|
||
onPressed: inFlight ? null : onClear,
|
||
child: Text(l.approvalsClearSelection),
|
||
),
|
||
const Spacer(),
|
||
OutlinedButton.icon(
|
||
onPressed: inFlight ? null : onReject,
|
||
icon: const Icon(Icons.close, size: 14),
|
||
label: Text(l.approvalsBatchReject),
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: theme.colorScheme.error,
|
||
side: BorderSide(
|
||
color: theme.colorScheme.error.withValues(alpha: 0.5),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: ChainSpace.sm),
|
||
FilledButton.icon(
|
||
onPressed: inFlight ? null : onApprove,
|
||
icon: inFlight
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.check, size: 14),
|
||
label: Text(l.approvalsBatchApprove),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _HistoryList extends StatelessWidget {
|
||
final Future<List<ApprovalRecord>> future;
|
||
final VoidCallback onRetry;
|
||
|
||
const _HistoryList({required this.future, required this.onRetry});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return FutureBuilder<List<ApprovalRecord>>(
|
||
future: future,
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (snapshot.hasError) {
|
||
return ChainEmptyState(
|
||
icon: Icons.cloud_off_outlined,
|
||
iconColor: theme.colorScheme.error,
|
||
title: l.hubUnreachable,
|
||
hint: l.hubUnreachableHint,
|
||
action: FilledButton.tonal(
|
||
onPressed: onRetry,
|
||
child: Text(l.buttonRetry),
|
||
),
|
||
);
|
||
}
|
||
final decided = snapshot.data ?? [];
|
||
if (decided.isEmpty) {
|
||
return ChainEmptyState(
|
||
icon: Icons.history,
|
||
title: l.approvalsHistoryEmpty,
|
||
hint: l.approvalsHistoryEmptyHint,
|
||
);
|
||
}
|
||
return ListView.separated(
|
||
padding: const EdgeInsets.all(ChainSpace.xl),
|
||
itemCount: decided.length,
|
||
separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.xs),
|
||
itemBuilder: (context, i) {
|
||
final a = decided[i];
|
||
return _HistoryRow(record: a);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ApprovalCard extends StatelessWidget {
|
||
final ApprovalRecord approval;
|
||
final bool selected;
|
||
final VoidCallback onToggleSelected;
|
||
final VoidCallback onApprove;
|
||
final VoidCallback onReject;
|
||
|
||
const _ApprovalCard({
|
||
required this.approval,
|
||
required this.selected,
|
||
required this.onToggleSelected,
|
||
required this.onApprove,
|
||
required this.onReject,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return ChainCard(
|
||
accentTop: true,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Checkbox(
|
||
value: selected,
|
||
onChanged: (_) => onToggleSelected(),
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
const SizedBox(width: ChainSpace.xs),
|
||
ChainPill(
|
||
label: l.approvalsPillPending,
|
||
tone: ChainPillTone.warning,
|
||
icon: Icons.pending_outlined,
|
||
),
|
||
const SizedBox(width: ChainSpace.sm),
|
||
Expanded(
|
||
child: Text(
|
||
'${approval.flowName} › ${approval.stepId}',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
if (approval.expiresAt != null)
|
||
ChainPill(
|
||
label: _expiresInLabel(context, approval.expiresAt!),
|
||
tone: ChainPillTone.neutral,
|
||
icon: Icons.schedule,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: ChainSpace.lg),
|
||
Text(approval.prompt, style: theme.textTheme.bodyLarge),
|
||
if (approval.payloadPreview != null) ...[
|
||
const SizedBox(height: ChainSpace.md),
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 4),
|
||
child: Text(
|
||
l.approvalsPayloadPreview,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(ChainSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: SelectableText(
|
||
_prettyPreview(approval.payloadPreview!),
|
||
style: ChainTheme.mono(
|
||
size: 11,
|
||
color: theme.colorScheme.onSurface,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: ChainSpace.lg),
|
||
Row(
|
||
children: [
|
||
Text(
|
||
approval.id,
|
||
style: ChainTheme.mono(
|
||
size: 10,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
OutlinedButton.icon(
|
||
onPressed: onReject,
|
||
icon: const Icon(Icons.close, size: 16),
|
||
label: Text(l.approvalsRejectButton),
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: theme.colorScheme.error,
|
||
side: BorderSide(
|
||
color: theme.colorScheme.error.withValues(alpha: 0.5),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: ChainSpace.sm),
|
||
FilledButton.icon(
|
||
onPressed: onApprove,
|
||
icon: const Icon(Icons.check, size: 16),
|
||
label: Text(l.approvalsApproveButton),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
String _prettyPreview(String raw) {
|
||
if (raw.isEmpty) return raw;
|
||
try {
|
||
final dynamic parsed = const JsonDecoder().convert(raw);
|
||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||
} catch (_) {
|
||
return raw;
|
||
}
|
||
}
|
||
|
||
String _expiresInLabel(BuildContext context, DateTime t) {
|
||
final l = AppLocalizations.of(context)!;
|
||
final remaining = t.difference(DateTime.now());
|
||
if (remaining.isNegative) return l.approvalsPillExpired;
|
||
final minutes = remaining.inMinutes;
|
||
if (minutes < 60) return l.approvalsExpiresIn(minutes);
|
||
return l.approvalsExpiresInHours(remaining.inHours);
|
||
}
|
||
}
|
||
|
||
/// Compact one-line entry for a decided approval. Click expands
|
||
/// the prompt, decision metadata, and payload preview in a
|
||
/// dialog. Keeps the History tab scannable when there are many
|
||
/// decisions.
|
||
class _HistoryRow extends StatelessWidget {
|
||
final ApprovalRecord record;
|
||
const _HistoryRow({required this.record});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final (tone, icon, label) = _statusPresentation(context, record.status);
|
||
final decided = record.decidedAt;
|
||
final timeStr = decided == null ? '' : _formatTimestamp(decided.toLocal());
|
||
return InkWell(
|
||
onTap: () => showDialog<void>(
|
||
context: context,
|
||
builder: (_) => _HistoryDialog(record: record),
|
||
),
|
||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: ChainSpace.sm,
|
||
vertical: ChainSpace.sm,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
ChainPill(label: label, tone: tone, icon: icon),
|
||
const SizedBox(width: ChainSpace.md),
|
||
SizedBox(
|
||
width: 150,
|
||
child: Text(
|
||
timeStr,
|
||
style: ChainTheme.mono(
|
||
size: 11,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
'${record.flowName} › ${record.stepId}',
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: ChainSpace.md),
|
||
if (record.decidedBy.isNotEmpty)
|
||
Text(
|
||
record.decidedBy,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
static (ChainPillTone, IconData, String) _statusPresentation(
|
||
BuildContext context,
|
||
String status,
|
||
) {
|
||
final l = AppLocalizations.of(context)!;
|
||
switch (status) {
|
||
case 'approved':
|
||
return (ChainPillTone.success, Icons.check, l.approvalsPillApproved);
|
||
case 'rejected':
|
||
return (ChainPillTone.danger, Icons.close, l.approvalsPillRejected);
|
||
case 'expired':
|
||
return (
|
||
ChainPillTone.neutral,
|
||
Icons.timer_off_outlined,
|
||
l.approvalsPillExpired,
|
||
);
|
||
default:
|
||
return (ChainPillTone.neutral, Icons.help_outline, status);
|
||
}
|
||
}
|
||
}
|
||
|
||
class _HistoryDialog extends StatelessWidget {
|
||
final ApprovalRecord record;
|
||
const _HistoryDialog({required this.record});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final (tone, icon, label) = _HistoryRow._statusPresentation(
|
||
context,
|
||
record.status,
|
||
);
|
||
return AlertDialog(
|
||
title: Row(
|
||
children: [
|
||
ChainPill(label: label, tone: tone, icon: icon),
|
||
const SizedBox(width: ChainSpace.sm),
|
||
Expanded(
|
||
child: Text(
|
||
'${record.flowName} › ${record.stepId}',
|
||
style: theme.textTheme.titleMedium,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
content: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 540, maxHeight: 480),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (record.decidedAt != null)
|
||
_kv(
|
||
theme,
|
||
AppLocalizations.of(context)!.approvalsDialogDecided,
|
||
'${_formatTimestamp(record.decidedAt!.toLocal())} '
|
||
'· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}',
|
||
),
|
||
_kv(
|
||
theme,
|
||
AppLocalizations.of(context)!.approvalsDialogCreated,
|
||
_formatTimestamp(record.createdAt.toLocal()),
|
||
),
|
||
if (record.reason.isNotEmpty)
|
||
_kv(
|
||
theme,
|
||
AppLocalizations.of(context)!.approvalsDialogReason,
|
||
record.reason,
|
||
),
|
||
const SizedBox(height: ChainSpace.md),
|
||
Text(
|
||
AppLocalizations.of(context)!.approvalsDialogPrompt,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
SelectableText(record.prompt, style: theme.textTheme.bodyMedium),
|
||
if (record.payloadPreview != null) ...[
|
||
const SizedBox(height: ChainSpace.md),
|
||
Text(
|
||
AppLocalizations.of(context)!.approvalsPayloadPreview,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(ChainSpace.sm),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: SelectableText(
|
||
record.payloadPreview!,
|
||
style: ChainTheme.mono(size: 11),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: ChainSpace.md),
|
||
Text(
|
||
record.id,
|
||
style: ChainTheme.mono(
|
||
size: 10,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: Text(AppLocalizations.of(context)!.buttonClose),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _kv(ThemeData theme, String k, String v) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 70,
|
||
child: Text(
|
||
k,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
),
|
||
),
|
||
),
|
||
Expanded(child: SelectableText(v, style: theme.textTheme.bodySmall)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Locale-unambiguous YYYY-MM-DD HH:mm:ss formatter shared by
|
||
/// the history row + dialog. Always renders in the operator's
|
||
/// local time zone — ISO timestamps from the wire still appear
|
||
/// raw in the per-record dialog for cross-checking.
|
||
String _formatTimestamp(DateTime local) {
|
||
final yyyy = local.year.toString().padLeft(4, '0');
|
||
final mo = local.month.toString().padLeft(2, '0');
|
||
final dd = local.day.toString().padLeft(2, '0');
|
||
final hh = local.hour.toString().padLeft(2, '0');
|
||
final mm = local.minute.toString().padLeft(2, '0');
|
||
final ss = local.second.toString().padLeft(2, '0');
|
||
return '$yyyy-$mo-$dd $hh:$mm:$ss';
|
||
}
|