reclaim/app/lib/pages/norm_detail_page.dart
flemming-it aabff2b140 feat(ui): scope, freshness and jurist-review badges + live empty-state
Adds three dark-first chip badges in the TierBadge pattern:
- Jurisdiction (Berlin/DE/EU/International) — derived from the ELI,
  surfaced on the norms list, detail header and heatmap chips/hover.
- Freshness (aktuell/geaendert/ungeprueft) — flags norms amended
  upstream (standDate + sourceSha256); renders only when stale.
- Jurist review — marks evaluations a trusted jurist has confirmed
  (optional; the figure stands on its own).
Models gain Jurisdiction/Freshness enums, Norm.freshness/supersededNote
and Evaluation.reviewedBy/reviewedAt; HubRepository parses them from
the flow bag (ELI fallback). HeatmapPage shows a real empty-state
(hub-down vs no-evaluations) instead of a blank plot, and connect()
no longer throws on a failed probe.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-19 02:25:39 +02:00

297 lines
10 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 'package:flutter/material.dart';
import '../data/models.dart';
import '../data/repository.dart';
import '../theme/reclaim_tokens.dart';
import '../widgets/mode_banner.dart';
import '../widgets/evidence_sidebar.dart';
import '../widgets/freshness_badge.dart';
import '../widgets/jurisdiction_badge.dart';
import '../widgets/norm_text_card.dart';
import '../widgets/reclaim_card.dart';
import '../widgets/review_badge.dart';
import '../widgets/tier_badge.dart';
class NormDetailPage extends StatelessWidget {
const NormDetailPage({
super.key,
required this.repository,
required this.evaluation,
});
final EvaluationRepository repository;
final Evaluation evaluation;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final e = evaluation;
return Scaffold(
appBar: AppBar(
title: Text('${e.norm.jurabk} ${e.norm.paragraph}'),
),
body: Column(
children: [
ModeBanner(repository: repository),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(ReclaimSpace.xl),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(e.norm.title,
style: t.displaySmall),
const SizedBox(height: 4),
SelectableText(
e.norm.eli,
style: t.bodyLarge?.copyWith(
fontFamily:
ReclaimTypography.mono,
color: ReclaimColors.mute,
),
),
],
),
),
Column(
crossAxisAlignment:
CrossAxisAlignment.end,
children: [
JurisdictionBadge(
jurisdiction: e.norm.jurisdiction),
const SizedBox(height: ReclaimSpace.sm),
TierBadge(tier: e.tierLowest),
if (e.norm.freshness.needsBadge) ...[
const SizedBox(
height: ReclaimSpace.sm),
FreshnessBadge(
freshness: e.norm.freshness,
note: e.norm.supersededNote,
),
],
const SizedBox(height: ReclaimSpace.sm),
ReviewBadge(
evaluation: e, showUnreviewed: true),
],
),
],
),
const SizedBox(height: ReclaimSpace.lg),
_MetricsRow(e: e),
const SizedBox(height: ReclaimSpace.lg),
NormTextCard(norm: e.norm),
const SizedBox(height: ReclaimSpace.lg),
ReclaimCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Pflichten',
style: t.headlineSmall),
const SizedBox(
height: ReclaimSpace.sm),
Text(
'Atomare deontische Aussagen, extrahiert '
'aus dem Norm-Text (Phase 1: regex + LLM-'
'Vorschlag, Juristen-Review obligatorisch).',
style: t.labelSmall?.copyWith(
color: ReclaimColors.mute),
),
const SizedBox(
height: ReclaimSpace.md),
for (final d in e.duties)
_DutyTile(duty: d),
],
),
),
if (e.notes != null) ...[
const SizedBox(height: ReclaimSpace.lg),
ReclaimCard(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text('Methodik-Notiz',
style: t.headlineSmall),
const SizedBox(
height: ReclaimSpace.sm),
Text(e.notes!, style: t.bodyLarge),
],
),
),
],
],
),
),
const SizedBox(width: ReclaimSpace.xl),
SizedBox(
width: 340,
child: EvidenceSidebar(evaluation: e),
),
],
),
),
),
],
),
);
}
}
class _MetricsRow extends StatelessWidget {
const _MetricsRow({required this.e});
final Evaluation e;
@override
Widget build(BuildContext context) {
return Row(
children: [
_MetricBox(
label: 'Schaden',
value: _eur(e.skmEurPerYear),
sub: 'SKM P×F×T×h',
),
const SizedBox(width: ReclaimSpace.md),
_MetricBox(
label: 'Nutzen',
value: '${e.benefitScore.toStringAsFixed(1)} / 5',
sub: '7-dim. Score',
),
const SizedBox(width: ReclaimSpace.md),
_MetricBox(
label: 'Betroffene',
value: _kmu(e.affectedCount),
sub: 'DESTATIS',
),
const SizedBox(width: ReclaimSpace.md),
_MetricBox(
label: 'Frust',
value: '${e.frustScore.toStringAsFixed(1)} / 10',
sub: 'Lesbarkeit + Tiefe',
),
],
);
}
static String _eur(double v) {
if (v >= 1e9) return '${(v / 1e9).toStringAsFixed(2)} Mrd €';
if (v >= 1e6) return '${(v / 1e6).toStringAsFixed(1)} Mio €';
if (v >= 1e3) return '${(v / 1e3).toStringAsFixed(0)} k €';
return '${v.toStringAsFixed(0)}';
}
static String _kmu(int n) {
if (n >= 1_000_000) return '${(n / 1e6).toStringAsFixed(1)} Mio';
if (n >= 1_000) return '${(n / 1e3).toStringAsFixed(0)} k';
return n.toString();
}
}
class _MetricBox extends StatelessWidget {
const _MetricBox({
required this.label,
required this.value,
required this.sub,
});
final String label;
final String value;
final String sub;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Expanded(
child: ReclaimCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: t.labelSmall
?.copyWith(color: ReclaimColors.mute)),
const SizedBox(height: 4),
Text(value, style: t.displaySmall),
const SizedBox(height: 4),
Text(sub,
style: t.labelSmall
?.copyWith(color: ReclaimColors.mute)),
],
),
),
);
}
}
class _DutyTile extends StatelessWidget {
const _DutyTile({required this.duty});
final Duty duty;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(bottom: ReclaimSpace.md),
child: Container(
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: ReclaimColors.signal.withValues(alpha: 0.18),
borderRadius:
const BorderRadius.all(ReclaimRadius.sm),
),
child: Text(
duty.modality,
style: t.labelSmall?.copyWith(
color: ReclaimColors.signal,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: ReclaimSpace.sm),
Text(duty.sourceNorm,
style: t.labelSmall?.copyWith(
fontFamily: ReclaimTypography.mono,
color: ReclaimColors.mute,
)),
]),
const SizedBox(height: ReclaimSpace.sm),
Text(duty.action, style: t.bodyLarge),
const SizedBox(height: 4),
Text(
'Adressat: ${duty.addressee} · '
'Frequenz: ${duty.frequency}'
'${duty.authority != null ? " · Behörde: ${duty.authority}" : ""}'
'${duty.consequence != null ? " · Sanktion: ${duty.consequence}" : ""}',
style: t.labelSmall?.copyWith(color: ReclaimColors.mute),
),
],
),
),
);
}
}