feat: initial F∆I Law-Heatmap Phase 0 scaffold

First end-to-end-buildable cut of the pflicht-/graph-basierte
Wirkungsanalyse von Rechtsnormen, running on Ch∆In. Demo runs
without external dependencies and without a hub connection so
the methodology + UI stack can be reviewed before any Verbands-
Kooperation or Beirats-Validierung kicks in.

Tree layout

  MACHBARKEITSSTUDIE.md       v0.3 — pflicht-basierte Methodik,
                              4-Tier-Evidenz, 8 law*-Module
                              auf Ch∆In statt der alten F∆I-
                              Plattform.
  RUN.md                      one-page how-to-run.
  flows/durchstich-gewo-14.yaml
                              vertical Phase-0 flow: pull →
                              normalize → duties → citations →
                              cohort → skm → frust → benefit →
                              attribution → system.approval.
  app/                        Flutter macOS client, design
                              language pulled from fai_web
                              (ink #08090A, paper #F4F3EF,
                              petrol signal #2E8F9E). Mock
                              repository ships five fixture
                              norms with real source URLs and a
                              permanent T4 demo-banner so no
                              figure can be mistaken for a
                              validated one.

Module repos (separate, see chain-modules*/):

  text-akoma-normalize, text-deontic-extract, graph-citation-
  extract, text-readability-score, stats-cohort-size, graph-
  shapley-attribution, econ-skm-score, law-benefit-score.

What runs today

  - flutter analyze: 0 issues
  - flutter test: 2/2 (landing + nav-to-shell smoke)
  - flutter build macos (via app/build-macos.sh) and ad-hoc
    codesign, app launches and the Dart VM service comes up
  - native cargo test green on every module
  - cargo build --release --target wasm32-wasip2 produces a
    130 KiB artefact for text-akoma-normalize

What is deliberately mock / stub

  - gRPC wire from the client to a chain serve hub (Repository
    abstraction is in place; live impl is the next step)
  - NKR Bürokratiekosten-Datenbank ingestion (for the canonical
    h-Werte that close out the Engpass per Studie §6.7)
  - DESTATIS GENESIS-API adapter for stats.cohort_size

License: Apache-2.0. Author/contact in MACHBARKEITSSTUDIE.md.
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-18 11:26:28 +02:00
commit 8ee6a294a1
64 changed files with 5641 additions and 0 deletions

274
app/lib/data/fixtures.dart Normal file
View file

@ -0,0 +1,274 @@
import 'models.dart';
/// Phase-0 fixture data for the five pilot norms.
///
/// **THIS IS DEMO DATA.** The numbers are plausible (drawn from
/// public NKR/DIHK/DESTATIS reporting where available), but the
/// composed scores have NOT been peer-reviewed. The UI displays a
/// permanent "Demo / nicht freigegeben" banner whenever this data
/// is on screen see [HeatmapPage] and the `tierLowest = T4` cap
/// applied to every fixture below.
///
/// Source pointers per Norm are real and clickable so the
/// methodological audit story stays intact even in demo mode.
class Fixtures {
static final List<Evaluation> evaluations = [
_gewo14,
_kassenSichV,
_dsgvoArt30,
_berlinBauO60,
_miLoG17,
];
static final _gewo14 = Evaluation(
norm: Norm(
eli: 'eli/bund/gewo/14',
title: 'Anzeigepflicht',
jurabk: 'GewO',
paragraph: '§ 14',
standDate: DateTime.utc(2025, 12, 1),
sourceUrl: 'https://www.gesetze-im-internet.de/gewo/__14.html',
sourceSha256: 'demo-${'a' * 60}',
),
skmEurPerYear: 2_800_000,
benefitScore: 2.5,
affectedCount: 180_000,
frustScore: 4.5,
tierLowest: EvidenceTier.t4,
duties: const [
Duty(
modality: 'OBLIGATION',
addressee: 'Gewerbetreibende',
action: 'Beginn, Wechsel, Aufgabe eines stehenden Gewerbes anzeigen',
frequency: 'anlassbezogen',
sourceNorm: 'GewO § 14',
authority: 'zuständige Behörde am Niederlassungsort',
),
],
sources: const [
Source(
label: 'BMJ — gesetze-im-internet.de',
url: 'https://www.gesetze-im-internet.de/gewo/__14.html',
tier: EvidenceTier.t1,
),
Source(
label: 'NKR Bürokratiekosten-Datenbank',
url: 'https://www.normenkontrollrat.bund.de/',
tier: EvidenceTier.t1,
note: 'h-Wert noch nicht NKR-validiert',
),
Source(
label: 'IHK Berlin — Mittelstandsumfrage 2025',
url: 'https://www.ihk.de/berlin',
tier: EvidenceTier.t2,
),
],
auditEventId: 'demo-evt-gewo-14',
notes:
'Demo-Schätzung. h aus IHK-Beispielwert, P aus DESTATIS Unternehmensregister Berlin.',
);
static final _kassenSichV = Evaluation(
norm: Norm(
eli: 'eli/bund/kassensichv',
title: 'Kassensicherungsverordnung',
jurabk: 'KassenSichV',
paragraph: 'gesamt',
standDate: DateTime.utc(2025, 11, 15),
sourceUrl:
'https://www.gesetze-im-internet.de/kassensichv/index.html',
sourceSha256: 'demo-${'b' * 60}',
),
skmEurPerYear: 25_000_000,
benefitScore: 3.0,
affectedCount: 500_000,
frustScore: 7.5,
tierLowest: EvidenceTier.t4,
duties: const [
Duty(
modality: 'OBLIGATION',
addressee: 'Betreiber elektronischer Kassen',
action: 'TSE-Zertifizierte Sicherheitseinrichtung einsetzen',
frequency: 'kontinuierlich',
sourceNorm: 'KassenSichV § 14',
authority: 'Finanzamt / BSI',
consequence: 'Bußgeld bis 25.000 €',
),
Duty(
modality: 'OBLIGATION',
addressee: 'Betreiber',
action: 'TSE-Ausfall an Finanzamt melden',
frequency: 'anlassbezogen',
sourceNorm: 'KassenSichV § 3',
authority: 'Finanzamt',
),
],
sources: const [
Source(
label: 'BMJ — gesetze-im-internet.de',
url:
'https://www.gesetze-im-internet.de/kassensichv/index.html',
tier: EvidenceTier.t1,
),
Source(
label: 'DIHK — Bürokratie-Index 2025',
url: 'https://www.dihk.de/buerokratie',
tier: EvidenceTier.t2,
),
Source(
label: 'DEHOGA Berlin Mittelstandsumfrage',
url: 'https://www.dehoga-berlin.de/',
tier: EvidenceTier.t2,
note: 'h-Wert aus Gastronomie-Stichprobe',
),
],
auditEventId: 'demo-evt-kassensichv',
notes:
'Demo. Hoher Frust-Score wegen TSE-Ausfallmeldung + Hardware-Folgekosten.',
);
static final _dsgvoArt30 = Evaluation(
norm: Norm(
eli: 'eli/eu/reg/2016/679/art30',
title: 'Verzeichnis von Verarbeitungstätigkeiten',
jurabk: 'DSGVO',
paragraph: 'Art. 30',
standDate: DateTime.utc(2025, 10, 1),
sourceUrl: 'https://eur-lex.europa.eu/eli/reg/2016/679',
sourceSha256: 'demo-${'c' * 60}',
),
skmEurPerYear: 450_000_000,
benefitScore: 3.8,
affectedCount: 3_000_000,
frustScore: 8.5,
tierLowest: EvidenceTier.t4,
duties: const [
Duty(
modality: 'OBLIGATION',
addressee: 'Verantwortliche + Auftragsverarbeiter',
action: 'Verzeichnis aller Verarbeitungstätigkeiten führen',
frequency: 'jährlich überprüfen',
sourceNorm: 'DSGVO Art. 30 Abs. 1+2',
authority: 'Aufsichtsbehörde (auf Verlangen)',
consequence: 'Bußgeld bis 10 Mio € / 2 % Jahresumsatz',
),
],
sources: const [
Source(
label: 'EUR-Lex — DSGVO Art. 30',
url: 'https://eur-lex.europa.eu/eli/reg/2016/679',
tier: EvidenceTier.t1,
),
Source(
label: 'DIHK — DSGVO-Belastung der Wirtschaft 2024',
url: 'https://www.dihk.de/dsgvo',
tier: EvidenceTier.t2,
),
Source(
label: 'BfDI — Tätigkeitsbericht',
url: 'https://www.bfdi.bund.de/',
tier: EvidenceTier.t1,
),
],
auditEventId: 'demo-evt-dsgvo-30',
notes:
'Demo. EU-determiniert → Reform-Vorschlag muss Brüssel adressieren, nicht DE-Streichung.',
);
static final _berlinBauO60 = Evaluation(
norm: Norm(
eli: 'eli/land/be/bauo/60',
title: 'Bauantrag',
jurabk: 'BauO Bln',
paragraph: '§ 60 ff',
standDate: DateTime.utc(2025, 9, 1),
sourceUrl:
'https://gesetze.berlin.de/bsbe/document/jlr-BauOBE2005V25P60',
sourceSha256: 'demo-${'d' * 60}',
),
skmEurPerYear: 72_000_000,
benefitScore: 3.5,
affectedCount: 15_000,
frustScore: 9.0,
tierLowest: EvidenceTier.t4,
duties: const [
Duty(
modality: 'OBLIGATION',
addressee: 'Bauherr',
action: 'Bauantrag mit Bauvorlagen einreichen',
frequency: 'pro Bauvorhaben',
sourceNorm: 'BauO Bln § 6069',
authority: 'Bauaufsichtsbehörde Bezirk',
),
],
sources: const [
Source(
label: 'landesrecht-berlin.de',
url:
'https://gesetze.berlin.de/bsbe/document/jlr-BauOBE2005V25P60',
tier: EvidenceTier.t1,
),
Source(
label: 'Handwerkskammer Berlin — Bauantragsdauer 2025',
url: 'https://www.hwk-berlin.de/',
tier: EvidenceTier.t2,
),
Source(
label: 'mein.berlin.de — Bürgerbeteiligung Bauverfahren',
url: 'https://mein.berlin.de/',
tier: EvidenceTier.t4,
),
],
auditEventId: 'demo-evt-berlinbauo-60',
notes:
'Demo. Höchster Frust-Score (Verweistiefe + Behörden-Pluralität Bezirk×Senat).',
);
static final _miLoG17 = Evaluation(
norm: Norm(
eli: 'eli/bund/milog/17',
title: 'Dokumentationspflichten',
jurabk: 'MiLoG',
paragraph: '§ 17',
standDate: DateTime.utc(2025, 11, 1),
sourceUrl: 'https://www.gesetze-im-internet.de/milog/__17.html',
sourceSha256: 'demo-${'e' * 60}',
),
skmEurPerYear: 270_000_000,
benefitScore: 3.2,
affectedCount: 3_000_000,
frustScore: 7.0,
tierLowest: EvidenceTier.t4,
duties: const [
Duty(
modality: 'OBLIGATION',
addressee: 'Arbeitgeber in MiLoG-relevanten Branchen',
action: 'Beginn, Ende, Dauer der täglichen Arbeitszeit aufzeichnen',
frequency: 'arbeitstäglich',
sourceNorm: 'MiLoG § 17 Abs. 1',
authority: 'Zoll (FKS)',
consequence: 'Bußgeld bis 30.000 €',
),
],
sources: const [
Source(
label: 'BMJ — gesetze-im-internet.de',
url: 'https://www.gesetze-im-internet.de/milog/__17.html',
tier: EvidenceTier.t1,
),
Source(
label: 'NKR — Stellungnahme Mindestlohngesetz',
url: 'https://www.normenkontrollrat.bund.de/',
tier: EvidenceTier.t1,
),
Source(
label: 'ZDH — Bürokratiekosten Handwerk',
url: 'https://www.zdh.de/',
tier: EvidenceTier.t2,
),
],
auditEventId: 'demo-evt-milog-17',
notes:
'Demo. P × F × T × h: 3 Mio Betroffene × 220 Arbeitstage × 0,1h × 30€/h.',
);
}

144
app/lib/data/models.dart Normal file
View file

@ -0,0 +1,144 @@
// Domain model for the FI Law-Heatmap client.
//
// These types are the lingua franca between the (eventually
// gRPC-fed) repository and the UI. Names mirror the Studie's
// §5.3 data model.
/// Evidence tier of the lowest-tier ingredient in a value.
/// Per Studie §6.7: "Regel der niedrigsten Stufe" any composed
/// figure carries the tier of its weakest input.
enum EvidenceTier {
/// Official / peer-reviewed: NKR, DESTATIS, ifo, ZEW.
t1,
/// Verbands-Erhebung: DIHK, BDI, ZDH, IHK.
t2,
/// Eigene strukturierte Erhebung (Phase 2/3).
t3,
/// Qualitatives Signal pain indicator only, never magnitude.
t4,
}
extension EvidenceTierLabel on EvidenceTier {
String get short => switch (this) {
EvidenceTier.t1 => 'T1',
EvidenceTier.t2 => 'T2',
EvidenceTier.t3 => 'T3',
EvidenceTier.t4 => 'T4',
};
String get description => switch (this) {
EvidenceTier.t1 => 'amtlich / peer-reviewed',
EvidenceTier.t2 => 'Verband',
EvidenceTier.t3 => 'eigene Erhebung',
EvidenceTier.t4 => 'qualitatives Signal',
};
}
/// A citable source never an LLM, always a public document or
/// dataset reference. UI surfaces these next to every figure.
class Source {
const Source({
required this.label,
required this.url,
required this.tier,
this.note,
});
final String label;
final String url;
final EvidenceTier tier;
final String? note;
}
/// One atomic obligation (Pflicht) extracted from a norm.
/// Tuple shape matches Studie §6.3.
class Duty {
const Duty({
required this.modality,
required this.addressee,
required this.action,
required this.frequency,
required this.sourceNorm,
this.authority,
this.consequence,
});
final String modality; // OBLIGATION | PROHIBITION |
final String addressee;
final String action;
final String frequency;
final String sourceNorm;
final String? authority;
final String? consequence;
}
/// One norm version a versioned legal text identified by its
/// (synthetic for now) ELI/CELEX.
class Norm {
const Norm({
required this.eli,
required this.title,
required this.jurabk,
required this.paragraph,
required this.standDate,
required this.sourceUrl,
required this.sourceSha256,
});
final String eli;
final String title;
final String jurabk;
final String paragraph;
final DateTime standDate;
final String sourceUrl;
final String sourceSha256;
}
/// A heatmap point: one norm with its computed scores.
/// Axes mirror §6 in the study.
class Evaluation {
const Evaluation({
required this.norm,
required this.skmEurPerYear,
required this.benefitScore,
required this.affectedCount,
required this.frustScore,
required this.tierLowest,
required this.duties,
required this.sources,
required this.auditEventId,
this.notes,
});
final Norm norm;
/// Yearly bureaucracy cost across the addressee population.
/// /year, computed via SKM (`P × F × T × h`).
final double skmEurPerYear;
/// 05, composite of seven dimensions
/// (safety / market / legal-certainty / EU-harmony / ).
final double benefitScore;
/// Number of addressees (KMU, Bürger, etc.). UI uses this for
/// heatmap point size.
final int affectedCount;
/// 010, composite of readability + Verweistiefe + Akteurs-
/// pluralität + Norm-Volatilität.
final double frustScore;
/// Lowest tier among all ingredients of any displayed figure.
final EvidenceTier tierLowest;
final List<Duty> duties;
final List<Source> sources;
/// Pointer into the ChIn audit log (SQLite event_log row).
final String auditEventId;
final String? notes;
}

View file

@ -0,0 +1,38 @@
import 'models.dart';
import 'fixtures.dart';
/// Repository abstraction for evaluations.
///
/// Phase 0 ships exactly one implementation: [MockRepository],
/// backed by [Fixtures]. The live implementation (gRPC against a
/// running `chain serve`) lands in Phase 0 week 1 its API will
/// match this interface so no UI change is needed.
abstract class EvaluationRepository {
Future<List<Evaluation>> list();
Future<Evaluation?> byEli(String eli);
String get mode;
String get hubLabel;
}
/// In-memory, no-network implementation. Marks itself clearly so
/// the UI shows a "demo mode" badge.
class MockRepository implements EvaluationRepository {
const MockRepository();
@override
Future<List<Evaluation>> list() async => Fixtures.evaluations;
@override
Future<Evaluation?> byEli(String eli) async {
for (final e in Fixtures.evaluations) {
if (e.norm.eli == eli) return e;
}
return null;
}
@override
String get mode => 'demo';
@override
String get hubLabel => 'in-memory fixtures (kein Hub)';
}

36
app/lib/main.dart Normal file
View file

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'data/repository.dart';
import 'pages/landing_page.dart';
import 'theme/lawheatmap_theme.dart';
void main() {
runApp(const LawHeatmapApp());
}
class LawHeatmapApp extends StatelessWidget {
const LawHeatmapApp({super.key});
@override
Widget build(BuildContext context) {
// Phase 0: in-memory fixtures only. Swap to a HubRepository
// (gRPC against `chain serve`) without changing the UI.
const EvaluationRepository repository = MockRepository();
return MaterialApp(
title: 'F∆I Law-Heatmap',
debugShowCheckedModeBanner: false,
theme: LawHeatmapTheme.light(),
darkTheme: LawHeatmapTheme.dark(),
themeMode: ThemeMode.dark,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de'), Locale('en')],
home: const LandingPage(repository: repository),
);
}
}

View file

@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import '../theme/lawheatmap_tokens.dart';
import '../widgets/lawheatmap_card.dart';
class AboutPage extends StatelessWidget {
const AboutPage({super.key});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return SingleChildScrollView(
padding: const EdgeInsets.all(LawHeatmapSpace.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Methodik & Quellen', style: t.displaySmall),
const SizedBox(height: LawHeatmapSpace.xs),
Text(
'Wie das System bewertet, woran es sich messen lässt.',
style: t.bodyLarge?.copyWith(color: LawHeatmapColors.mute),
),
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
accent: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Drei nicht verhandelbare Sätze',
style: t.headlineSmall),
const SizedBox(height: LawHeatmapSpace.md),
Text(
'1. Wir bewerten Pflichten, nicht Gesetze.\n'
'2. Jede Zahl trägt die Tier-Stufe ihres schwächsten '
'Bestandteils. Kein figürlich-runder Wert ohne Quelle.\n'
'3. Kein Output verlässt das System ohne Juristen-Gate '
'(`system.approval@^0`).',
style: t.bodyLarge?.copyWith(height: 1.7),
),
],
),
),
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Methodische Vorbilder', style: t.headlineSmall),
const SizedBox(height: LawHeatmapSpace.sm),
_bullet(t,
'Standardkostenmodell (SKM) — Nationaler Normenkontrollrat. P × F × T × h.'),
_bullet(t,
'Coupette / Katz / Bommarito 2021 — Netzwerkanalyse deutsches Bundesrecht (Graph-Schema).'),
_bullet(t,
'Mercatus QuantGov / RegData — NLP-basierte Pflicht-Klassifikation (must/shall/may).'),
_bullet(t,
'EU Better Regulation Toolbox + REFIT — Cost-Benefit + Evaluations-Methodik.'),
_bullet(t,
'Akoma Ntoso 1.0 (OASIS) + LegalRuleML — XML-Format und Deontik-Datenmodell.'),
_bullet(t,
'UK Better Regulation Framework — Regulatory Policy Committee als unabhängige Letzt-Validierung.'),
],
),
),
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('4-Tier-Evidenz-Modell', style: t.headlineSmall),
const SizedBox(height: LawHeatmapSpace.sm),
_bullet(t,
'T1 — amtlich/peer-reviewed: NKR, DESTATIS, ifo, ZEW, BMWK, SVR, IfM Bonn.'),
_bullet(t,
'T2 — Verbands-Erhebung: DIHK, IHK Berlin, ZDH, BDI Mittelstand, BVMW, HDE.'),
_bullet(t,
'T3 — eigene strukturierte Erhebung (Phase 2/3, DSGVO-anonymisiert).'),
_bullet(t,
'T4 — qualitatives Signal: Petitionen, openPetition, mein.berlin.de, Foren — nur Pain-Indikator.'),
],
),
),
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Plattform', style: t.headlineSmall),
const SizedBox(height: LawHeatmapSpace.sm),
Text(
'Ch∆In v0.16.0 (vormals F∆I-Plattform) — Vendor: '
'F∆I = Flemming.AI. Hub. Module. Flow. Deterministisch, '
'WASM-sandboxed, tamper-evident Audit-Log '
'(CHAIN-EVENT-V3), Apache 2.0, CRA-ready.',
style: t.bodyLarge?.copyWith(height: 1.6),
),
],
),
),
],
),
);
}
Widget _bullet(TextTheme t, String text) {
return Padding(
padding: const EdgeInsets.only(bottom: LawHeatmapSpace.sm),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 6, right: 10),
child: Container(
width: 5,
height: 5,
decoration: const BoxDecoration(
color: LawHeatmapColors.signal,
shape: BoxShape.circle,
),
),
),
Expanded(
child: Text(text, style: t.bodyLarge?.copyWith(height: 1.6)),
),
],
),
);
}
}

View file

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../data/repository.dart';
import '../theme/lawheatmap_tokens.dart';
import '../widgets/demo_banner.dart';
import '../widgets/heatmap_grid.dart';
import '../widgets/lawheatmap_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(LawHeatmapSpace.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: LawHeatmapSpace.xs),
Text(
'X: jährliche Bürokratiekosten (€, log10). '
'Y: Nutzen-Score 05. Punktgröße: Betroffenheit. '
'Klick auf einen Punkt → Detail mit Pflichten + Quellen.',
style: t.bodyLarge?.copyWith(
color: LawHeatmapColors.mute,
),
),
const SizedBox(height: LawHeatmapSpace.lg),
Expanded(
child: LawHeatmapCard(
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: LawHeatmapSpace.lg),
Wrap(
spacing: LawHeatmapSpace.md,
runSpacing: LawHeatmapSpace.sm,
children: [
for (final e in items)
InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NormDetailPage(
repository: widget.repository,
evaluation: e,
),
),
);
},
child: LawHeatmapCard(
padding: const EdgeInsets.symmetric(
horizontal: LawHeatmapSpace.md,
vertical: LawHeatmapSpace.sm,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TierBadge(
tier: e.tierLowest, compact: true),
const SizedBox(
width: LawHeatmapSpace.sm),
Text(
'${e.norm.jurabk} ${e.norm.paragraph}',
style: t.labelLarge,
),
],
),
),
),
],
),
],
);
},
),
),
),
],
);
}
}

View file

@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import '../data/repository.dart';
import '../theme/lawheatmap_tokens.dart';
import '../widgets/lawheatmap_card.dart';
class HubStatusPage extends StatelessWidget {
const HubStatusPage({super.key, required this.repository});
final EvaluationRepository repository;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return SingleChildScrollView(
padding: const EdgeInsets.all(LawHeatmapSpace.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Ch∆In Hub', style: t.displaySmall),
const SizedBox(height: LawHeatmapSpace.xs),
Text(
'Status der Verbindung zum lokalen Hub und des Audit-Logs.',
style: t.bodyLarge?.copyWith(color: LawHeatmapColors.mute),
),
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
accent: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_row('Modus', repository.mode.toUpperCase(), t),
_row('Quelle', repository.hubLabel, t),
_row('gRPC-Endpunkt', '— (Phase-0-Stub)', t),
_row('Audit-DB', '~/.chain/chain.db (geplant)', t),
_row('Hash-Tag', 'CHAIN-EVENT-V3', t),
],
),
),
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Geplante Live-Anbindung', style: t.headlineSmall),
const SizedBox(height: LawHeatmapSpace.sm),
Text(
'1. `chain serve` startet lokal auf Port 50051.\n'
'2. Diese App liest `~/.chain/hub-auth-token` und '
'verbindet via gRPC.\n'
'3. Capability `text.akoma_normalize@^0` muss '
'installiert sein (`chain install text.akoma_normalize`).\n'
'4. Pilot-Flow `flows/durchstich-gewo-14.yaml` läuft '
'gegen GewO §14.\n'
'5. Jedes step.completed-Event landet im Audit-Log; '
'die Repository-Implementierung tauscht von MockRepository '
'auf HubRepository (gleiches Interface, kein UI-Wechsel).',
style: t.bodyLarge?.copyWith(
height: 1.6,
),
),
],
),
),
],
),
);
}
Widget _row(String label, String value, TextTheme t) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 160,
child: Text(label,
style: t.labelLarge
?.copyWith(color: LawHeatmapColors.mute)),
),
Expanded(
child: Text(value,
style: t.bodyLarge
?.copyWith(fontFamily: LawHeatmapTypography.mono)),
),
],
),
);
}
}

View file

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../data/repository.dart';
import '../theme/lawheatmap_theme.dart';
import '../theme/lawheatmap_tokens.dart';
import '../widgets/delta_mark.dart';
import 'shell_page.dart';
/// First-run landing: brand mark, one-line claim, "enter app" CTA.
/// Mock-mode bypasses Hub connect; the live wire lands in week 1.
class LandingPage extends StatelessWidget {
const LandingPage({super.key, required this.repository});
final EvaluationRepository repository;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Scaffold(
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: LawHeatmapSpace.xxl,
vertical: LawHeatmapSpace.xxxl,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const DeltaMark(size: 96),
const SizedBox(height: LawHeatmapSpace.xxl),
Text('F∆I Law-Heatmap', style: t.displaySmall),
const SizedBox(height: LawHeatmapSpace.sm),
Text(
'Pflicht-basierte Wirkungsanalyse von Rechtsnormen — '
'auf Ch∆In, mit Quellen-Audit by construction.',
style: t.bodyLarge
?.copyWith(color: LawHeatmapColors.mute),
),
const SizedBox(height: LawHeatmapSpace.xxl),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: LawHeatmapSpace.md,
runSpacing: LawHeatmapSpace.sm,
children: [
FilledButton.icon(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) =>
ShellPage(repository: repository),
),
);
},
icon: const Icon(Icons.east),
label: const Text('App öffnen (Demo-Modus)'),
),
Text(
'Demo: 5 Pilot-Normen, ohne Hub-Verbindung',
style: t.labelSmall?.copyWith(
color: LawHeatmapColors.mute,
),
),
],
),
const SizedBox(height: LawHeatmapSpace.xl),
Text(
'v0.1.0 · Phase 0 · Mock-Modus · '
'Live-Hub-Anbindung folgt in Woche 1',
style: LawHeatmapTheme.mono(
t.labelSmall ?? const TextStyle(),
).copyWith(color: LawHeatmapColors.mute),
),
],
),
),
),
),
),
);
}
}

View file

@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../data/repository.dart';
import '../theme/lawheatmap_tokens.dart';
import '../widgets/demo_banner.dart';
import '../widgets/evidence_sidebar.dart';
import '../widgets/lawheatmap_card.dart';
import '../widgets/tier_badge.dart';
class NormDetailPage extends StatelessWidget {
const NormDetailPage({
super.key,
required this.repository,
required this.evaluation,
});
final EvaluationRepository repository;
final Evaluation evaluation;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final e = evaluation;
return Scaffold(
appBar: AppBar(
title: Text('${e.norm.jurabk} ${e.norm.paragraph}'),
),
body: Column(
children: [
const DemoBanner(),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(LawHeatmapSpace.xl),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(e.norm.title,
style: t.displaySmall),
const SizedBox(height: 4),
SelectableText(
e.norm.eli,
style: t.bodyLarge?.copyWith(
fontFamily:
LawHeatmapTypography.mono,
color: LawHeatmapColors.mute,
),
),
],
),
),
TierBadge(tier: e.tierLowest),
],
),
const SizedBox(height: LawHeatmapSpace.lg),
_MetricsRow(e: e),
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Pflichten',
style: t.headlineSmall),
const SizedBox(
height: LawHeatmapSpace.sm),
Text(
'Atomare deontische Aussagen, extrahiert '
'aus dem Norm-Text (Phase 1: regex + LLM-'
'Vorschlag, Juristen-Review obligatorisch).',
style: t.labelSmall?.copyWith(
color: LawHeatmapColors.mute),
),
const SizedBox(
height: LawHeatmapSpace.md),
for (final d in e.duties)
_DutyTile(duty: d),
],
),
),
if (e.notes != null) ...[
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text('Methodik-Notiz',
style: t.headlineSmall),
const SizedBox(
height: LawHeatmapSpace.sm),
Text(e.notes!, style: t.bodyLarge),
],
),
),
],
],
),
),
const SizedBox(width: LawHeatmapSpace.xl),
SizedBox(
width: 340,
child: EvidenceSidebar(evaluation: e),
),
],
),
),
),
],
),
);
}
}
class _MetricsRow extends StatelessWidget {
const _MetricsRow({required this.e});
final Evaluation e;
@override
Widget build(BuildContext context) {
return Row(
children: [
_MetricBox(
label: 'Schaden',
value: _eur(e.skmEurPerYear),
sub: 'SKM P×F×T×h',
),
const SizedBox(width: LawHeatmapSpace.md),
_MetricBox(
label: 'Nutzen',
value: '${e.benefitScore.toStringAsFixed(1)} / 5',
sub: '7-dim. Score',
),
const SizedBox(width: LawHeatmapSpace.md),
_MetricBox(
label: 'Betroffene',
value: _kmu(e.affectedCount),
sub: 'DESTATIS',
),
const SizedBox(width: LawHeatmapSpace.md),
_MetricBox(
label: 'Frust',
value: '${e.frustScore.toStringAsFixed(1)} / 10',
sub: 'Lesbarkeit + Tiefe',
),
],
);
}
static String _eur(double v) {
if (v >= 1e9) return '${(v / 1e9).toStringAsFixed(2)} Mrd €';
if (v >= 1e6) return '${(v / 1e6).toStringAsFixed(1)} Mio €';
if (v >= 1e3) return '${(v / 1e3).toStringAsFixed(0)} k €';
return '${v.toStringAsFixed(0)}';
}
static String _kmu(int n) {
if (n >= 1_000_000) return '${(n / 1e6).toStringAsFixed(1)} Mio';
if (n >= 1_000) return '${(n / 1e3).toStringAsFixed(0)} k';
return n.toString();
}
}
class _MetricBox extends StatelessWidget {
const _MetricBox({
required this.label,
required this.value,
required this.sub,
});
final String label;
final String value;
final String sub;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Expanded(
child: LawHeatmapCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: t.labelSmall
?.copyWith(color: LawHeatmapColors.mute)),
const SizedBox(height: 4),
Text(value, style: t.displaySmall),
const SizedBox(height: 4),
Text(sub,
style: t.labelSmall
?.copyWith(color: LawHeatmapColors.mute)),
],
),
),
);
}
}
class _DutyTile extends StatelessWidget {
const _DutyTile({required this.duty});
final Duty duty;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(bottom: LawHeatmapSpace.md),
child: Container(
padding: const EdgeInsets.all(LawHeatmapSpace.md),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.12)),
borderRadius: const BorderRadius.all(LawHeatmapRadius.sm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: LawHeatmapColors.signal.withValues(alpha: 0.18),
borderRadius:
const BorderRadius.all(LawHeatmapRadius.sm),
),
child: Text(
duty.modality,
style: t.labelSmall?.copyWith(
color: LawHeatmapColors.signal,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: LawHeatmapSpace.sm),
Text(duty.sourceNorm,
style: t.labelSmall?.copyWith(
fontFamily: LawHeatmapTypography.mono,
color: LawHeatmapColors.mute,
)),
]),
const SizedBox(height: LawHeatmapSpace.sm),
Text(duty.action, style: t.bodyLarge),
const SizedBox(height: 4),
Text(
'Adressat: ${duty.addressee} · '
'Frequenz: ${duty.frequency}'
'${duty.authority != null ? " · Behörde: ${duty.authority}" : ""}'
'${duty.consequence != null ? " · Sanktion: ${duty.consequence}" : ""}',
style: t.labelSmall?.copyWith(color: LawHeatmapColors.mute),
),
],
),
),
);
}
}

View file

@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../data/repository.dart';
import '../theme/lawheatmap_tokens.dart';
import '../widgets/demo_banner.dart';
import '../widgets/lawheatmap_card.dart';
import '../widgets/tier_badge.dart';
import 'norm_detail_page.dart';
class NormsListPage extends StatelessWidget {
const NormsListPage({super.key, required this.repository});
final EvaluationRepository repository;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Column(
children: [
const DemoBanner(),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(LawHeatmapSpace.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Normen-Pilotkorpus', style: t.displaySmall),
const SizedBox(height: LawHeatmapSpace.xs),
Text(
'5 Normen für den vertikalen Durchstich. '
'Korpus erweiterbar in Phase 1.',
style: t.bodyLarge?.copyWith(
color: LawHeatmapColors.mute,
),
),
const SizedBox(height: LawHeatmapSpace.lg),
FutureBuilder<List<Evaluation>>(
future: repository.list(),
builder: (context, snap) {
if (!snap.hasData) {
return const Center(
child: CircularProgressIndicator());
}
return Column(
children: [
for (final e in snap.data!)
Padding(
padding: const EdgeInsets.only(
bottom: LawHeatmapSpace.md),
child: _NormRow(
evaluation: e,
repository: repository,
),
),
],
);
},
),
],
),
),
),
],
);
}
}
class _NormRow extends StatelessWidget {
const _NormRow({required this.evaluation, required this.repository});
final Evaluation evaluation;
final EvaluationRepository repository;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NormDetailPage(
repository: repository,
evaluation: evaluation,
),
),
);
},
child: LawHeatmapCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${evaluation.norm.jurabk} ${evaluation.norm.paragraph}${evaluation.norm.title}',
style: t.titleMedium,
),
const SizedBox(height: 4),
Text(
evaluation.norm.eli,
style: t.labelSmall?.copyWith(
fontFamily: LawHeatmapTypography.mono,
color: LawHeatmapColors.mute,
),
),
],
),
),
const SizedBox(width: LawHeatmapSpace.lg),
Expanded(
child: _MetricColumn(
label: 'Schaden / Jahr',
value: _eur(evaluation.skmEurPerYear),
),
),
Expanded(
child: _MetricColumn(
label: 'Nutzen',
value:
'${evaluation.benefitScore.toStringAsFixed(1)} / 5',
),
),
Expanded(
child: _MetricColumn(
label: 'Betroffene',
value: _kmu(evaluation.affectedCount),
),
),
const SizedBox(width: LawHeatmapSpace.md),
TierBadge(tier: evaluation.tierLowest),
],
),
),
);
}
String _eur(double v) {
if (v >= 1e9) return '${(v / 1e9).toStringAsFixed(2)} Mrd €';
if (v >= 1e6) return '${(v / 1e6).toStringAsFixed(1)} Mio €';
if (v >= 1e3) return '${(v / 1e3).toStringAsFixed(0)} k €';
return '${v.toStringAsFixed(0)}';
}
String _kmu(int n) {
if (n >= 1_000_000) return '${(n / 1e6).toStringAsFixed(1)} Mio';
if (n >= 1_000) return '${(n / 1e3).toStringAsFixed(0)} k';
return n.toString();
}
}
class _MetricColumn extends StatelessWidget {
const _MetricColumn({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: t.labelSmall?.copyWith(color: LawHeatmapColors.mute),
),
const SizedBox(height: 2),
Text(value, style: t.titleMedium),
],
);
}
}

View file

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import '../data/repository.dart';
import '../theme/lawheatmap_tokens.dart';
import '../widgets/delta_mark.dart';
import 'about_page.dart';
import 'heatmap_page.dart';
import 'hub_status_page.dart';
import 'norms_list_page.dart';
/// App shell with a NavigationRail. Each rail destination is one
/// page; pages do their own scrolling.
class ShellPage extends StatefulWidget {
const ShellPage({super.key, required this.repository});
final EvaluationRepository repository;
@override
State<ShellPage> createState() => _ShellPageState();
}
class _ShellPageState extends State<ShellPage> {
int _index = 0;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final pages = <Widget>[
HeatmapPage(repository: widget.repository),
NormsListPage(repository: widget.repository),
HubStatusPage(repository: widget.repository),
const AboutPage(),
];
return Scaffold(
body: Row(
children: [
NavigationRail(
backgroundColor: Theme.of(context).colorScheme.surface,
extended: MediaQuery.of(context).size.width > 1100,
selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i),
leading: Padding(
padding: const EdgeInsets.symmetric(
vertical: LawHeatmapSpace.lg,
horizontal: LawHeatmapSpace.md,
),
child: Column(
children: [
const DeltaMark(size: 36),
const SizedBox(height: LawHeatmapSpace.sm),
Text(
'F∆I',
style: t.labelLarge?.copyWith(
letterSpacing: 2,
color: LawHeatmapColors.signal,
),
),
],
),
),
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.grid_view_outlined),
selectedIcon: Icon(Icons.grid_view),
label: Text('Heatmap'),
),
NavigationRailDestination(
icon: Icon(Icons.list_alt_outlined),
selectedIcon: Icon(Icons.list_alt),
label: Text('Normen'),
),
NavigationRailDestination(
icon: Icon(Icons.hub_outlined),
selectedIcon: Icon(Icons.hub),
label: Text('Hub'),
),
NavigationRailDestination(
icon: Icon(Icons.info_outline),
selectedIcon: Icon(Icons.info),
label: Text('Methodik'),
),
],
),
const VerticalDivider(width: 1),
Expanded(child: pages[_index]),
],
),
);
}
}

View file

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'lawheatmap_tokens.dart';
/// ThemeData assembly for the FI Law-Heatmap client.
///
/// Dark mode is the default (consistent with fai_web's
/// `color-scheme: dark`). Light mode falls back to the paper
/// palette for printing/screenshot work.
class LawHeatmapTheme {
LawHeatmapTheme._();
static ThemeData dark() => _build(Brightness.dark);
static ThemeData light() => _build(Brightness.light);
static ThemeData _build(Brightness brightness) {
final isDark = brightness == Brightness.dark;
final canvas = isDark ? LawHeatmapColors.ink : LawHeatmapColors.paper;
final surface =
isDark ? LawHeatmapColors.inkRaised : LawHeatmapColors.paperRaised;
final onSurface = isDark
? const Color(0xFFE9E7E2)
: const Color(0xFF1A1B1E);
final colorScheme = ColorScheme(
brightness: brightness,
primary: LawHeatmapColors.signal,
onPrimary: LawHeatmapColors.ink,
secondary: LawHeatmapColors.mute,
onSecondary: onSurface,
error: const Color(0xFFD8723A),
onError: LawHeatmapColors.ink,
surface: surface,
onSurface: onSurface,
);
final textTheme = _textTheme(onSurface);
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: colorScheme,
scaffoldBackgroundColor: canvas,
canvasColor: canvas,
fontFamily: LawHeatmapTypography.body,
textTheme: textTheme,
cardTheme: CardThemeData(
color: surface,
elevation: 0,
margin: EdgeInsets.zero,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(LawHeatmapRadius.md),
side: BorderSide(color: Color(0x24FFFFFF)),
),
),
dividerTheme: DividerThemeData(
color: onSurface.withValues(alpha: LawHeatmapColors.hairlineDarkOpacity),
thickness: 1,
space: 1,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: LawHeatmapColors.signal,
foregroundColor: LawHeatmapColors.ink,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(LawHeatmapRadius.sm),
),
textStyle: textTheme.labelLarge,
padding: const EdgeInsets.symmetric(
horizontal: LawHeatmapSpace.lg,
vertical: LawHeatmapSpace.md,
),
),
),
);
}
static TextTheme _textTheme(Color onSurface) {
const display = LawHeatmapTypography.display;
const body = LawHeatmapTypography.body;
return TextTheme(
displaySmall: TextStyle(
fontFamily: display,
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.02 * 28,
color: onSurface,
),
headlineSmall: TextStyle(
fontFamily: display,
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.02 * 20,
color: onSurface,
),
titleMedium: TextStyle(
fontFamily: body,
fontSize: 16,
fontWeight: FontWeight.w500,
color: onSurface,
),
bodyLarge: TextStyle(
fontFamily: body,
fontSize: 14,
height: 1.45,
color: onSurface,
),
labelLarge: TextStyle(
fontFamily: body,
fontSize: 13,
fontWeight: FontWeight.w500,
color: onSurface,
),
labelSmall: TextStyle(
fontFamily: body,
fontSize: 11,
fontWeight: FontWeight.w500,
color: onSurface.withValues(alpha: 0.72),
),
);
}
/// Convenience: JetBrains-Mono variant of an existing style for
/// technical content (ELI/CELEX, SHA-256, audit-event IDs).
static TextStyle mono(TextStyle base) =>
base.copyWith(fontFamily: LawHeatmapTypography.mono);
}

View file

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
/// Visual tokens for the FI Law-Heatmap client, sourced from the
/// fai_web brand palette (see fai_web/src/styles/global.css:14-20).
///
/// Unlike `fai_chain_studio`'s `ChainColors` (Tailwind sky-400 —
/// dev-tool look), this palette matches the public-facing brand:
/// ink/paper with a petrol "signal" accent that doubles as the eye
/// of the `` mark.
class LawHeatmapColors {
LawHeatmapColors._();
// Core palette (fai_web/src/styles/global.css)
static const ink = Color(0xFF08090A);
static const inkRaised = Color(0xFF111315);
static const paper = Color(0xFFF4F3EF);
static const paperRaised = Color(0xFFE7E5DF);
static const signal = Color(0xFF2E8F9E);
static const mute = Color(0xFF6B7177);
// Hairline divider currentColor at 14% opacity (fai_web).
static const hairlineDarkOpacity = 0.14;
static const hairlineLightOpacity = 0.14;
// Heatmap scale derived from `signal` by HSL shift:
// x-axis (Schaden): cool warm as score rises
// y-axis (Nutzen): muted saturated as score rises
// size: cohort count, linear sqrt mapping
static const harmCool = Color(0xFF2E6E8F);
static const harmWarm = Color(0xFFD8723A);
static const benefitMuted = Color(0xFF5A7E83);
static const benefitSaturated = Color(0xFF2E8F9E);
// Tier badges evidence tier of the lowest-tier ingredient.
static const tierT1 = Color(0xFF2E8F9E); // official / peer-reviewed
static const tierT2 = Color(0xFF6CA29A); // verband
static const tierT3 = Color(0xFFB0AC8E); // own survey
static const tierT4 = Color(0xFFA86F5C); // qualitative signal only
}
/// Spacing scale 4/8/12/16/24/32/48 multiples, matching
/// `ChainSpace` from `fai_chain_studio/lib/theme/tokens.dart`.
class LawHeatmapSpace {
LawHeatmapSpace._();
static const xs = 4.0;
static const sm = 8.0;
static const md = 12.0;
static const lg = 16.0;
static const xl = 24.0;
static const xxl = 32.0;
static const xxxl = 48.0;
}
class LawHeatmapRadius {
LawHeatmapRadius._();
static const sm = Radius.circular(6);
static const md = Radius.circular(10);
}
class LawHeatmapTypography {
LawHeatmapTypography._();
static const display = 'SpaceGroteskVariable';
static const body = 'InterVariable';
static const mono = 'JetBrainsMono';
}

View file

@ -0,0 +1,98 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/lawheatmap_tokens.dart';
/// The FI / ChIn brand mark: an upward triangle (the ``) with
/// a petrol "eye" at the centre. Geometry mirrors fai_web's
/// Logo.astro the triangle IS the "A"; the eye-dot IS the dot
/// of the "i".
///
/// Subtle 9s breathing pulse on the eye (matches fai_web's
/// `.eye-pulse`). Set [animated] to false for static contexts
/// (icons, screenshots).
class DeltaMark extends StatefulWidget {
const DeltaMark({super.key, this.size = 64, this.animated = true});
final double size;
final bool animated;
@override
State<DeltaMark> createState() => _DeltaMarkState();
}
class _DeltaMarkState extends State<DeltaMark>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 9),
);
if (widget.animated) {
_controller.repeat();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final t = _controller.value;
// Breathe: scale 1 1.28 1 over the cycle.
final pulse = 1.0 + 0.28 * (1 - (math.cos(t * 2 * math.pi).abs()));
return CustomPaint(
size: Size.square(widget.size),
painter: _DeltaPainter(pulse: widget.animated ? pulse : 1.0),
);
},
);
}
}
class _DeltaPainter extends CustomPainter {
_DeltaPainter({required this.pulse});
final double pulse;
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
// Upward triangle, geometric centre.
final stroke = Paint()
..color = const Color(0xFFE9E7E2)
..style = PaintingStyle.stroke
..strokeWidth = w * 0.04
..strokeJoin = StrokeJoin.miter;
final inset = w * 0.08;
final tri = Path()
..moveTo(w / 2, inset)
..lineTo(w - inset, h - inset)
..lineTo(inset, h - inset)
..close();
canvas.drawPath(tri, stroke);
// Eye petrol fill, centred on the triangle's centroid.
final eyeCentre = Offset(w / 2, inset + (h - 2 * inset) * 2 / 3);
final eyeR = w * 0.06 * pulse;
final eye = Paint()..color = LawHeatmapColors.signal;
canvas.drawCircle(eyeCentre, eyeR, eye);
}
@override
bool shouldRepaint(covariant _DeltaPainter old) => old.pulse != pulse;
}

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../theme/lawheatmap_tokens.dart';
/// Permanent banner shown above all data screens in mock mode.
/// Carries the political-defensibility caveat: this is demo data,
/// not peer-reviewed, not Juristen-approved.
class DemoBanner extends StatelessWidget {
const DemoBanner({super.key});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: LawHeatmapSpace.lg,
vertical: LawHeatmapSpace.sm,
),
decoration: BoxDecoration(
color: LawHeatmapColors.tierT4.withValues(alpha: 0.12),
border: Border(
bottom: BorderSide(
color: LawHeatmapColors.tierT4.withValues(alpha: 0.55),
),
),
),
child: Row(
children: [
const Icon(Icons.science_outlined,
size: 18, color: LawHeatmapColors.tierT4),
const SizedBox(width: LawHeatmapSpace.sm),
Expanded(
child: Text(
'Demo-Daten — Phase 0, ohne Verbands-Erhebung. '
'Keine Juristen-Approval, keine Beirats-Validierung. '
'Alle Werte tragen Tier T4, bis T1/T2-Pipeline läuft.',
style: t.labelSmall?.copyWith(
color: LawHeatmapColors.tierT4,
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../theme/lawheatmap_tokens.dart';
import 'lawheatmap_card.dart';
import 'source_chip.dart';
/// Sidebar listing the citable sources behind an evaluation.
/// Surfaces the methodological audit story: every figure has a
/// source and an evidence tier.
class EvidenceSidebar extends StatelessWidget {
const EvidenceSidebar({super.key, required this.evaluation});
final Evaluation evaluation;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return LawHeatmapCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Quellen', style: t.headlineSmall),
const SizedBox(height: LawHeatmapSpace.sm),
Text(
'Jede Zahl trägt die Tier-Stufe ihres schwächsten '
'Bestandteils (Regel der niedrigsten Stufe, §6.7).',
style: t.labelSmall?.copyWith(color: LawHeatmapColors.mute),
),
const SizedBox(height: LawHeatmapSpace.lg),
for (final s in evaluation.sources) SourceChip(source: s),
const Divider(height: LawHeatmapSpace.xl),
Text('Audit-Event', style: t.labelLarge),
const SizedBox(height: 4),
SelectableText(
evaluation.auditEventId,
style: t.labelSmall?.copyWith(
fontFamily: LawHeatmapTypography.mono,
color: LawHeatmapColors.mute,
),
),
const SizedBox(height: LawHeatmapSpace.sm),
Text(
'Quell-SHA-256:',
style: t.labelSmall?.copyWith(color: LawHeatmapColors.mute),
),
SelectableText(
evaluation.norm.sourceSha256,
style: t.labelSmall?.copyWith(
fontFamily: LawHeatmapTypography.mono,
color: LawHeatmapColors.mute,
fontSize: 10,
),
),
],
),
);
}
}

View file

@ -0,0 +1,306 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../theme/lawheatmap_tokens.dart';
/// 2D scatter heatmap x: Schaden (/Jahr, log-scaled),
/// y: Nutzen (05), point area: Betroffenheit (sqrt-scaled).
///
/// Server delivers JSON; client pixels.
class HeatmapGrid extends StatefulWidget {
const HeatmapGrid({
super.key,
required this.evaluations,
this.onTap,
});
final List<Evaluation> evaluations;
final void Function(Evaluation)? onTap;
@override
State<HeatmapGrid> createState() => _HeatmapGridState();
}
class _HeatmapGridState extends State<HeatmapGrid> {
int? _hoverIndex;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final size = Size(constraints.maxWidth, constraints.maxHeight);
return MouseRegion(
onHover: (e) => _setHover(_hitTest(e.localPosition, size)),
onExit: (_) => _setHover(null),
child: GestureDetector(
onTapDown: (e) {
final idx = _hitTest(e.localPosition, size);
if (idx != null && widget.onTap != null) {
widget.onTap!(widget.evaluations[idx]);
}
},
child: CustomPaint(
painter: _HeatmapPainter(
evaluations: widget.evaluations,
hoverIndex: _hoverIndex,
theme: Theme.of(context),
),
size: size,
),
),
);
},
);
}
void _setHover(int? idx) {
if (idx != _hoverIndex) setState(() => _hoverIndex = idx);
}
int? _hitTest(Offset pos, Size size) {
final layout = _Layout(size);
for (var i = 0; i < widget.evaluations.length; i++) {
final c = layout.project(widget.evaluations[i]);
final r = layout.radius(widget.evaluations[i]);
if ((pos - c).distance <= r + 4) return i;
}
return null;
}
}
class _Layout {
_Layout(this.size)
: padding = const EdgeInsets.fromLTRB(72, 28, 28, 48);
final Size size;
final EdgeInsets padding;
double get plotLeft => padding.left;
double get plotRight => size.width - padding.right;
double get plotTop => padding.top;
double get plotBottom => size.height - padding.bottom;
double get plotWidth => plotRight - plotLeft;
double get plotHeight => plotBottom - plotTop;
/// x: log10(/year), clipped to [3, 9].
static double xLog(double eur) {
if (eur <= 1) return 3;
final v = math.log(eur) / math.ln10;
return v.clamp(3.0, 9.0);
}
Offset project(Evaluation e) {
final x = xLog(e.skmEurPerYear);
final y = e.benefitScore.clamp(0.0, 5.0);
final dx = plotLeft + ((x - 3) / 6) * plotWidth;
final dy = plotBottom - (y / 5) * plotHeight;
return Offset(dx, dy);
}
double radius(Evaluation e) {
final n = e.affectedCount.clamp(1, 10_000_000);
final r = math.sqrt(n / 1000) * 1.6;
return r.clamp(6.0, 44.0);
}
}
class _HeatmapPainter extends CustomPainter {
_HeatmapPainter({
required this.evaluations,
required this.hoverIndex,
required this.theme,
});
final List<Evaluation> evaluations;
final int? hoverIndex;
final ThemeData theme;
@override
void paint(Canvas canvas, Size size) {
final layout = _Layout(size);
final onSurface = theme.colorScheme.onSurface;
const mute = LawHeatmapColors.mute;
// Plot frame.
final frame = Paint()
..color = onSurface.withValues(alpha: 0.14)
..style = PaintingStyle.stroke
..strokeWidth = 1;
canvas.drawRect(
Rect.fromLTRB(
layout.plotLeft,
layout.plotTop,
layout.plotRight,
layout.plotBottom,
),
frame,
);
// Grid lines (y = 1..4, x = 4..8).
final gridPaint = Paint()
..color = onSurface.withValues(alpha: 0.08)
..strokeWidth = 1;
for (var y = 1; y <= 4; y++) {
final dy = layout.plotBottom - (y / 5) * layout.plotHeight;
canvas.drawLine(
Offset(layout.plotLeft, dy),
Offset(layout.plotRight, dy),
gridPaint,
);
}
for (var x = 4; x <= 8; x++) {
final dx = layout.plotLeft + ((x - 3) / 6) * layout.plotWidth;
canvas.drawLine(
Offset(dx, layout.plotTop),
Offset(dx, layout.plotBottom),
gridPaint,
);
}
// Axis labels.
const axisStyle = TextStyle(
color: mute,
fontSize: 10,
fontFamily: LawHeatmapTypography.body,
);
_drawText(
canvas,
'Schaden €/Jahr (log)',
Offset(layout.plotLeft, layout.plotBottom + 22),
axisStyle,
);
_drawText(
canvas,
'Nutzen 05',
Offset(8, layout.plotTop - 18),
axisStyle,
);
// X tick labels for 10^4 .. 10^9
for (var x = 4; x <= 9; x++) {
final dx = layout.plotLeft + ((x - 3) / 6) * layout.plotWidth;
_drawText(
canvas,
'10^$x',
Offset(dx - 12, layout.plotBottom + 6),
axisStyle,
);
}
// Y tick labels for 0..5
for (var y = 0; y <= 5; y++) {
final dy = layout.plotBottom - (y / 5) * layout.plotHeight;
_drawText(
canvas,
'$y',
Offset(layout.plotLeft - 18, dy - 6),
axisStyle,
);
}
// Points.
for (var i = 0; i < evaluations.length; i++) {
final e = evaluations[i];
final c = layout.project(e);
final r = layout.radius(e);
final hot = e.skmEurPerYear > 50000000;
final color = Color.lerp(
LawHeatmapColors.benefitSaturated,
LawHeatmapColors.harmWarm,
hot ? 0.5 : 0.0,
)!;
final body = Paint()..color = color.withValues(alpha: 0.7);
final ring = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = i == hoverIndex ? 3 : 1.5;
canvas.drawCircle(c, r, body);
canvas.drawCircle(c, r, ring);
_drawText(
canvas,
'${e.norm.jurabk} ${e.norm.paragraph}',
Offset(c.dx + r + 6, c.dy - 6),
TextStyle(
color: onSurface,
fontSize: 11,
fontFamily: LawHeatmapTypography.body,
fontWeight: i == hoverIndex ? FontWeight.w600 : FontWeight.w400,
),
);
}
// Hover detail box.
if (hoverIndex != null) {
final e = evaluations[hoverIndex!];
final c = layout.project(e);
final r = layout.radius(e);
final box = Rect.fromLTWH(c.dx + r + 8, c.dy + 8, 240, 64);
final boxPaint = Paint()
..color = theme.colorScheme.surface.withValues(alpha: 0.92);
final boxBorder = Paint()
..style = PaintingStyle.stroke
..color = LawHeatmapColors.signal.withValues(alpha: 0.6);
final rrect = RRect.fromRectAndRadius(box, LawHeatmapRadius.sm);
canvas.drawRRect(rrect, boxPaint);
canvas.drawRRect(rrect, boxBorder);
_drawText(
canvas,
e.norm.title,
box.topLeft + const Offset(10, 8),
TextStyle(
color: onSurface,
fontSize: 12,
fontWeight: FontWeight.w600,
fontFamily: LawHeatmapTypography.body,
),
);
_drawText(
canvas,
'€/Jahr: ${_eur(e.skmEurPerYear)} Betroffene: ${_kmu(e.affectedCount)}',
box.topLeft + const Offset(10, 26),
const TextStyle(
color: mute,
fontSize: 11,
fontFamily: LawHeatmapTypography.body,
),
);
_drawText(
canvas,
'Tier: ${e.tierLowest.short} Klick → Detail',
box.topLeft + const Offset(10, 44),
const TextStyle(
color: mute,
fontSize: 11,
fontFamily: LawHeatmapTypography.body,
),
);
}
}
String _eur(double v) {
if (v >= 1e9) return '${(v / 1e9).toStringAsFixed(1)} Mrd';
if (v >= 1e6) return '${(v / 1e6).toStringAsFixed(1)} Mio';
if (v >= 1e3) return '${(v / 1e3).toStringAsFixed(0)} k';
return v.toStringAsFixed(0);
}
String _kmu(int n) {
if (n >= 1_000_000) return '${(n / 1e6).toStringAsFixed(1)} Mio';
if (n >= 1_000) return '${(n / 1e3).toStringAsFixed(0)} k';
return n.toString();
}
void _drawText(Canvas canvas, String text, Offset at, TextStyle style) {
final tp = TextPainter(
text: TextSpan(text: text, style: style),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, at);
}
@override
bool shouldRepaint(covariant _HeatmapPainter old) =>
old.hoverIndex != hoverIndex ||
old.evaluations.length != evaluations.length;
}

View file

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import '../theme/lawheatmap_tokens.dart';
/// Surface container visual equivalent of fai_chain_studio's
/// `ChainCard`, retuned to the fai_web palette.
class LawHeatmapCard extends StatelessWidget {
const LawHeatmapCard({
super.key,
required this.child,
this.padding =
const EdgeInsets.all(LawHeatmapSpace.lg),
this.accent = false,
this.expand = false,
});
final Widget child;
final EdgeInsetsGeometry padding;
/// If true, draws a thin petrol accent bar at the top
/// used for "this card carries a primary action".
final bool accent;
/// If true, the card grows to fill its parent's bounded
/// dimensions and forces the child to expand. Use when the
/// card is placed inside an `Expanded` widget and the child
/// is itself a free-sizing widget (e.g. CustomPaint).
final bool expand;
@override
Widget build(BuildContext context) {
final padded = Padding(padding: padding, child: child);
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
children: [
if (accent)
Container(
height: 2,
decoration: const BoxDecoration(
color: LawHeatmapColors.signal,
borderRadius: BorderRadius.vertical(
top: LawHeatmapRadius.md,
),
),
),
if (expand) Expanded(child: padded) else padded,
],
),
);
}
}

View file

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../theme/lawheatmap_tokens.dart';
import 'tier_badge.dart';
/// A citable source line: tier badge + label + (planned) link.
/// External link opening lands with `url_launcher` in week 1 for
/// now the chip surfaces the URL via tooltip + copy hint.
class SourceChip extends StatelessWidget {
const SourceChip({super.key, required this.source});
final Source source;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(bottom: LawHeatmapSpace.sm),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: TierBadge(tier: source.tier, compact: true),
),
const SizedBox(width: LawHeatmapSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(source.label, style: t.bodyLarge),
Text(
source.url,
style: t.labelSmall?.copyWith(
color: LawHeatmapColors.mute,
),
),
if (source.note != null)
Text(
source.note!,
style: t.labelSmall?.copyWith(
color: LawHeatmapColors.mute,
fontStyle: FontStyle.italic,
),
),
],
),
),
],
),
);
}
}

View file

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../theme/lawheatmap_tokens.dart';
/// Tier badge surfaces the lowest evidence tier in a composed
/// value. Every figure in the app carries one of these next to it.
class TierBadge extends StatelessWidget {
const TierBadge({super.key, required this.tier, this.compact = false});
final EvidenceTier tier;
final bool compact;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final color = switch (tier) {
EvidenceTier.t1 => LawHeatmapColors.tierT1,
EvidenceTier.t2 => LawHeatmapColors.tierT2,
EvidenceTier.t3 => LawHeatmapColors.tierT3,
EvidenceTier.t4 => LawHeatmapColors.tierT4,
};
return Tooltip(
message: '${tier.short}${tier.description}',
child: Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : LawHeatmapSpace.sm,
vertical: compact ? 2 : 4,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.18),
border: Border.all(color: color.withValues(alpha: 0.55)),
borderRadius: const BorderRadius.all(LawHeatmapRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
tier.short,
style: (compact ? t.labelSmall : t.labelLarge)?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}