chain-studio/lib/pages/approvals.dart
flemming-it ebc668d28d feat(approvals,audit): record the reviewer as the unchecked claim it is
The hub copies the reviewer string a client sends straight into
decided_by (DecideApproval, ClearEventLog); nothing on the wire ties
it to the authenticated caller. Studio filled it from the OS account,
so an export read like non-repudiation while being an arbitrary
client claim — the legal finding of the 2026-07-26 usertest panel.

The real fix is hub-side (derive decided_by from CALLER_IDENTITY);
that contract is written down in docs/reviewer-identity.md and needs
a hub release. Until then Studio does the one thing it can do
honestly and marks its own claim as a claim, inside the record:

- data/reviewer_identity.dart is the single place that produces and
  reads the value; wire() is idempotent, so page and HubService may
  both normalise. Every write path funnels through HubService, so no
  surface can send a bare handle.
- The inbox states before the decision who will be recorded, what
  that attribution is worth on this hub (from AuthStatus), and the
  literal string that lands in decided_by. An unreadable auth policy
  stays unreadable — never optimistic.
- Reading back: a marked value shows its plain name plus an
  unchecked flag; an unmarked one (legacy row, CLI decision, or a
  future hub-derived identity) is not classified either way.
- The audit wipe seeds the same kind of marked attribution into its
  chain.reset marker.

When the hub starts deriving the value it overwrites the field and
the prefix disappears by itself — no Studio release needed.

Guards: reviewer_identity_test (the value) and
approvals_reviewer_identity_test (every surface that writes or
renders it, against the hermetic fake hub). Visual proof for both
themes via the dialog-shot harness.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-08-03 23:50:34 +02:00

1369 lines
47 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.

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;
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';
}