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 createState() => _HeatmapPageState(); } class _HeatmapPageState extends State { late Future> _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>( 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 0–5, 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, ), ), ), ), ], ); } }