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