Adds three dark-first chip badges in the TierBadge pattern: - Jurisdiction (Berlin/DE/EU/International) — derived from the ELI, surfaced on the norms list, detail header and heatmap chips/hover. - Freshness (aktuell/geaendert/ungeprueft) — flags norms amended upstream (standDate + sourceSha256); renders only when stale. - Jurist review — marks evaluations a trusted jurist has confirmed (optional; the figure stands on its own). Models gain Jurisdiction/Freshness enums, Norm.freshness/supersededNote and Evaluation.reviewedBy/reviewedAt; HubRepository parses them from the flow bag (ELI fallback). HeatmapPage shows a real empty-state (hub-down vs no-evaluations) instead of a blank plot, and connect() no longer throws on a failed probe. Signed-off-by: flemming-it <sf@flemming.it>
467 lines
18 KiB
Dart
467 lines
18 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/jurisdiction_badge.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: items.isEmpty
|
||
? _EmptyHubState(repository: widget.repository)
|
||
: ReclaimCard(
|
||
accent: true,
|
||
expand: true,
|
||
child: HeatmapGrid(
|
||
evaluations: items,
|
||
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 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: [
|
||
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 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.',
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|