Two findings from the workspace persona review, both rated high:
* Switch race: the hub client re-pointed at a sealed area's hub
before the workspace announced the sealed context, so the 2 s
page pollers (runs, audit) could fetch and render that hub's
data without the sealed marking. Switches now run inside an
explicit switching window: opened before anything touches the
connection, announced optimistically in the identity bar
("switching…" + spinner, leave button hidden), pollers and the
shell health tick pause inside it, and pages drop replies whose
context epoch changed mid-flight. The sealed context is
announced only after the new hub answered healthy.
* Filter loss: entering a sealed area cleared the shared-hub
project filter and returning restored only the endpoint. The
filter is now parked on entry and restored on return; prefs
keep the parked value throughout, so live state and prefs agree
after the round trip (and after a mid-session relaunch).
Guard: workspace_switch_race_test pins both invariants
state-matrix-style against scripted hub + sealed-area fakes —
reconnects may only happen inside an open switch window, pollers
must stay silent inside it, and the filter must survive the round
trip. SealedAreaService gained a debugSetInstance seam so the
suite never scans a real ~/.chain.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1372 lines
47 KiB
Dart
1372 lines
47 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import '../data/error_presentation.dart';
|
||
import '../data/hub.dart';
|
||
import '../data/reviewer_identity.dart';
|
||
import '../data/workspace.dart';
|
||
import '../l10n/app_localizations.dart';
|
||
import '../theme/theme.dart';
|
||
import '../theme/tokens.dart';
|
||
import '../widgets/widgets.dart';
|
||
import 'welcome.dart' show showFaiDoc;
|
||
|
||
/// The fixed English sentence pre-0.21 hubs baked into stored
|
||
/// approvals when the flow gave no `prompt:`. Newer hubs store the
|
||
/// empty prompt verbatim.
|
||
const _legacyHubPromptDefault =
|
||
'Please review and approve this step before continuing.';
|
||
|
||
/// Reviewer-facing prompt with fallbacks: an empty prompt (the flow
|
||
/// gave none) renders the localized default, and the legacy English
|
||
/// default from old hub rows is mapped onto the same localized
|
||
/// default so it stops showing English inside a German UI.
|
||
String displayApprovalPrompt(AppLocalizations l, String prompt) {
|
||
final trimmed = prompt.trim();
|
||
if (trimmed.isEmpty || trimmed == _legacyHubPromptDefault) {
|
||
return l.approvalsRequestFallback;
|
||
}
|
||
return prompt;
|
||
}
|
||
|
||
class ApprovalsPage extends StatefulWidget {
|
||
const ApprovalsPage({super.key});
|
||
|
||
@override
|
||
State<ApprovalsPage> createState() => _ApprovalsPageState();
|
||
}
|
||
|
||
class _ApprovalsPageState extends State<ApprovalsPage> {
|
||
/// 0 = pending inbox, 1 = history. Plain index instead of a
|
||
/// TabController: the page uses the canonical pill segment
|
||
/// (ChainSegments), and an IndexedStack keeps both lists alive
|
||
/// so switching does not refetch.
|
||
int _tabIndex = 0;
|
||
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;
|
||
|
||
/// Operator-facing handle of whoever is deciding here. The OS
|
||
/// account is a label, not an identity — [HubService.approve]
|
||
/// marks it as an unchecked claim on the wire, and the strip
|
||
/// above the inbox says so in plain words. See
|
||
/// `lib/data/reviewer_identity.dart`.
|
||
final String _reviewer = ReviewerIdentity.localHandle;
|
||
|
||
/// What actually goes into `decided_by` — the handle plus the
|
||
/// marker saying it is an unchecked client claim. Computed here
|
||
/// (not only inside [HubService], which normalises again as a
|
||
/// backstop) because the strip above the inbox shows the operator
|
||
/// this very string before they decide.
|
||
String get _reviewerWire => ReviewerIdentity.wire(_reviewer);
|
||
|
||
/// What that attribution is worth on THIS hub. Starts unknown and
|
||
/// stays unknown when the policy cannot be read (AuthStatus is
|
||
/// admin-scoped, and old hubs answer UNIMPLEMENTED) — the strip
|
||
/// has an honest line for each case.
|
||
ReviewerAssurance _assurance = ReviewerAssurance.unknown;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
Workspace.instance.addListener(_refresh);
|
||
Workspace.instance.ensureLoaded();
|
||
_refresh();
|
||
_loadAssurance();
|
||
}
|
||
|
||
Future<void> _loadAssurance() async {
|
||
ReviewerAssurance resolved;
|
||
try {
|
||
final policy = await HubService.instance.authStatus();
|
||
resolved = reviewerAssuranceFor(policy.anonymousAllowed);
|
||
} catch (_) {
|
||
// Denied / unsupported / hub gone: not knowing is a state of
|
||
// its own, never an excuse to imply the attribution is sound.
|
||
resolved = ReviewerAssurance.unknown;
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _assurance = resolved);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
Workspace.instance.removeListener(_refresh);
|
||
super.dispose();
|
||
}
|
||
|
||
void _refresh() {
|
||
if (!mounted) return;
|
||
// Mid-switch the client points between contexts — hold off; the
|
||
// end-of-switch notify lands here again with a settled client.
|
||
if (Workspace.instance.switching) return;
|
||
final project = Workspace.instance.activeSlug;
|
||
setState(() {
|
||
_pendingFuture = HubService.instance.listApprovalsRecords(
|
||
statuses: const ['pending'],
|
||
project: project,
|
||
);
|
||
_historyFuture = HubService.instance.listApprovalsRecords(
|
||
statuses: const ['approved', 'rejected', 'expired'],
|
||
limit: 200,
|
||
project: project,
|
||
);
|
||
// Only the visible tab has a FutureBuilder listening; give the
|
||
// hidden tab's future a silent listener so a load failure there
|
||
// never surfaces as an uncaught async error (the FutureBuilder
|
||
// that attaches on tab switch still receives the error).
|
||
_pendingFuture.then((_) {}, onError: (_) {});
|
||
_historyFuture.then((_) {}, onError: (_) {});
|
||
});
|
||
}
|
||
|
||
Future<void> _approve(ApprovalRecord a) async {
|
||
final l = AppLocalizations.of(context)!;
|
||
// No `show:` data attached → never approve on a reflex. Calmly
|
||
// explain and ask for a conscious confirmation first.
|
||
if (a.payloadPreview == null) {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(l.approvalsNoDataConfirmTitle),
|
||
content: Text(l.approvalsNoDataConfirmBody),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: Text(l.buttonCancel),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: Text(l.approvalsNoDataConfirmAction),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true) return;
|
||
}
|
||
if (!mounted) return;
|
||
try {
|
||
await HubService.instance.approve(a.id, _reviewerWire);
|
||
_toast(l.approvalsApprovedToast(a.flowName, a.stepId));
|
||
_refresh();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
// Copyable error — never a bare SnackBar(Text(e)).
|
||
showChainErrorSnack(context, 'approvals.approve', e);
|
||
}
|
||
}
|
||
|
||
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, _reviewerWire, reason);
|
||
_toast(l.approvalsRejectedToast(a.flowName, a.stepId));
|
||
_refresh();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
showChainErrorSnack(context, 'approvals.reject', e);
|
||
}
|
||
}
|
||
|
||
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)!;
|
||
// Same conscious-confirmation guard as the single approve, but for
|
||
// the whole batch: a one-click bulk approve must not silently
|
||
// sweep through the data-less requests the single path stops on.
|
||
final noData = picked.where((a) => a.payloadPreview == null).length;
|
||
if (noData > 0) {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(l.approvalsBatchNoDataTitle),
|
||
content: Text(l.approvalsBatchNoDataBody(noData, picked.length)),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: Text(l.buttonCancel),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: Text(l.approvalsNoDataConfirmAction),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true) return;
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _batchInFlight = true);
|
||
var ok = 0;
|
||
var failed = 0;
|
||
Object? firstError;
|
||
for (final a in picked) {
|
||
try {
|
||
await HubService.instance.approve(a.id, _reviewerWire);
|
||
ok += 1;
|
||
_selectedIds.remove(a.id);
|
||
} catch (e) {
|
||
failed += 1;
|
||
firstError ??= e;
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _batchInFlight = false);
|
||
if (failed == 0) {
|
||
_toast(l.approvalsBatchApproveDoneToast(ok));
|
||
} else {
|
||
// Partial failure: surface the copyable cause, not just a count.
|
||
// The failed items stay selected so they remain visible.
|
||
showChainErrorSnack(context, 'approvals.batchApprove', firstError!);
|
||
}
|
||
_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;
|
||
Object? firstError;
|
||
for (final a in picked) {
|
||
try {
|
||
await HubService.instance.reject(a.id, _reviewerWire, reason);
|
||
ok += 1;
|
||
_selectedIds.remove(a.id);
|
||
} catch (e) {
|
||
failed += 1;
|
||
firstError ??= e;
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _batchInFlight = false);
|
||
if (failed == 0) {
|
||
_toast(l.approvalsBatchRejectDoneToast(ok));
|
||
} else {
|
||
showChainErrorSnack(context, 'approvals.batchReject', firstError!);
|
||
}
|
||
_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: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// Explain the consequence at the point of input, and make
|
||
// the reason a real required field: the button stays
|
||
// disabled while it is empty instead of the dialog closing
|
||
// silently and nothing happening (usertest finding).
|
||
ChainInlineHelp(text: l.approvalsRejectDialogHelp),
|
||
const SizedBox(height: ChainSpace.md),
|
||
TextField(
|
||
controller: controller,
|
||
autofocus: true,
|
||
minLines: 1,
|
||
maxLines: 3,
|
||
decoration: InputDecoration(
|
||
labelText: l.approvalsRejectReasonLabel,
|
||
helperText: l.approvalsRejectReasonHelper,
|
||
border: const OutlineInputBorder(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, null),
|
||
child: Text(l.buttonCancel),
|
||
),
|
||
ValueListenableBuilder<TextEditingValue>(
|
||
valueListenable: controller,
|
||
builder: (ctx, value, _) {
|
||
final empty = value.text.trim().isEmpty;
|
||
return FilledButton(
|
||
onPressed: empty
|
||
? null
|
||
: () => Navigator.pop(ctx, controller.text.trim()),
|
||
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: PreferredSize(
|
||
preferredSize: const Size.fromHeight(44),
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
ChainSpace.xl,
|
||
0,
|
||
ChainSpace.xl,
|
||
ChainSpace.sm,
|
||
),
|
||
child: ChainSegments<int>(
|
||
items: [
|
||
ChainSegmentItem(
|
||
0,
|
||
AppLocalizations.of(context)!.approvalsTabPending,
|
||
),
|
||
ChainSegmentItem(
|
||
1,
|
||
AppLocalizations.of(context)!.approvalsTabHistory,
|
||
),
|
||
],
|
||
value: _tabIndex,
|
||
onChanged: (i) => setState(() => _tabIndex = i),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
const ChainWorkspaceSwitcher(),
|
||
const SizedBox(width: ChainSpace.md),
|
||
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: IndexedStack(
|
||
index: _tabIndex,
|
||
children: [
|
||
_PendingList(
|
||
future: _pendingFuture,
|
||
reviewer: _reviewer,
|
||
assurance: _assurance,
|
||
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 String reviewer;
|
||
final ReviewerAssurance assurance;
|
||
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.reviewer,
|
||
required this.assurance,
|
||
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 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 HubLoadErrorView(error: snapshot.error!, onRetry: onRetry);
|
||
}
|
||
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 Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// A calm one-line explainer so a first-time reviewer knows
|
||
// what this inbox is and what Approve / Reject actually do,
|
||
// without opening the doc sheet.
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
ChainSpace.xl,
|
||
ChainSpace.lg,
|
||
ChainSpace.xl,
|
||
0,
|
||
),
|
||
child: ChainInlineHelp(text: l.approvalsIntroHelp),
|
||
),
|
||
// Who the audit trail will name, and what that name is
|
||
// worth — stated before the decision, not discovered
|
||
// afterwards by a lawyer reading the export.
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
ChainSpace.xl,
|
||
ChainSpace.md,
|
||
ChainSpace.xl,
|
||
0,
|
||
),
|
||
child: _ReviewerIdentityStrip(
|
||
handle: reviewer,
|
||
assurance: assurance,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Stack(
|
||
children: [
|
||
ListView.separated(
|
||
padding: EdgeInsets.fromLTRB(
|
||
ChainSpace.xl,
|
||
ChainSpace.md,
|
||
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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// "You are deciding as …" — names the attribution the audit trail
|
||
/// will carry and, in one plain sentence, what it is worth on this
|
||
/// hub. Studio cannot prove the identity (the hub stores whatever a
|
||
/// client sends as `decided_by`), so the honest move is to say that
|
||
/// out loud instead of letting the name pass for proof.
|
||
class _ReviewerIdentityStrip extends StatelessWidget {
|
||
final String handle;
|
||
final ReviewerAssurance assurance;
|
||
|
||
const _ReviewerIdentityStrip({
|
||
required this.handle,
|
||
required this.assurance,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final note = switch (assurance) {
|
||
ReviewerAssurance.anonymousHub => l.approvalsReviewerNoteAnonymous,
|
||
ReviewerAssurance.accessControlled =>
|
||
l.approvalsReviewerNoteAuthenticated,
|
||
ReviewerAssurance.unknown => l.approvalsReviewerNoteUnknown,
|
||
};
|
||
return Container(
|
||
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: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(
|
||
Icons.badge_outlined,
|
||
size: 16,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: ChainSpace.sm),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Wrap, not Row: a long handle plus the pill must
|
||
// reflow instead of overflowing on narrow windows.
|
||
Wrap(
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
spacing: ChainSpace.sm,
|
||
runSpacing: 4,
|
||
children: [
|
||
Text(
|
||
l.approvalsReviewerLabel,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
SelectableText(
|
||
handle,
|
||
maxLines: 1,
|
||
style: ChainTheme.mono(
|
||
size: 12,
|
||
color: theme.colorScheme.onSurface,
|
||
),
|
||
),
|
||
ChainPill(
|
||
label: l.approvalsReviewerUnverifiedPill,
|
||
tone: ChainPillTone.warning,
|
||
icon: Icons.gpp_maybe_outlined,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
note,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
height: 1.4,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
// The literal value that lands in `decided_by`, so
|
||
// nobody is surprised by the prefix when they read
|
||
// the log, the CLI output or a DB export.
|
||
SelectableText(
|
||
l.approvalsReviewerRecordedAs(
|
||
ReviewerIdentity.wire(handle),
|
||
),
|
||
style: ChainTheme.mono(
|
||
size: 11,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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 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 HubLoadErrorView(error: snapshot.error!, onRetry: onRetry);
|
||
}
|
||
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)!;
|
||
final metaStyle = theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
);
|
||
// One origin fact per line: small leading icon, the value beside
|
||
// it. Keeps the "where does this come from" block scannable.
|
||
Widget originLine(IconData icon, Widget child) => Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(icon, size: 13, color: theme.colorScheme.onSurfaceVariant),
|
||
const SizedBox(width: 6),
|
||
Expanded(child: child),
|
||
],
|
||
),
|
||
);
|
||
return ChainCard(
|
||
accentTop: true,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Status row: selection + state + expiry. No flow id here —
|
||
// the headline below leads with WHAT is being asked, not the
|
||
// technical flow name.
|
||
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 Spacer(),
|
||
if (approval.expiresAt != null)
|
||
ChainPill(
|
||
label: _expiresInLabel(context, approval.expiresAt!),
|
||
tone: ChainPillTone.neutral,
|
||
icon: Icons.schedule,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: ChainSpace.lg),
|
||
// Headline = the human question the flow author wrote ("what
|
||
// am I approving"). Falls back to a clear sentence when the
|
||
// step left the prompt empty, so the card is never reduced to
|
||
// a cryptic flow id.
|
||
Text(
|
||
displayApprovalPrompt(l, approval.prompt),
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: ChainSpace.md),
|
||
// Origin block — the "which flow, which step, which run, in
|
||
// which project, requested when" a reviewer needs to place
|
||
// the request in context. The run id is copyable so it can
|
||
// be cross-referenced against the event timeline on the
|
||
// Audit page.
|
||
Text(
|
||
l.approvalsOriginLabel,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
originLine(
|
||
Icons.account_tree_outlined,
|
||
Text(
|
||
l.approvalsFlowStepMeta(approval.flowName, approval.stepId),
|
||
style: metaStyle,
|
||
),
|
||
),
|
||
if (approval.project.isNotEmpty)
|
||
originLine(
|
||
Icons.folder_outlined,
|
||
Text(
|
||
'${l.approvalsOriginProject}: ${approval.project}',
|
||
style: metaStyle,
|
||
),
|
||
),
|
||
if (approval.createdAt != null)
|
||
originLine(
|
||
Icons.schedule,
|
||
Text(
|
||
'${l.approvalsOriginRequested}: '
|
||
'${_formatTimestamp(approval.createdAt!.toLocal())}',
|
||
style: metaStyle,
|
||
),
|
||
),
|
||
if (approval.flowExecution != null)
|
||
originLine(
|
||
Icons.tag_outlined,
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: SelectableText(
|
||
'${l.approvalsOriginRun}: ${approval.flowExecution}',
|
||
style: ChainTheme.mono(
|
||
size: 11,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
// One-click copy so the run id can be pasted into the
|
||
// Audit page's search to reach the event timeline
|
||
// (a filtered deeplink is a larger shell change — see
|
||
// the backlog).
|
||
Tooltip(
|
||
message: l.approvalsCopyRun,
|
||
child: InkResponse(
|
||
radius: 16,
|
||
onTap: () async {
|
||
await Clipboard.setData(
|
||
ClipboardData(text: approval.flowExecution!),
|
||
);
|
||
if (context.mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(l.approvalsRunCopied)),
|
||
);
|
||
}
|
||
},
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(2),
|
||
child: Icon(
|
||
Icons.content_copy,
|
||
size: 13,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
if (approval.payloadPreview != null)
|
||
Container(
|
||
width: double.infinity,
|
||
// Cap the height so a large payload scrolls inside the
|
||
// card instead of stretching it off-screen; still fully
|
||
// readable + copyable (SelectableText).
|
||
constraints: const BoxConstraints(maxHeight: 280),
|
||
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: SingleChildScrollView(
|
||
child: SelectableText(
|
||
_prettyJson(approval.payloadPreview!),
|
||
style: ChainTheme.mono(
|
||
size: 11,
|
||
color: theme.colorScheme.onSurface,
|
||
),
|
||
),
|
||
),
|
||
)
|
||
else
|
||
// No `show:` on the approval step → explain where the data
|
||
// would come from instead of rendering nothing.
|
||
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: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(
|
||
Icons.info_outline,
|
||
size: 14,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: ChainSpace.sm),
|
||
Expanded(
|
||
child: Text(
|
||
l.approvalsNoPayload,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: ChainSpace.lg),
|
||
Row(
|
||
children: [
|
||
Text(
|
||
approval.id,
|
||
style: ChainTheme.mono(
|
||
size: 10,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Tooltip(
|
||
message: l.approvalsRejectTooltip,
|
||
child: 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),
|
||
Tooltip(
|
||
message: l.approvalsApproveTooltip,
|
||
child: FilledButton.icon(
|
||
onPressed: onApprove,
|
||
icon: const Icon(Icons.check, size: 16),
|
||
label: Text(l.approvalsApproveButton),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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) _DecidedBy(record.decidedBy),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Renders a stored `decided_by`. A value Studio wrote carries the
|
||
/// `unverified:` marker — show the readable name and keep the marker
|
||
/// visible as a warning glyph rather than dropping either. A value
|
||
/// without the marker (legacy row, a CLI decision) gets no badge at
|
||
/// all: Studio does not know where it came from and must not imply
|
||
/// it was checked.
|
||
class _DecidedBy extends StatelessWidget {
|
||
final String recorded;
|
||
const _DecidedBy(this.recorded);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final parsed = ReviewerIdentity.parse(recorded);
|
||
final name = Text(
|
||
parsed.handle,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
);
|
||
if (!parsed.isSelfDeclared) return name;
|
||
return Tooltip(
|
||
message: l.approvalsReviewerUnverifiedTooltip,
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.gpp_maybe_outlined,
|
||
size: 14,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Flexible(child: name),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// "who decided" for the detail dialog's one-line summary: the
|
||
/// readable name, and — when Studio wrote it — the plain-words
|
||
/// hint that the attribution is an unchecked claim.
|
||
String _decidedByLabel(BuildContext context, String recorded) {
|
||
final l = AppLocalizations.of(context)!;
|
||
if (recorded.isEmpty) return l.approvalsUnknownReviewer;
|
||
final parsed = ReviewerIdentity.parse(recorded);
|
||
if (!parsed.isSelfDeclared) return parsed.handle;
|
||
return '${parsed.handle} (${l.approvalsReviewerUnverifiedPill})';
|
||
}
|
||
|
||
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())} '
|
||
'· ${_decidedByLabel(context, record.decidedBy)}',
|
||
),
|
||
if (record.createdAt != null)
|
||
_kv(
|
||
theme,
|
||
AppLocalizations.of(context)!.approvalsDialogCreated,
|
||
_formatTimestamp(record.createdAt!.toLocal()),
|
||
),
|
||
if (record.project.isNotEmpty)
|
||
_kv(
|
||
theme,
|
||
AppLocalizations.of(context)!.approvalsOriginProject,
|
||
record.project,
|
||
),
|
||
if (record.flowExecution != null)
|
||
_kv(
|
||
theme,
|
||
AppLocalizations.of(context)!.approvalsOriginRun,
|
||
record.flowExecution!,
|
||
),
|
||
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(
|
||
displayApprovalPrompt(
|
||
AppLocalizations.of(context)!,
|
||
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(
|
||
_prettyJson(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)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Pretty-print a JSON payload preview; returns the raw string
|
||
/// unchanged when it is empty or not valid JSON. Shared by the
|
||
/// pending card + the history dialog so both render identically.
|
||
String _prettyJson(String raw) {
|
||
if (raw.isEmpty) return raw;
|
||
try {
|
||
final dynamic parsed = const JsonDecoder().convert(raw);
|
||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||
} catch (_) {
|
||
return raw;
|
||
}
|
||
}
|
||
|
||
/// 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';
|
||
}
|