reclaim/app/lib/pages/heatmap_page.dart
flemming-it 94f4473686 feat(ui): collapsible legend gives the heatmap more vertical room
The legend below the heatmap used four full prose paragraphs at
all times — visually it claimed about a quarter of the heatmap
page's height while the plot itself was the actual artefact.
Now the legend defaults to a single-line header strip:

  Legende    ↔ Schaden    ↕ Nutzen    ● Betroffenheit
             ◐ Evidenz-Stufe                              ⌄

Tap anywhere on the strip and an AnimatedSize transition opens
the full _LegendRow descriptions (180 ms). Same content, hidden
until asked for. The Expanded(heatmap) above reclaims the
height — concretely on a 900 px window the plot grows from
~420 px to ~600 px tall, which is what makes hover-targeting
the smaller points actually comfortable.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-18 14:54:01 +02:00

364 lines
13 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/demo_banner.dart';
import '../widgets/heatmap_grid.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: [
const DemoBanner(),
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: ReclaimCard(
accent: true,
expand: true,
child: HeatmapGrid(
evaluations: items,
onTap: (e) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NormDetailPage(
repository: widget.repository,
evaluation: e,
),
),
);
},
),
),
),
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: [
TierBadge(
tier: e.tierLowest, compact: true),
const SizedBox(
width: ReclaimSpace.sm),
Text(
'${e.norm.jurabk} ${e.norm.paragraph}',
style: t.labelLarge,
),
],
),
),
),
],
),
],
);
},
),
),
),
],
);
}
}
/// 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'),
],
),
),
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.',
),
],
),
)
: const SizedBox.shrink(),
),
],
),
),
),
);
}
}
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,
),
),
),
),
],
);
}
}