From aabff2b1408ea519d362a8bce8457b8014c82a57 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 19 Jun 2026 02:25:39 +0200 Subject: [PATCH] feat(ui): scope, freshness and jurist-review badges + live empty-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three dark-first chip badges in the TierBadge pattern: - Jurisdiction (Berlin/DE/EU/International) — derived from the ELI, surfaced on the norms list, detail header and heatmap chips/hover. - Freshness (aktuell/geaendert/ungeprueft) — flags norms amended upstream (standDate + sourceSha256); renders only when stale. - Jurist review — marks evaluations a trusted jurist has confirmed (optional; the figure stands on its own). Models gain Jurisdiction/Freshness enums, Norm.freshness/supersededNote and Evaluation.reviewedBy/reviewedAt; HubRepository parses them from the flow bag (ELI fallback). HeatmapPage shows a real empty-state (hub-down vs no-evaluations) instead of a blank plot, and connect() no longer throws on a failed probe. Signed-off-by: flemming-it --- app/lib/data/fixtures.dart | 15 +++ app/lib/data/hub_repository.dart | 45 ++++++- app/lib/data/models.dart | 161 ++++++++++++++++++++++++ app/lib/pages/heatmap_page.dart | 126 ++++++++++++++++--- app/lib/pages/norm_detail_page.dart | 25 +++- app/lib/pages/norms_list_page.dart | 18 ++- app/lib/theme/reclaim_tokens.dart | 17 +++ app/lib/widgets/freshness_badge.dart | 74 +++++++++++ app/lib/widgets/heatmap_grid.dart | 3 +- app/lib/widgets/jurisdiction_badge.dart | 69 ++++++++++ app/lib/widgets/review_badge.dart | 77 ++++++++++++ 11 files changed, 604 insertions(+), 26 deletions(-) create mode 100644 app/lib/widgets/freshness_badge.dart create mode 100644 app/lib/widgets/jurisdiction_badge.dart create mode 100644 app/lib/widgets/review_badge.dart diff --git a/app/lib/data/fixtures.dart b/app/lib/data/fixtures.dart index abf5685..1fc2af0 100644 --- a/app/lib/data/fixtures.dart +++ b/app/lib/data/fixtures.dart @@ -26,6 +26,7 @@ class Fixtures { title: 'Anzeigepflicht', jurabk: 'GewO', paragraph: '§ 14', + jurisdiction: Jurisdiction.deutschland, standDate: DateTime.utc(2025, 12, 1), sourceUrl: 'https://www.gesetze-im-internet.de/gewo/__14.html', sourceSha256: 'demo-${'a' * 60}', @@ -104,6 +105,7 @@ class Fixtures { title: 'Kassensicherungsverordnung', jurabk: 'KassenSichV', paragraph: 'gesamt', + jurisdiction: Jurisdiction.deutschland, standDate: DateTime.utc(2025, 11, 15), sourceUrl: 'https://www.gesetze-im-internet.de/kassensichv/index.html', @@ -177,6 +179,8 @@ class Fixtures { ), ], auditEventId: 'demo-evt-kassensichv', + reviewedBy: 'RA Dr. M. Kessler', + reviewedAt: DateTime.utc(2026, 1, 20), notes: 'Komponenten-Tier: Initial-Erfüllungsaufwand wurde 2016 im NKR-Stellungnahme-' 'Prozess zum Kassengesetz dokumentiert (T1 für Größenordnung). ' @@ -197,6 +201,7 @@ class Fixtures { title: 'Verzeichnis von Verarbeitungstätigkeiten', jurabk: 'DSGVO', paragraph: 'Art. 30', + jurisdiction: Jurisdiction.eu, standDate: DateTime.utc(2025, 10, 1), sourceUrl: 'https://eur-lex.europa.eu/eli/reg/2016/679', sourceSha256: 'demo-${'c' * 60}', @@ -277,6 +282,8 @@ class Fixtures { ), ], auditEventId: 'demo-evt-dsgvo-30', + reviewedBy: 'Prof. Dr. A. Lindner', + reviewedAt: DateTime.utc(2026, 2, 3), notes: 'EU-determiniert: Reform-Vorschlag muss Brüssel adressieren, nicht ' 'DE-Streichung. Komponenten-Tier: P (Fallzahl Verantwortliche) ' @@ -299,6 +306,7 @@ class Fixtures { title: 'Bauantrag', jurabk: 'BauO Bln', paragraph: '§ 60 ff', + jurisdiction: Jurisdiction.berlin, standDate: DateTime.utc(2025, 9, 1), sourceUrl: 'https://gesetze.berlin.de/bsbe/document/jlr-BauOBE2005V25P60', @@ -363,6 +371,8 @@ class Fixtures { ), ], auditEventId: 'demo-evt-berlinbauo-60', + reviewedBy: 'RAin S. Brandt', + reviewedAt: DateTime.utc(2026, 1, 28), notes: 'Berlin-spezifisch, daher kein NKR-Erfüllungsaufwand auf Bundesebene. ' 'P aus Berliner Bauaufsichts-Statistik (T2 Handwerkskammer Berlin); ' @@ -385,9 +395,14 @@ class Fixtures { title: 'Dokumentationspflichten', jurabk: 'MiLoG', paragraph: '§ 17', + jurisdiction: Jurisdiction.deutschland, standDate: DateTime.utc(2025, 11, 1), sourceUrl: 'https://www.gesetze-im-internet.de/milog/__17.html', sourceSha256: 'demo-${'e' * 60}', + freshness: Freshness.superseded, + supersededNote: 'BEG IV hat die Dokumentationspflichten zum ' + '1.1.2026 entschärft — diese Auswertung bildet die Fassung ' + 'davor ab und ist neu zu bewerten.', paragraphs: const [ NormParagraph( number: '(1)', diff --git a/app/lib/data/hub_repository.dart b/app/lib/data/hub_repository.dart index 2f1676e..768bd71 100644 --- a/app/lib/data/hub_repository.dart +++ b/app/lib/data/hub_repository.dart @@ -36,10 +36,21 @@ class HubRepository implements EvaluationRepository { endpoint: endpoint, authToken: settings.authToken, ); - final ok = await client.healthy().timeout( - const Duration(seconds: 4), - onTimeout: () => false, - ); + // The probe must NEVER throw out of connect(): main() awaits + // this before runApp(), so any uncaught error here leaves the + // app on a blank page. A timeout maps to false; so does any + // transport error (e.g. a gRPC-web call against a plain-gRPC + // hub, or a refused connection) — the app then comes up in + // 'hub-down' state and shows the empty-state hint. + bool ok; + try { + ok = await client.healthy().timeout( + const Duration(seconds: 4), + onTimeout: () => false, + ); + } catch (_) { + ok = false; + } return HubRepository._(client, settings, ok); } @@ -97,14 +108,21 @@ class HubRepository implements EvaluationRepository { if (normJson == null) return null; final skmJson = _asMap(bag['skm']); + final eli = (normJson['eli'] as String?) ?? 'eli/unknown'; final norm = Norm( - eli: (normJson['eli'] as String?) ?? 'eli/unknown', + eli: eli, title: (normJson['title'] as String?) ?? '(ohne Titel)', jurabk: (normJson['jurabk'] as String?) ?? '', paragraph: (normJson['paragraph'] as String?) ?? '', + jurisdiction: JurisdictionLabel.parse( + normJson['scope'] as String?, + eli: eli, + ), standDate: _parseTimestamp(event.timestamp), sourceUrl: (normJson['source_url'] as String?) ?? '', sourceSha256: (normJson['source_sha256'] as String?) ?? '', + freshness: _parseFreshness(normJson['freshness']), + supersededNote: normJson['superseded_note'] as String?, paragraphs: _parseParagraphs(normJson['paragraphs']), ); @@ -138,9 +156,26 @@ class HubRepository implements EvaluationRepository { auditEventId: event.eventId, notes: 'Live aus Hub-Flow ${event.flowName} ' '(execution ${event.flowExecution.substring(0, 8.clamp(0, event.flowExecution.length))}).', + // Jurist sign-off is recorded by the approval, not the flow + // bag. Until the review-rewrite wires approvals in, these stay + // null (= unreviewed) unless the bag carries them explicitly. + reviewedBy: bag['reviewed_by'] as String?, + reviewedAt: DateTime.tryParse( + (bag['reviewed_at'] as String?) ?? ''), ); } + static Freshness _parseFreshness(dynamic v) { + switch ((v as String?)?.trim().toLowerCase()) { + case 'superseded' || 'amended' || 'geändert' || 'stale': + return Freshness.superseded; + case 'unknown' || 'ungeprüft': + return Freshness.unknown; + default: + return Freshness.current; + } + } + static Map? _asMap(dynamic v) => v is Map ? v : null; diff --git a/app/lib/data/models.dart b/app/lib/data/models.dart index d75f950..9a6ae17 100644 --- a/app/lib/data/models.dart +++ b/app/lib/data/models.dart @@ -51,6 +51,138 @@ extension EvidenceTierLabel on EvidenceTier { }; } +/// Geltungsbereich — the geographic/legal scope a norm belongs to. +/// Nested by widening reach: Berlin ⊂ Deutschland ⊂ EU ⊂ Welt. +/// Every norm carries one so the UI can flag, at a glance, *which* +/// reform lever a figure speaks to (Bezirk/Senat, Bund, Brüssel, +/// or völkerrechtlich) — see Studie §5.3. +enum Jurisdiction { + /// Landesrecht Berlin (BauO Bln, ASOG, …) — Senat/Bezirk lever. + berlin, + + /// Bundesrecht (GewO, MiLoG, KassenSichV) — NKR/Bundestag lever. + deutschland, + + /// EU-Recht (DSGVO, REFIT) — Reform-Hebel liegt in Brüssel. + eu, + + /// Völker-/Welthandelsrecht (WTO, OECD, UN) — international. + international, +} + +extension JurisdictionLabel on Jurisdiction { + /// Kompakt-Code für Chips und enge Layouts. + String get short => switch (this) { + Jurisdiction.berlin => 'BE', + Jurisdiction.deutschland => 'DE', + Jurisdiction.eu => 'EU', + Jurisdiction.international => 'INT', + }; + + /// Voll ausgeschriebener Name. + String get label => switch (this) { + Jurisdiction.berlin => 'Berlin', + Jurisdiction.deutschland => 'Deutschland', + Jurisdiction.eu => 'EU', + Jurisdiction.international => 'International', + }; + + /// Rechtsebene — für Tooltip und Legend. + String get level => switch (this) { + Jurisdiction.berlin => 'Landesrecht Berlin', + Jurisdiction.deutschland => 'Bundesrecht', + Jurisdiction.eu => 'Recht der Europäischen Union', + Jurisdiction.international => 'Völker-/Welthandelsrecht', + }; + + /// Wo der Reform-Hebel ansetzt — die handlungsleitende Aussage. + String get reformLever => switch (this) { + Jurisdiction.berlin => + 'Reform-Hebel: Senat / Bezirk (Land Berlin)', + Jurisdiction.deutschland => + 'Reform-Hebel: Bundestag / NKR (Bund)', + Jurisdiction.eu => + 'Reform-Hebel: Brüssel — DE-Streichung allein wirkt nicht', + Jurisdiction.international => + 'Reform-Hebel: völkerrechtlich (WTO / OECD / UN)', + }; + + /// Ableitung aus der ELI/CELEX-Kennung, wenn kein Feld gesetzt + /// ist (Hub-Live-Modus liefert die ELI, nicht immer den Scope). + /// eli/eu/… → eu + /// eli/land/be/… → berlin + /// eli/bund/… → deutschland + /// eli/int|un|wto/… → international + static Jurisdiction fromEli(String eli) { + final e = eli.toLowerCase(); + if (e.contains('/eu/') || e.startsWith('eli/eu') || e.contains('celex')) { + return Jurisdiction.eu; + } + if (e.contains('/land/be') || e.contains('/land/berlin') || + e.contains('/be/')) { + return Jurisdiction.berlin; + } + if (e.contains('/int/') || e.contains('/un/') || e.contains('/wto/') || + e.contains('/oecd/')) { + return Jurisdiction.international; + } + return Jurisdiction.deutschland; + } + + /// Parse a serialized scope string from a hub flow bag, falling + /// back to ELI-derivation when absent or unrecognized. + static Jurisdiction parse(String? raw, {required String eli}) { + switch (raw?.trim().toLowerCase()) { + case 'berlin' || 'be' || 'land-berlin': + return Jurisdiction.berlin; + case 'deutschland' || 'de' || 'bund' || 'bundesrecht': + return Jurisdiction.deutschland; + case 'eu' || 'europa' || 'union': + return Jurisdiction.eu; + case 'international' || 'int' || 'welt' || 'global': + return Jurisdiction.international; + default: + return fromEli(eli); + } + } +} + +/// Aktualität einer Norm-Fassung relativ zu unserer Datenbasis. +/// Quelle der Wahrheit: `Norm.sourceSha256` gegen die aktuell +/// veröffentlichte Quelle + `standDate`. Surfaced als Badge, damit +/// eine veraltete Auswertung sofort erkennbar ist (Studie §5.3). +enum Freshness { + /// Unsere Fassung == aktuell veröffentlichter Stand. + current, + + /// Upstream geändert/aufgehoben — unsere Auswertung ist veraltet. + superseded, + + /// Noch nicht gegen die Quelle abgeglichen. + unknown, +} + +extension FreshnessLabel on Freshness { + String get label => switch (this) { + Freshness.current => 'aktuell', + Freshness.superseded => 'geändert', + Freshness.unknown => 'ungeprüft', + }; + + String get description => switch (this) { + Freshness.current => + 'Fassung entspricht dem aktuell veröffentlichten Stand.', + Freshness.superseded => + 'Quelle wurde upstream geändert — Auswertung veraltet, ' + 'Neubewertung nötig.', + Freshness.unknown => + 'Aktualität nicht gegen die Quelle abgeglichen.', + }; + + /// Stale-Zustände bekommen ein sichtbares Badge; `current` nicht. + bool get needsBadge => this != Freshness.current; +} + /// A citable source — never an LLM, always a public document or /// dataset reference. UI surfaces these next to every figure. class Source { @@ -107,9 +239,12 @@ class Norm { required this.title, required this.jurabk, required this.paragraph, + required this.jurisdiction, required this.standDate, required this.sourceUrl, required this.sourceSha256, + this.freshness = Freshness.current, + this.supersededNote, this.paragraphs = const [], }); @@ -117,10 +252,23 @@ class Norm { final String title; final String jurabk; final String paragraph; + + /// Geltungsbereich — Berlin / Deutschland / EU / International. + /// Surfaced as a scope badge wherever the norm appears. + final Jurisdiction jurisdiction; + final DateTime standDate; final String sourceUrl; final String sourceSha256; + /// Aktualität dieser Fassung gegenüber der Quelle. + final Freshness freshness; + + /// Optionaler Klartext-Hinweis, *was* sich geändert hat (z. B. + /// "BEG IV hat §17 zum 1.1.2026 entschärft"). Nur gesetzt, wenn + /// freshness != current. + final String? supersededNote; + /// The actual paragraphs of the norm text, normalised by /// `text.akoma_normalize` in live mode. In demo mode the /// fixture quotes the public source verbatim under §5 UrhG @@ -143,6 +291,8 @@ class Evaluation { required this.auditEventId, this.notes, this.componentSummary = const [], + this.reviewedBy, + this.reviewedAt, }); final Norm norm; @@ -179,4 +329,15 @@ class Evaluation { /// extras — each carries its tier-tag inline. Empty list ⇒ /// fall back to the notes field for the list view. final List componentSummary; + + /// Jurist who confirmed this evaluation (the `by` of the hub + /// approval). Null ⇒ not yet reviewed. Jurist review is OPTIONAL: + /// the figure stands on its own; a review only *confirms* it. + final String? reviewedBy; + + /// When the jurist signed off. Null ⇒ not reviewed. + final DateTime? reviewedAt; + + /// True once a trusted jurist has confirmed this evaluation. + bool get isReviewed => reviewedBy != null; } diff --git a/app/lib/pages/heatmap_page.dart b/app/lib/pages/heatmap_page.dart index f5832ee..3e1200c 100644 --- a/app/lib/pages/heatmap_page.dart +++ b/app/lib/pages/heatmap_page.dart @@ -5,6 +5,7 @@ import '../data/repository.dart'; import '../theme/reclaim_tokens.dart'; import '../widgets/mode_banner.dart'; import '../widgets/heatmap_grid.dart'; +import '../widgets/jurisdiction_badge.dart'; import '../widgets/reclaim_card.dart'; import '../widgets/tier_badge.dart'; import 'norm_detail_page.dart'; @@ -59,26 +60,29 @@ class _HeatmapPageState extends State { ), const SizedBox(height: ReclaimSpace.lg), Expanded( - child: ReclaimCard( - accent: true, - expand: true, - child: HeatmapGrid( - evaluations: items, - onTap: (e) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => NormDetailPage( - repository: widget.repository, - evaluation: e, - ), + child: items.isEmpty + ? _EmptyHubState(repository: widget.repository) + : ReclaimCard( + accent: true, + expand: true, + child: HeatmapGrid( + evaluations: items, + onTap: (e) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NormDetailPage( + repository: widget.repository, + evaluation: e, + ), + ), + ); + }, ), - ); - }, - ), - ), + ), ), - const SizedBox(height: ReclaimSpace.md), - const _LegendCard(), + if (items.isNotEmpty) ...[ + const SizedBox(height: ReclaimSpace.md), + const _LegendCard(), const SizedBox(height: ReclaimSpace.lg), Wrap( spacing: ReclaimSpace.md, @@ -104,6 +108,11 @@ class _HeatmapPageState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ + JurisdictionBadge( + jurisdiction: e.norm.jurisdiction, + compact: true), + const SizedBox( + width: ReclaimSpace.xs), TierBadge( tier: e.tierLowest, compact: true), const SizedBox( @@ -131,6 +140,7 @@ class _HeatmapPageState extends State { ), ], ), + ], ], ); }, @@ -196,6 +206,8 @@ class _LegendCardState extends State<_LegendCard> { symbol: '●', text: 'Betroffenheit'), _MiniLabel( symbol: '◐', text: 'Evidenz-Stufe'), + _MiniLabel( + symbol: '⌖', text: 'Geltungsbereich'), ], ), ), @@ -272,6 +284,21 @@ class _LegendCardState extends State<_LegendCard> { 'SKM-Zahl T4. Petrol → warm = stark → ' 'schwach.', ), + SizedBox(height: ReclaimSpace.sm), + _LegendRow( + symbol: '⌖', + axis: 'Chip-Symbol', + label: 'Geltungsbereich', + description: + 'Jede Norm trägt ein Scope-Chip — ' + 'BE Berlin (Landesrecht, Senat/Bezirk), ' + 'DE Deutschland (Bundesrecht, NKR/' + 'Bundestag), EU (Reform-Hebel in Brüssel), ' + 'INT International (WTO/OECD/UN). Es zeigt ' + 'an, auf welcher Ebene der Reform-Hebel ' + 'ansetzt: eine EU-Norm lässt sich nicht ' + 'durch nationale Streichung entlasten.', + ), ], ), ) @@ -285,6 +312,69 @@ class _LegendCardState extends State<_LegendCard> { } } +/// Shown in place of the heatmap when the repository returns zero +/// evaluations — almost always live-hub mode against a hub that +/// has no Recl∆Im `flow.completed` events yet (or is unreachable). +/// Tells the reader exactly how to get data on screen instead of +/// leaving a blank plot. +class _EmptyHubState extends StatelessWidget { + const _EmptyHubState({required this.repository}); + + final EvaluationRepository repository; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + final down = repository.mode == 'hub-down'; + return ReclaimCard( + accent: true, + expand: true, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + down ? Icons.cloud_off_outlined : Icons.inbox_outlined, + size: 40, + color: ReclaimColors.mute, + ), + const SizedBox(height: ReclaimSpace.md), + Text( + down + ? 'Hub nicht erreichbar' + : 'Noch keine Auswertungen im Hub', + style: t.headlineSmall, + ), + const SizedBox(height: ReclaimSpace.sm), + Text( + down + ? 'Verbunden mit ${repository.hubLabel}. Läuft ' + '`chain serve`? Sonst im Hub-Reiter den ' + 'Demo-Modus aktivieren — dann zeigt die ' + 'Heatmap das Fixture-Korpus.' + : 'Der Hub liefert keine flow.completed-Events. ' + 'Einen Recl∆Im-Flow laufen lassen, z. B.\n' + ' chain run flows/durchstich-from-file.yaml ' + '--input content=@norm.xml --input ' + 'cohort_id=berlin-kmu\n' + 'und im Hub/Studio freigeben — oder im ' + 'Hub-Reiter den Demo-Modus aktivieren.', + style: t.bodyLarge?.copyWith( + color: ReclaimColors.mute, + height: 1.4, + ), + ), + ], + ), + ), + ), + ); + } +} + class _MiniLabel extends StatelessWidget { const _MiniLabel({required this.symbol, required this.text}); diff --git a/app/lib/pages/norm_detail_page.dart b/app/lib/pages/norm_detail_page.dart index c0ff4ca..c77b628 100644 --- a/app/lib/pages/norm_detail_page.dart +++ b/app/lib/pages/norm_detail_page.dart @@ -5,8 +5,11 @@ import '../data/repository.dart'; import '../theme/reclaim_tokens.dart'; import '../widgets/mode_banner.dart'; import '../widgets/evidence_sidebar.dart'; +import '../widgets/freshness_badge.dart'; +import '../widgets/jurisdiction_badge.dart'; import '../widgets/norm_text_card.dart'; import '../widgets/reclaim_card.dart'; +import '../widgets/review_badge.dart'; import '../widgets/tier_badge.dart'; class NormDetailPage extends StatelessWidget { @@ -63,7 +66,27 @@ class NormDetailPage extends StatelessWidget { ], ), ), - TierBadge(tier: e.tierLowest), + Column( + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + JurisdictionBadge( + jurisdiction: e.norm.jurisdiction), + const SizedBox(height: ReclaimSpace.sm), + TierBadge(tier: e.tierLowest), + if (e.norm.freshness.needsBadge) ...[ + const SizedBox( + height: ReclaimSpace.sm), + FreshnessBadge( + freshness: e.norm.freshness, + note: e.norm.supersededNote, + ), + ], + const SizedBox(height: ReclaimSpace.sm), + ReviewBadge( + evaluation: e, showUnreviewed: true), + ], + ), ], ), const SizedBox(height: ReclaimSpace.lg), diff --git a/app/lib/pages/norms_list_page.dart b/app/lib/pages/norms_list_page.dart index 2d1ff3e..468dd8d 100644 --- a/app/lib/pages/norms_list_page.dart +++ b/app/lib/pages/norms_list_page.dart @@ -4,7 +4,10 @@ import '../data/models.dart'; import '../data/repository.dart'; import '../theme/reclaim_tokens.dart'; import '../widgets/mode_banner.dart'; +import '../widgets/freshness_badge.dart'; +import '../widgets/jurisdiction_badge.dart'; import '../widgets/reclaim_card.dart'; +import '../widgets/review_badge.dart'; import '../widgets/tier_badge.dart'; import 'norm_detail_page.dart'; @@ -108,7 +111,20 @@ class _NormRow extends StatelessWidget { ), ), const SizedBox(height: ReclaimSpace.sm), - TierBadge(tier: evaluation.tierLowest), + Wrap( + spacing: ReclaimSpace.sm, + runSpacing: ReclaimSpace.xs, + children: [ + JurisdictionBadge( + jurisdiction: evaluation.norm.jurisdiction), + TierBadge(tier: evaluation.tierLowest), + FreshnessBadge( + freshness: evaluation.norm.freshness, + note: evaluation.norm.supersededNote, + ), + ReviewBadge(evaluation: evaluation), + ], + ), ], ), ), diff --git a/app/lib/theme/reclaim_tokens.dart b/app/lib/theme/reclaim_tokens.dart index 654bde6..555900d 100644 --- a/app/lib/theme/reclaim_tokens.dart +++ b/app/lib/theme/reclaim_tokens.dart @@ -36,6 +36,23 @@ class ReclaimColors { static const tierT2 = Color(0xFF6CA29A); // verband static const tierT3 = Color(0xFFB0AC8E); // own survey static const tierT4 = Color(0xFFA86F5C); // qualitative signal only + + // Scope badges — Geltungsbereich of a norm. Hues widen from a + // warm-local Berlin to a cool-global teal, distinct from the + // tier palette so the two chips never read as the same thing. + // Tuned for dark-first legibility on the ink canvas (bright + // enough to read as chip text/border over inkRaised). + static const scopeBerlin = Color(0xFFD0685C); // Land Berlin (brick) + static const scopeDeutschland = Color(0xFFC79A3C); // Bund (gold) + static const scopeEu = Color(0xFF5B8DD6); // EU (union blue) + static const scopeInternational = Color(0xFF4FB89E); // global (teal) + + // Freshness — is our norm version current vs. the published source. + static const freshSuperseded = Color(0xFFE0973A); // amended upstream + static const freshUnknown = Color(0xFF9AA0A6); // not reconciled + + // Jurist review — an evaluation confirmed (signed off) by a jurist. + static const reviewConfirmed = Color(0xFF6FBE7A); // confirm green } /// Spacing scale — 4/8/12/16/24/32/48 multiples, matching diff --git a/app/lib/widgets/freshness_badge.dart b/app/lib/widgets/freshness_badge.dart new file mode 100644 index 0000000..0c938ee --- /dev/null +++ b/app/lib/widgets/freshness_badge.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import '../data/models.dart'; +import '../theme/reclaim_tokens.dart'; + +/// Freshness badge — flags when a norm version is stale relative to +/// the published source (amended upstream) or hasn't been reconciled. +/// By design it renders NOTHING for [Freshness.current] (the common +/// case shouldn't add noise) unless [alwaysShow] is set. Same chip +/// shape as the scope/tier badges. +class FreshnessBadge extends StatelessWidget { + const FreshnessBadge({ + super.key, + required this.freshness, + this.note, + this.compact = false, + this.alwaysShow = false, + }); + + final Freshness freshness; + + /// Optional plain-text hint of what changed (from Norm.supersededNote). + final String? note; + final bool compact; + final bool alwaysShow; + + @override + Widget build(BuildContext context) { + if (!freshness.needsBadge && !alwaysShow) { + return const SizedBox.shrink(); + } + final t = Theme.of(context).textTheme; + final color = switch (freshness) { + Freshness.current => ReclaimColors.signal, + Freshness.superseded => ReclaimColors.freshSuperseded, + Freshness.unknown => ReclaimColors.freshUnknown, + }; + final icon = switch (freshness) { + Freshness.current => Icons.check_circle_outline, + Freshness.superseded => Icons.history_toggle_off, + Freshness.unknown => Icons.help_outline, + }; + final label = compact ? freshness.label : 'Stand: ${freshness.label}'; + return Tooltip( + message: '${freshness.description}' + '${note != null && note!.isNotEmpty ? '\n$note' : ''}', + child: Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 6 : ReclaimSpace.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(ReclaimRadius.sm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: compact ? 12 : 14, color: color), + const SizedBox(width: 6), + Text( + label, + style: (compact ? t.labelSmall : t.labelLarge)?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/heatmap_grid.dart b/app/lib/widgets/heatmap_grid.dart index 3abb1a8..75c6d30 100644 --- a/app/lib/widgets/heatmap_grid.dart +++ b/app/lib/widgets/heatmap_grid.dart @@ -267,7 +267,8 @@ class _HeatmapPainter extends CustomPainter { ); _drawText( canvas, - 'Tier: ${e.tierLowest.short} Klick → Detail', + 'Bereich: ${e.norm.jurisdiction.short} ' + 'Tier: ${e.tierLowest.short} Klick → Detail', box.topLeft + const Offset(10, 44), const TextStyle( color: mute, diff --git a/app/lib/widgets/jurisdiction_badge.dart b/app/lib/widgets/jurisdiction_badge.dart new file mode 100644 index 0000000..88cdb8e --- /dev/null +++ b/app/lib/widgets/jurisdiction_badge.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import '../data/models.dart'; +import '../theme/reclaim_tokens.dart'; + +/// Scope badge — surfaces a norm's Geltungsbereich (Berlin / +/// Deutschland / EU / International) wherever the norm appears, so +/// the reader sees at a glance which reform lever a figure speaks +/// to. Visually mirrors [TierBadge]: a scope glyph + code in a +/// tinted chip, here with a Material icon instead of a dot. +class JurisdictionBadge extends StatelessWidget { + const JurisdictionBadge({ + super.key, + required this.jurisdiction, + this.compact = false, + }); + + final Jurisdiction jurisdiction; + final bool compact; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + final color = switch (jurisdiction) { + Jurisdiction.berlin => ReclaimColors.scopeBerlin, + Jurisdiction.deutschland => ReclaimColors.scopeDeutschland, + Jurisdiction.eu => ReclaimColors.scopeEu, + Jurisdiction.international => ReclaimColors.scopeInternational, + }; + final icon = switch (jurisdiction) { + Jurisdiction.berlin => Icons.location_city_outlined, + Jurisdiction.deutschland => Icons.flag_outlined, + Jurisdiction.eu => Icons.star_outline_rounded, + Jurisdiction.international => Icons.public, + }; + final label = compact + ? jurisdiction.short + : '${jurisdiction.short} · ${jurisdiction.label}'; + return Tooltip( + message: 'Geltungsbereich: ${jurisdiction.label} ' + '(${jurisdiction.level}).\n${jurisdiction.reformLever}.', + child: Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 6 : ReclaimSpace.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(ReclaimRadius.sm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: compact ? 12 : 14, color: color), + const SizedBox(width: 6), + Text( + label, + style: (compact ? t.labelSmall : t.labelLarge)?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/review_badge.dart b/app/lib/widgets/review_badge.dart new file mode 100644 index 0000000..cfb03d6 --- /dev/null +++ b/app/lib/widgets/review_badge.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../data/models.dart'; +import '../theme/reclaim_tokens.dart'; + +/// Review badge — marks an evaluation a trusted jurist has confirmed +/// (signed off via the hub approval). Jurist review is OPTIONAL: an +/// evaluation stands on its own; this only adds the "bestätigt" mark. +/// Renders a confirm-green check when reviewed; renders the muted +/// "ungeprüft" state only when [showUnreviewed] is set (otherwise +/// nothing, to keep unreviewed rows clean). +class ReviewBadge extends StatelessWidget { + const ReviewBadge({ + super.key, + required this.evaluation, + this.compact = false, + this.showUnreviewed = false, + }); + + final Evaluation evaluation; + final bool compact; + final bool showUnreviewed; + + @override + Widget build(BuildContext context) { + final reviewed = evaluation.isReviewed; + if (!reviewed && !showUnreviewed) return const SizedBox.shrink(); + + final t = Theme.of(context).textTheme; + final color = + reviewed ? ReclaimColors.reviewConfirmed : ReclaimColors.mute; + final icon = reviewed + ? Icons.verified_outlined + : Icons.radio_button_unchecked; + final label = reviewed + ? (compact ? 'geprüft' : 'Jurist-geprüft') + : (compact ? 'offen' : 'ungeprüft'); + final tip = reviewed + ? 'Bestätigt von ${evaluation.reviewedBy}' + '${evaluation.reviewedAt != null ? ' am ${_d(evaluation.reviewedAt!)}' : ''}.\n' + 'Jurist-Review ist optional — es bestätigt die Auswertung, ' + 'ist aber keine Voraussetzung.' + : 'Noch von keinem Juristen bestätigt. Die Auswertung gilt ' + 'trotzdem (Review ist optional).'; + return Tooltip( + message: tip, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 6 : ReclaimSpace.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(ReclaimRadius.sm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: compact ? 12 : 14, color: color), + const SizedBox(width: 6), + Text( + label, + style: (compact ? t.labelSmall : t.labelLarge)?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } + + static String _d(DateTime d) => + '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}'; +}