Three small frictions a first-time reader hit at the same time,
fixed in one pass:
1. The TierBadge said 'T1' without context. Default form is
now 'T1 · amtlich' / 'T2 · Verband' / 'T3 · Erhebung' /
'T4 · Signal'. Compact form (heatmap chip wrap) keeps the
bare code. Tooltip spells out the long form + cites the
Regel der niedrigsten Stufe (Studie §6.7) so the meaning
of the cap is at hand on hover.
2. The Legende's tier row was the abstract one of the four —
it now inlines all four stage names (amtlich / Verband /
Erhebung / Signal) and explains why a single T4 component
drags a whole figure to T4. No more 'what is Tier?' on
screen.
3. The DemoBanner repeated 'Tier T4' without explaining what
T4 is. Now: 'qualitatives Signal — bis NKR/Verbands-
Pipeline + Juristen-Approval laufen'. The cause-and-cure
is visible at a glance.
4. The NavigationRail leading column showed only 'F∆I' as
brand mark. Now shows 'Recl∆Im' prominently (petrol-signal,
bold) with 'F∆I' as a small muted vendor tag below.
Product first, vendor second.
EvidenceTierLabel extension grows a new 'headline' getter for
the one-word default ('amtlich'/'Verband'/…), and 'description'
gets longer, source-naming text (NKR / DESTATIS / DIHK / IHK /
ZDH / BDI). Code paths that used the old short-only badge keep
working because 'short' still returns 'T1'.
flutter pub get refreshed against chain_client_sdk 0.18, which
the fai_chain agent confirmed already carries the gRPC-Web
channel — no SDK bump needed for the web-hub-mode wire.
Signed-off-by: flemming-it <sf@flemming.it>
268 lines
8.9 KiB
Dart
268 lines
8.9 KiB
Dart
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. Spells out what is
|
||
/// plotted and how — the four-line top description deliberately
|
||
/// stays short and high-level, this card carries the precise
|
||
/// reading instructions a reviewer needs to defend the plot
|
||
/// against "what does that even mean?"
|
||
class _LegendCard extends StatelessWidget {
|
||
const _LegendCard();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = Theme.of(context).textTheme;
|
||
return ReclaimCard(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('Legende', style: t.headlineSmall),
|
||
const SizedBox(height: ReclaimSpace.md),
|
||
_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).',
|
||
),
|
||
const 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.',
|
||
),
|
||
const 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.',
|
||
),
|
||
const 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.',
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|