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:
commit
8ee6a294a1
64 changed files with 5641 additions and 0 deletions
274
app/lib/data/fixtures.dart
Normal file
274
app/lib/data/fixtures.dart
Normal 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 § 1–4',
|
||||
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 § 60–69',
|
||||
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
144
app/lib/data/models.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// Domain model for the F∆I 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;
|
||||
|
||||
/// 0–5, 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;
|
||||
|
||||
/// 0–10, 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 Ch∆In audit log (SQLite event_log row).
|
||||
final String auditEventId;
|
||||
|
||||
final String? notes;
|
||||
}
|
||||
38
app/lib/data/repository.dart
Normal file
38
app/lib/data/repository.dart
Normal 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
36
app/lib/main.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
129
app/lib/pages/about_page.dart
Normal file
129
app/lib/pages/about_page.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
128
app/lib/pages/heatmap_page.dart
Normal file
128
app/lib/pages/heatmap_page.dart
Normal 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 0–5. 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
90
app/lib/pages/hub_status_page.dart
Normal file
90
app/lib/pages/hub_status_page.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
app/lib/pages/landing_page.dart
Normal file
85
app/lib/pages/landing_page.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
271
app/lib/pages/norm_detail_page.dart
Normal file
271
app/lib/pages/norm_detail_page.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
176
app/lib/pages/norms_list_page.dart
Normal file
176
app/lib/pages/norms_list_page.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
91
app/lib/pages/shell_page.dart
Normal file
91
app/lib/pages/shell_page.dart
Normal 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]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
127
app/lib/theme/lawheatmap_theme.dart
Normal file
127
app/lib/theme/lawheatmap_theme.dart
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'lawheatmap_tokens.dart';
|
||||
|
||||
/// ThemeData assembly for the F∆I 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);
|
||||
}
|
||||
68
app/lib/theme/lawheatmap_tokens.dart
Normal file
68
app/lib/theme/lawheatmap_tokens.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Visual tokens for the F∆I 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';
|
||||
}
|
||||
98
app/lib/widgets/delta_mark.dart
Normal file
98
app/lib/widgets/delta_mark.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/lawheatmap_tokens.dart';
|
||||
|
||||
/// The F∆I / Ch∆In 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;
|
||||
}
|
||||
47
app/lib/widgets/demo_banner.dart
Normal file
47
app/lib/widgets/demo_banner.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
app/lib/widgets/evidence_sidebar.dart
Normal file
59
app/lib/widgets/evidence_sidebar.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
306
app/lib/widgets/heatmap_grid.dart
Normal file
306
app/lib/widgets/heatmap_grid.dart
Normal 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 (0–5), 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 0–5',
|
||||
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;
|
||||
}
|
||||
53
app/lib/widgets/lawheatmap_card.dart
Normal file
53
app/lib/widgets/lawheatmap_card.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
app/lib/widgets/source_chip.dart
Normal file
54
app/lib/widgets/source_chip.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
app/lib/widgets/tier_badge.dart
Normal file
59
app/lib/widgets/tier_badge.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue