import 'package:chain_client_sdk/chain_client_sdk.dart' as chain; import 'package:flutter/material.dart'; import '../data/hub_repository.dart'; import '../data/repository.dart'; import '../theme/reclaim_tokens.dart'; import '../widgets/mode_banner.dart'; import '../widgets/reclaim_card.dart'; /// Jurist review — the human-in-the-loop Freigabe. Lists the hub's /// pending approvals (a flow's `system.approval` step posts one with /// the norm + sources as payload); a jurist reads the context and /// confirms ("Haken") or rejects. The decision lands in the hub's /// hash-chained audit log under the reviewer's name — non-repudiable. /// /// Review is OPTIONAL: the platform's figures stand on their own; a /// jurist pass only *confirms* a topic. Login + a trusted-jurist /// registry (who may decide) is enforced hub-side — see the chain /// handoff. Here the reviewer types their identity (the audit `by`). class JuristReviewPage extends StatefulWidget { const JuristReviewPage({super.key, required this.repository}); final EvaluationRepository repository; @override State createState() => _JuristReviewPageState(); } class _JuristReviewPageState extends State { final _reviewer = TextEditingController(); Future>? _future; chain.HubClient? get _hub { final r = widget.repository; return r is HubRepository && r.isHealthy ? r.client : null; } @override void initState() { super.initState(); _reload(); } void _reload() { final hub = _hub; setState(() { _future = hub == null ? Future.value(const []) : hub.listApprovals(statuses: const ['pending']); }); } @override void dispose() { _reviewer.dispose(); super.dispose(); } Future _decide( chain.PendingApprovalEntry e, { required bool approve, }) async { final hub = _hub; if (hub == null) return; final reviewer = _reviewer.text.trim(); if (reviewer.isEmpty) { _snack('Bitte zuerst den Namen des prüfenden Juristen eintragen.'); return; } String? reason; if (!approve) { reason = await _askReason(); if (reason == null) return; // cancelled } try { if (approve) { await hub.approve(approvalId: e.approvalId, reviewer: reviewer); } else { await hub.reject( approvalId: e.approvalId, reviewer: reviewer, reason: reason!.isEmpty ? 'ohne Angabe' : reason, ); } _snack(approve ? 'Freigegeben — als juristisch bestätigt protokolliert.' : 'Abgelehnt — im Audit-Log vermerkt.'); _reload(); } catch (err) { _snack('Fehler: $err'); } } Future _askReason() { final ctrl = TextEditingController(); return showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Ablehnung begründen'), content: TextField( controller: ctrl, autofocus: true, maxLines: 3, decoration: const InputDecoration( hintText: 'Warum ist die Auswertung nicht korrekt?', ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen'), ), FilledButton( onPressed: () => Navigator.pop(ctx, ctrl.text.trim()), child: const Text('Ablehnen'), ), ], ), ); } void _snack(String msg) { if (!mounted) return; ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(msg))); } @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; return Column( children: [ ModeBanner(repository: widget.repository), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(ReclaimSpace.xl), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Juristische Freigaben', style: t.displaySmall), const SizedBox(height: ReclaimSpace.xs), Text( 'Offene Review-Aufgaben aus dem Hub. Prüfen, ' 'ob die Auswertung mit dem Gesetzestext ' 'übereinstimmt, dann Haken setzen oder ablehnen.', style: t.bodyLarge ?.copyWith(color: ReclaimColors.mute), ), ], ), ), IconButton( onPressed: _reload, icon: const Icon(Icons.refresh), tooltip: 'Aktualisieren', ), ], ), const SizedBox(height: ReclaimSpace.lg), _ReviewerField(controller: _reviewer), const SizedBox(height: ReclaimSpace.lg), if (_hub == null) const _HintCard( icon: Icons.hub_outlined, title: 'Freigaben brauchen den Live-Hub', body: 'Juristen-Reviews kommen aus laufenden Flows. ' 'Im Hub-Reiter den Live-Modus aktivieren — dann ' 'erscheinen hier die offenen system.approval-' 'Aufgaben.', ) else FutureBuilder>( future: _future, builder: (context, snap) { if (!snap.hasData) { return const Padding( padding: EdgeInsets.all(ReclaimSpace.xl), child: Center(child: CircularProgressIndicator()), ); } final items = snap.data!; if (items.isEmpty) { return const _HintCard( icon: Icons.inbox_outlined, title: 'Keine offenen Freigaben', body: 'Aktuell wartet keine Auswertung auf eine ' 'juristische Bestätigung.', ); } return Column( children: [ for (final e in items) Padding( padding: const EdgeInsets.only( bottom: ReclaimSpace.md), child: _ApprovalCard( entry: e, onApprove: () => _decide(e, approve: true), onReject: () => _decide(e, approve: false), ), ), ], ); }, ), ], ), ), ), ], ); } } class _ReviewerField extends StatelessWidget { const _ReviewerField({required this.controller}); final TextEditingController controller; @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; return ReclaimCard( child: Row( children: [ const Icon(Icons.badge_outlined, color: ReclaimColors.signal), const SizedBox(width: ReclaimSpace.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Prüfender Jurist', style: t.labelSmall ?.copyWith(color: ReclaimColors.mute)), TextField( controller: controller, decoration: const InputDecoration( isDense: true, border: InputBorder.none, hintText: 'Name / Kanzlei — wird im Audit-Log vermerkt', ), ), ], ), ), const Tooltip( message: 'Die Liste vertrauenswürdiger Juristen + Login ' 'erzwingt der Hub (chain). Hier wird der Name als ' 'Audit-Unterschrift mitgeführt.', child: Icon(Icons.info_outline, size: 18, color: ReclaimColors.mute), ), ], ), ); } } class _ApprovalCard extends StatelessWidget { const _ApprovalCard({ required this.entry, required this.onApprove, required this.onReject, }); final chain.PendingApprovalEntry entry; final VoidCallback onApprove; final VoidCallback onReject; @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; final prompt = entry.prompt.isNotEmpty ? entry.prompt : 'Freigabe für ${entry.flowName}'; return ReclaimCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.gavel_outlined, size: 18, color: ReclaimColors.signal), const SizedBox(width: ReclaimSpace.sm), Expanded(child: Text(prompt, style: t.titleMedium)), ], ), const SizedBox(height: 4), Text( '${entry.flowName} · ${entry.stepId}' '${entry.createdAt.isNotEmpty ? ' · ${entry.createdAt}' : ''}', style: t.labelSmall?.copyWith( fontFamily: ReclaimTypography.mono, color: ReclaimColors.mute, ), ), if (entry.payloadPreview.isNotEmpty) ...[ const SizedBox(height: ReclaimSpace.md), Container( width: double.infinity, constraints: const BoxConstraints(maxHeight: 180), padding: const EdgeInsets.all(ReclaimSpace.md), decoration: BoxDecoration( border: Border.all( color: Theme.of(context) .colorScheme .onSurface .withValues(alpha: 0.12), ), borderRadius: const BorderRadius.all(ReclaimRadius.sm), ), child: SingleChildScrollView( child: SelectableText( entry.payloadPreview, style: t.labelSmall?.copyWith( fontFamily: ReclaimTypography.mono, height: 1.4, ), ), ), ), ], const SizedBox(height: ReclaimSpace.md), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: onReject, icon: const Icon(Icons.close, size: 18), label: const Text('Ablehnen'), style: TextButton.styleFrom( foregroundColor: ReclaimColors.harmWarm, ), ), const SizedBox(width: ReclaimSpace.sm), FilledButton.icon( onPressed: onApprove, icon: const Icon(Icons.check, size: 18), label: const Text('Korrekt — freigeben'), ), ], ), ], ), ); } } class _HintCard extends StatelessWidget { const _HintCard({ required this.icon, required this.title, required this.body, }); final IconData icon; final String title; final String body; @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; return ReclaimCard( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, color: ReclaimColors.mute), const SizedBox(width: ReclaimSpace.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: t.titleMedium), const SizedBox(height: 4), Text(body, style: t.bodyLarge ?.copyWith(color: ReclaimColors.mute, height: 1.4)), ], ), ), ], ), ); } }