reclaim/app/lib/pages/heatmap_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

467 lines
18 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/heatmap_grid.dart';
import '../widgets/jurisdiction_badge.dart';
import '../widgets/reclaim_card.dart';
import '../widgets/tier_badge.dart';
import 'norm_detail_page.dart';
class HeatmapPage extends StatefulWidget {
const HeatmapPage({super.key, required this.repository});
final EvaluationRepository repository;
@override
State<HeatmapPage> createState() => _HeatmapPageState();
}
class _HeatmapPageState extends State<HeatmapPage> {
late Future<List<Evaluation>> _future;
@override
void initState() {
super.initState();
_future = widget.repository.list();
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Column(
children: [
ModeBanner(repository: widget.repository),
Expanded(
child: Padding(
padding: const EdgeInsets.all(ReclaimSpace.xl),
child: FutureBuilder<List<Evaluation>>(
future: _future,
builder: (context, snap) {
if (!snap.hasData) {
return const Center(
child: CircularProgressIndicator());
}
final items = snap.data!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Heatmap', style: t.displaySmall),
const SizedBox(height: ReclaimSpace.xs),
Text(
'Schaden × Nutzen × Betroffenheit pro Norm. '
'Klick auf einen Punkt öffnet das Detail mit '
'Pflichten, Quellen und Gesetzestext.',
style: t.bodyLarge?.copyWith(
color: ReclaimColors.mute,
),
),
const SizedBox(height: ReclaimSpace.lg),
Expanded(
child: items.isEmpty
? _EmptyHubState(repository: widget.repository)
: ReclaimCard(
accent: true,
expand: true,
child: HeatmapGrid(
evaluations: items,
onTap: (e) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NormDetailPage(
repository: widget.repository,
evaluation: e,
),
),
);
},
),
),
),
if (items.isNotEmpty) ...[
const SizedBox(height: ReclaimSpace.md),
const _LegendCard(),
const SizedBox(height: ReclaimSpace.lg),
Wrap(
spacing: ReclaimSpace.md,
runSpacing: ReclaimSpace.sm,
children: [
for (final e in items)
InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NormDetailPage(
repository: widget.repository,
evaluation: e,
),
),
);
},
child: ReclaimCard(
padding: const EdgeInsets.symmetric(
horizontal: ReclaimSpace.md,
vertical: ReclaimSpace.sm,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
JurisdictionBadge(
jurisdiction: e.norm.jurisdiction,
compact: true),
const SizedBox(
width: ReclaimSpace.xs),
TierBadge(
tier: e.tierLowest, compact: true),
const SizedBox(
width: ReclaimSpace.sm),
Text(
'${e.norm.jurabk} ${e.norm.paragraph}',
style: t.labelLarge,
),
Text(
' · ',
style: t.labelLarge?.copyWith(
color: ReclaimColors.mute,
),
),
Text(
e.norm.title,
style: t.labelLarge?.copyWith(
color: ReclaimColors.mute,
fontWeight: FontWeight.w400,
),
),
],
),
),
),
],
),
],
],
);
},
),
),
),
],
);
}
}
/// Axis + circle legend below the heatmap.
///
/// Collapsed default: one-line header row with mini-labels for
/// Schaden / Nutzen / Betroffenheit / Evidenz-Stufe + chevron —
/// keeps the heatmap visually dominant and gives back vertical
/// space to the plot. Tap anywhere on the header expands the
/// full reading-instructions (axes, scale, sources, Tier rules).
class _LegendCard extends StatefulWidget {
const _LegendCard();
@override
State<_LegendCard> createState() => _LegendCardState();
}
class _LegendCardState extends State<_LegendCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ReclaimCard(
padding: const EdgeInsets.symmetric(
horizontal: ReclaimSpace.lg,
vertical: ReclaimSpace.sm,
),
child: InkWell(
onTap: () => setState(() => _expanded = !_expanded),
borderRadius:
const BorderRadius.all(ReclaimRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: ReclaimSpace.xs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Legende',
style: t.titleMedium?.copyWith(
color: ReclaimColors.mute,
)),
const SizedBox(width: ReclaimSpace.lg),
const Expanded(
child: Wrap(
spacing: ReclaimSpace.lg,
runSpacing: ReclaimSpace.xs,
children: [
_MiniLabel(symbol: '', text: 'Schaden'),
_MiniLabel(symbol: '', text: 'Nutzen'),
_MiniLabel(
symbol: '', text: 'Betroffenheit'),
_MiniLabel(
symbol: '', text: 'Evidenz-Stufe'),
_MiniLabel(
symbol: '', text: 'Geltungsbereich'),
],
),
),
Icon(
_expanded
? Icons.expand_less
: Icons.expand_more,
color: ReclaimColors.mute,
),
],
),
AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
child: _expanded
? const Padding(
padding:
EdgeInsets.only(top: ReclaimSpace.lg),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
_LegendRow(
symbol: '',
axis: 'Abszisse (X)',
label: 'Schaden',
description:
'Bürokratiekosten pro Jahr in Euro, '
'logarithmisch skaliert (10⁴ = 10 k, '
'10⁶ = 1 Mio, 10⁹ = 1 Mrd). '
'Berechnet nach Standardkostenmodell '
'P × F × T × h (Studie §3.1).',
),
SizedBox(height: ReclaimSpace.sm),
_LegendRow(
symbol: '',
axis: 'Ordinate (Y)',
label: 'Nutzen',
description:
'Komposit-Score von 05, Mittelwert '
'aus den sieben Dimensionen '
'Schutz · Markt · Rechtssicherheit · '
'EU-Binnenmarkt · Sicherheit · '
'Umwelt · Einnahmen (Studie §3.2). '
'Jede Dimension einzeln '
'quellen-begründet.',
),
SizedBox(height: ReclaimSpace.sm),
_LegendRow(
symbol: '',
axis: 'Punktgröße',
label: 'Betroffenheit',
description:
'Anzahl der Adressaten (KMU, Bürger, '
'Behörden), wurzel-skaliert. Mehr '
'Adressaten → größerer Kreis. '
'Quellen: DESTATIS Unternehmensregister, '
'IHK-/Handwerkskammer-Mitgliederzahlen.',
),
SizedBox(height: ReclaimSpace.sm),
_LegendRow(
symbol: '',
axis: 'Farb-Kante',
label: 'Evidenz-Stufe',
description:
'Jeder Wert trägt eine von vier Stufen — '
'T1 amtlich (NKR, DESTATIS, ifo, ZEW), '
'T2 Verband (DIHK, IHK, ZDH, BDI), '
'T3 eigene Erhebung, '
'T4 qualitatives Signal. '
'Eine Zahl bekommt die Stufe ihrer '
'schwächsten Eingangs-Variable — wenn h '
'nur geschätzt ist (T4), ist die ganze '
'SKM-Zahl T4. Petrol → warm = stark → '
'schwach.',
),
SizedBox(height: ReclaimSpace.sm),
_LegendRow(
symbol: '',
axis: 'Chip-Symbol',
label: 'Geltungsbereich',
description:
'Jede Norm trägt ein Scope-Chip — '
'BE Berlin (Landesrecht, Senat/Bezirk), '
'DE Deutschland (Bundesrecht, NKR/'
'Bundestag), EU (Reform-Hebel in Brüssel), '
'INT International (WTO/OECD/UN). Es zeigt '
'an, auf welcher Ebene der Reform-Hebel '
'ansetzt: eine EU-Norm lässt sich nicht '
'durch nationale Streichung entlasten.',
),
],
),
)
: const SizedBox.shrink(),
),
],
),
),
),
);
}
}
/// Shown in place of the heatmap when the repository returns zero
/// evaluations — almost always live-hub mode against a hub that
/// has no Recl∆Im `flow.completed` events yet (or is unreachable).
/// Tells the reader exactly how to get data on screen instead of
/// leaving a blank plot.
class _EmptyHubState extends StatelessWidget {
const _EmptyHubState({required this.repository});
final EvaluationRepository repository;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final down = repository.mode == 'hub-down';
return ReclaimCard(
accent: true,
expand: true,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
down ? Icons.cloud_off_outlined : Icons.inbox_outlined,
size: 40,
color: ReclaimColors.mute,
),
const SizedBox(height: ReclaimSpace.md),
Text(
down
? 'Hub nicht erreichbar'
: 'Noch keine Auswertungen im Hub',
style: t.headlineSmall,
),
const SizedBox(height: ReclaimSpace.sm),
Text(
down
? 'Verbunden mit ${repository.hubLabel}. Läuft '
'`chain serve`? Sonst im Hub-Reiter den '
'Demo-Modus aktivieren — dann zeigt die '
'Heatmap das Fixture-Korpus.'
: 'Der Hub liefert keine flow.completed-Events. '
'Einen Recl∆Im-Flow laufen lassen, z. B.\n'
' chain run flows/durchstich-from-file.yaml '
'--input content=@norm.xml --input '
'cohort_id=berlin-kmu\n'
'und im Hub/Studio freigeben — oder im '
'Hub-Reiter den Demo-Modus aktivieren.',
style: t.bodyLarge?.copyWith(
color: ReclaimColors.mute,
height: 1.4,
),
),
],
),
),
),
);
}
}
class _MiniLabel extends StatelessWidget {
const _MiniLabel({required this.symbol, required this.text});
final String symbol;
final String text;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(symbol,
style: t.titleMedium?.copyWith(
color: ReclaimColors.signal,
)),
const SizedBox(width: 6),
Text(text, style: t.labelLarge),
],
);
}
}
class _LegendRow extends StatelessWidget {
const _LegendRow({
required this.symbol,
required this.axis,
required this.label,
required this.description,
});
final String symbol;
final String axis;
final String label;
final String description;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 28,
child: Text(
symbol,
style: t.titleMedium?.copyWith(
color: ReclaimColors.signal,
fontSize: 18,
),
),
),
SizedBox(
width: 140,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
axis,
style: t.labelSmall?.copyWith(
color: ReclaimColors.mute,
letterSpacing: 0.4,
),
),
Text(
label,
style: t.titleMedium,
),
],
),
),
const SizedBox(width: ReclaimSpace.md),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
description,
style: t.bodyLarge?.copyWith(
color: ReclaimColors.mute,
height: 1.5,
),
),
),
),
],
);
}
}