reclaim/app/lib/pages/heatmap_page.dart
flemming-it 51beb332c4 feat(ui): ModeBanner replaces DemoBanner + no silent mock-fallback
Two related fixes so the app never silently presents itself as
a demo when it is in fact running against a real hub:

  - The orange 'Demo-Daten · KEINE Rechtsberatung' banner was
    hard-coded into every data screen, regardless of whether
    the active repository was the in-memory MockRepository or
    the live HubRepository. Replaced with ModeBanner, which
    picks one of three voices by repository.mode:

      demo      orange T4 — 'Demo-Daten · KEINE Rechtsberatung'
      hub-live  petrol     — 'Live aus Hub · <endpoint>'
      hub-down  warm-warn  — 'Hub nicht erreichbar — leere Liste'

  - main.dart's _pickRepository used to fall back to
    MockRepository whenever HubRepository.connect() came back
    unhealthy. That silently downgraded a live deployment to
    demo data, which is exactly what the user is trying to avoid.
    Removed: when useHub=true, the HubRepository is returned
    regardless. Its list() returns [] on hub-down, and the
    'hub-down' banner explains why the screens are empty.
    Mock data only shows when the user explicitly turns useHub
    off in the Hub-Reiter.

Also: legend arrow icons now point in the actual axis direction
(→ for Schaden, ↑ for Nutzen) instead of the bidirectional ↔/↕,
which suggested the axes ran both ways. Mini-chips and the
expanded legend rows are consistent.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-18 15:57:50 +02:00

377 lines
14 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/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: 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,
),
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'),
],
),
),
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,
),
),
),
),
],
);
}
}