reclaim/app/lib/pages/heatmap_page.dart
flemming-it 4e00c83591 feat(ui): heatmap view modes (all / jurist-reviewed / marked)
A segmented toggle over the heatmap: 'Alle' shows every evaluation,
'Geprüft (N)' filters to jurist-confirmed ones, 'Markiert' shows all
with a confirm-green check on the reviewed points. Jurists are
optional — the platform works without them and the default is 'Alle';
review only confirms a figure. Empty filtered view gets its own hint.

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

589 lines
22 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';
/// Heatmap view modes. The platform works WITHOUT jurists — a jurist
/// pass only *confirms* an evaluation — so the default shows all.
enum _HeatmapView {
/// Every evaluation, jurist-reviewed or not.
all,
/// Only evaluations a jurist has confirmed.
reviewedOnly,
/// All evaluations, but reviewed ones carry a confirm marker.
marked,
}
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;
_HeatmapView _view = _HeatmapView.all;
@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!;
final shown = _view == _HeatmapView.reviewedOnly
? items.where((e) => e.isReviewed).toList()
: items;
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,
),
),
if (items.isNotEmpty) ...[
const SizedBox(height: ReclaimSpace.md),
_ViewModeSelector(
view: _view,
reviewedCount:
items.where((e) => e.isReviewed).length,
total: items.length,
onChanged: (v) => setState(() => _view = v),
),
],
const SizedBox(height: ReclaimSpace.lg),
Expanded(
child: items.isEmpty
? _EmptyHubState(repository: widget.repository)
: shown.isEmpty
? const _NoReviewedState()
: ReclaimCard(
accent: true,
expand: true,
child: HeatmapGrid(
evaluations: shown,
markReviewed:
_view == _HeatmapView.marked,
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 shown)
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,
),
),
],
),
),
),
);
}
}
/// Heatmap view-mode toggle: all / jurist-reviewed only / marked.
class _ViewModeSelector extends StatelessWidget {
const _ViewModeSelector({
required this.view,
required this.reviewedCount,
required this.total,
required this.onChanged,
});
final _HeatmapView view;
final int reviewedCount;
final int total;
final ValueChanged<_HeatmapView> onChanged;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Row(
children: [
const Icon(Icons.visibility_outlined,
size: 16, color: ReclaimColors.mute),
const SizedBox(width: ReclaimSpace.sm),
Text('Ansicht',
style: t.labelLarge?.copyWith(color: ReclaimColors.mute)),
const SizedBox(width: ReclaimSpace.md),
SegmentedButton<_HeatmapView>(
showSelectedIcon: false,
style: const ButtonStyle(visualDensity: VisualDensity.compact),
segments: [
const ButtonSegment(
value: _HeatmapView.all,
label: Text('Alle'),
icon: Icon(Icons.scatter_plot_outlined, size: 15),
),
ButtonSegment(
value: _HeatmapView.reviewedOnly,
label: Text('Geprüft ($reviewedCount)'),
icon: const Icon(Icons.verified_outlined, size: 15),
),
const ButtonSegment(
value: _HeatmapView.marked,
label: Text('Markiert'),
icon: Icon(Icons.label_important_outline, size: 15),
),
],
selected: {view},
onSelectionChanged: (s) => onChanged(s.first),
),
],
);
}
}
/// Shown when the 'jurist-reviewed only' filter leaves nothing.
class _NoReviewedState extends StatelessWidget {
const _NoReviewedState();
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ReclaimCard(
accent: true,
expand: true,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.verified_outlined,
size: 40, color: ReclaimColors.mute),
const SizedBox(height: ReclaimSpace.md),
Text('Noch keine juristen-geprüften Auswertungen',
style: t.headlineSmall),
const SizedBox(height: ReclaimSpace.sm),
Text(
'Die Plattform funktioniert auch ohne Juristen — wechsle '
'auf „Alle", um alle Auswertungen zu sehen. Ein '
'Juristen-Review bestätigt eine Auswertung zusätzlich.',
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,
),
),
),
),
],
);
}
}