feat(ui): scope, freshness and jurist-review badges + live empty-state
Adds three dark-first chip badges in the TierBadge pattern: - Jurisdiction (Berlin/DE/EU/International) — derived from the ELI, surfaced on the norms list, detail header and heatmap chips/hover. - Freshness (aktuell/geaendert/ungeprueft) — flags norms amended upstream (standDate + sourceSha256); renders only when stale. - Jurist review — marks evaluations a trusted jurist has confirmed (optional; the figure stands on its own). Models gain Jurisdiction/Freshness enums, Norm.freshness/supersededNote and Evaluation.reviewedBy/reviewedAt; HubRepository parses them from the flow bag (ELI fallback). HeatmapPage shows a real empty-state (hub-down vs no-evaluations) instead of a blank plot, and connect() no longer throws on a failed probe. Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
9dfa418e92
commit
aabff2b140
11 changed files with 604 additions and 26 deletions
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic>? _asMap(dynamic v) =>
|
||||
v is Map<String, dynamic> ? v : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HeatmapPage> {
|
|||
),
|
||||
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<HeatmapPage> {
|
|||
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<HeatmapPage> {
|
|||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
@ -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});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
74
app/lib/widgets/freshness_badge.dart
Normal file
74
app/lib/widgets/freshness_badge.dart
Normal file
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
69
app/lib/widgets/jurisdiction_badge.dart
Normal file
69
app/lib/widgets/jurisdiction_badge.dart
Normal file
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
77
app/lib/widgets/review_badge.dart
Normal file
77
app/lib/widgets/review_badge.dart
Normal file
|
|
@ -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}';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue