chain-studio/lib/pages/approvals.dart
flemming-it 28fafce7dc
Some checks failed
Security / Security check (push) Failing after 1s
feat(studio): localise Settings sidebar + add inline help docs + Approvals doc
Three operator-UX gaps closed:

  - Settings dialog's six-category sidebar (General / Appearance /
    System AI / Integrations / Security / Maintenance) was
    hardcoded English; now flows through AppLocalizations
    ("Allgemein" / "Darstellung" / "System-KI" / "Integrationen" /
    "Sicherheit" / "Wartung"). Same for the per-panel title +
    description.
  - Per-channel daemon-action labels ('enable autostart' /
    'disable autostart' / 'daemon restart' etc.) and the
    OK / Failed result line in the toast also moved to l10n,
    so the system-action feedback reads as one language.
  - New 'Approvals' doc bundle (en + de) under assets/docs/,
    registered as a fifth doc card on Welcome plus exposed via
    the new public  helper. The
    Approvals, Audit and Doctor app-bars grow a Help icon button
    next to Refresh that opens the matching doc in the existing
    bottom-sheet reader — no extra screen, no learning curve.

Studio bumped to 0.67.0; editor path-override pulls in 0.20.1
(flow-list row polish).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-09 01:57:46 +02:00

872 lines
28 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 '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: FaiSpace.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 FaiEmptyState(
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 FaiEmptyState(
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(
FaiSpace.xl,
FaiSpace.xl,
FaiSpace.xl,
// Keep the last card clear of the floating
// batch action bar.
selected.isEmpty ? FaiSpace.xl : 96.0,
),
itemCount: pending.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.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: FaiSpace.xl,
right: FaiSpace.xl,
bottom: FaiSpace.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(FaiRadius.md),
color: theme.colorScheme.surfaceContainerHigh,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.lg,
vertical: FaiSpace.md,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.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: FaiSpace.sm),
Text(
l.approvalsBatchSelected(selectedCount),
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: FaiSpace.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: FaiSpace.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 FaiEmptyState(
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 FaiEmptyState(
icon: Icons.history,
title: l.approvalsHistoryEmpty,
hint: l.approvalsHistoryEmptyHint,
);
}
return ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: decided.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.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 FaiCard(
accentTop: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Checkbox(
value: selected,
onChanged: (_) => onToggleSelected(),
visualDensity: VisualDensity.compact,
),
const SizedBox(width: FaiSpace.xs),
FaiPill(
label: l.approvalsPillPending,
tone: FaiPillTone.warning,
icon: Icons.pending_outlined,
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Text(
'${approval.flowName} ${approval.stepId}',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (approval.expiresAt != null)
FaiPill(
label: _expiresInLabel(context, approval.expiresAt!),
tone: FaiPillTone.neutral,
icon: Icons.schedule,
),
],
),
const SizedBox(height: FaiSpace.lg),
Text(approval.prompt, style: theme.textTheme.bodyLarge),
if (approval.payloadPreview != null) ...[
const SizedBox(height: FaiSpace.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(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(
_prettyPreview(approval.payloadPreview!),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
],
const SizedBox(height: FaiSpace.lg),
Row(
children: [
Text(
approval.id,
style: FaiTheme.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: FaiSpace.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(FaiRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: FaiSpace.sm,
),
child: Row(
children: [
FaiPill(label: label, tone: tone, icon: icon),
const SizedBox(width: FaiSpace.md),
SizedBox(
width: 150,
child: Text(
timeStr,
style: FaiTheme.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: FaiSpace.md),
if (record.decidedBy.isNotEmpty)
Text(
record.decidedBy,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
static (FaiPillTone, IconData, String) _statusPresentation(
BuildContext context,
String status,
) {
final l = AppLocalizations.of(context)!;
switch (status) {
case 'approved':
return (FaiPillTone.success, Icons.check, l.approvalsPillApproved);
case 'rejected':
return (FaiPillTone.danger, Icons.close, l.approvalsPillRejected);
case 'expired':
return (
FaiPillTone.neutral,
Icons.timer_off_outlined,
l.approvalsPillExpired,
);
default:
return (FaiPillTone.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: [
FaiPill(label: label, tone: tone, icon: icon),
const SizedBox(width: FaiSpace.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: FaiSpace.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: FaiSpace.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(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(
record.payloadPreview!,
style: FaiTheme.mono(size: 11),
),
),
],
const SizedBox(height: FaiSpace.md),
Text(
record.id,
style: FaiTheme.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';
}