chain-studio/lib/pages/approvals.dart
flemming-it ee83eb851a feat(studio): app-wide i18n via flutter_localizations (v0.24.0)
Replaces the Store-only DE/EN toggle with an app-wide one
parked in the sidebar footer next to the theme button.
Pressing it flips every translated string at once: nav
labels, page titles, common buttons, the bilingual
store-index content.

Implementation:
- Adds `flutter_localizations` + `intl` to pubspec, plus
  `flutter.generate: true` so `flutter gen-l10n` runs in the
  build pipeline.
- ARB sources at `lib/l10n/app_en.arb` and `app_de.arb`. The
  EN file is the template; DE carries the German strings.
  Initial coverage: navigation, common buttons, page titles,
  channels / store / audit / modules / approvals headers,
  hub-unreachable copy, MCP + n8n panel headers + hints.
  Rest of the UI strings are still English-literal — those
  fall in incrementally as we touch each surface.
- Generated `AppLocalizations` lives at
  `lib/l10n/app_localizations*.dart` (regenerated via
  `flutter gen-l10n` on every ARB edit).
- `StudioAppState` gains `localeNotifier` alongside
  `modeNotifier`; persisted via SharedPreferences key
  `locale.code`.
- Sidebar `_LanguageToggle` reads/writes through the
  notifier. The Store's per-page locale state is gone:
  `_locale` now reads `Localizations.localeOf(context)
  .languageCode`, so the bilingual store-index content
  follows the global setting without a second toggle.
- `_NavPage.label` becomes `_NavPage.id` + `labelOf(context)`;
  Cmd+K palette and Sidebar both read the localized label.

Out of scope this iteration: localizing the remaining
~80% of UI strings (Settings dialog labels, Store search
hint, error messages). Those land incrementally — the i18n
infrastructure now means each is a one-line ARB edit + one
call-site swap.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-08 01:19:32 +02:00

619 lines
19 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';
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;
// 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 {
try {
await HubService.instance.approve(a.id, _reviewer);
_toast('approved · ${a.flowName} ${a.stepId}');
_refresh();
} catch (e) {
_toast('approve failed: $e');
}
}
Future<void> _reject(ApprovalRecord a) async {
final reason = await _promptReason(context);
if (reason == null || reason.isEmpty) return;
try {
await HubService.instance.reject(a.id, _reviewer, reason);
_toast('rejected · ${a.flowName} ${a.stepId}');
_refresh();
} catch (e) {
_toast('reject failed: $e');
}
}
void _toast(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
Future<String?> _promptReason(BuildContext context) async {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reject approval'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Reason (recorded in audit log)',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, controller.text),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
foregroundColor: Theme.of(ctx).colorScheme.onError,
),
child: const Text('Reject'),
),
],
),
);
}
@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.refresh, size: 18),
tooltip: 'Reload',
onPressed: _refresh,
),
const SizedBox(width: FaiSpace.sm),
],
),
body: TabBarView(
controller: _tab,
children: [
_PendingList(
future: _pendingFuture,
onApprove: _approve,
onReject: _reject,
onRetry: _refresh,
),
_HistoryList(
future: _historyFuture,
onRetry: _refresh,
),
],
),
);
}
}
class _PendingList extends StatelessWidget {
final Future<List<ApprovalRecord>> future;
final void Function(ApprovalRecord) onApprove;
final void Function(ApprovalRecord) onReject;
final VoidCallback onRetry;
const _PendingList({
required this.future,
required this.onApprove,
required this.onReject,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
final theme = Theme.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: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
action: FilledButton.tonal(
onPressed: onRetry,
child: const Text('Retry'),
),
);
}
final pending = snapshot.data ?? [];
if (pending.isEmpty) {
return const FaiEmptyState(
icon: Icons.task_alt_outlined,
title: 'Inbox zero',
hint:
'All caught up. New `system.approval@^0` steps land here automatically.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: pending.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) {
final a = pending[i];
return _ApprovalCard(
approval: a,
onApprove: () => onApprove(a),
onReject: () => onReject(a),
);
},
);
},
);
}
}
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);
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: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
action: FilledButton.tonal(
onPressed: onRetry,
child: const Text('Retry'),
),
);
}
final decided = snapshot.data ?? [];
if (decided.isEmpty) {
return const FaiEmptyState(
icon: Icons.history,
title: 'No history yet',
hint:
'Decided approvals (approved / rejected / expired) appear here.\nThe audit log keeps the full hash-chained record.',
);
}
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 VoidCallback onApprove;
final VoidCallback onReject;
const _ApprovalCard({
required this.approval,
required this.onApprove,
required this.onReject,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FaiCard(
accentTop: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const FaiPill(
label: 'pending',
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: _expiresIn(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(
'PAYLOAD PREVIEW',
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: const Text('Reject'),
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: const Text('Approve'),
),
],
),
],
),
);
}
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 _expiresIn(DateTime t) {
final remaining = t.difference(DateTime.now());
if (remaining.isNegative) return 'expired';
final minutes = remaining.inMinutes;
if (minutes < 60) return '${minutes}m';
final hours = remaining.inHours;
return '${hours}h';
}
}
/// 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(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(String status) {
switch (status) {
case 'approved':
return (FaiPillTone.success, Icons.check, 'approved');
case 'rejected':
return (FaiPillTone.danger, Icons.close, 'rejected');
case 'expired':
return (FaiPillTone.neutral, Icons.timer_off_outlined, 'expired');
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(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, 'decided',
'${_formatTimestamp(record.decidedAt!.toLocal())} '
'· by ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'),
_kv(theme, 'created',
_formatTimestamp(record.createdAt.toLocal())),
if (record.reason.isNotEmpty)
_kv(theme, 'reason', record.reason),
const SizedBox(height: FaiSpace.md),
Text(
'PROMPT',
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(
'PAYLOAD PREVIEW',
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: const Text('Close'),
),
],
);
}
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';
}