feat(ui): inline the normative text + open-source URL

Adds the actual paragraphs of the law right next to the
evaluation panel on the norm-detail page. A reviewer no longer
has to leave the app to confirm what a score is about; the
canonical source URL stays one click away via 'Im Browser
öffnen' (url_launcher) and one click away as a clipboard copy.

Schema

  models.dart gains a NormParagraph (number + text). Norm carries
  a List<NormParagraph> paragraphs, defaulted to const [] so
  norms harvested by text.akoma_normalize@^0 in live mode slot
  in without breaking the Mock path.

Demo data

  fixtures.dart embeds the public source paragraphs verbatim
  under §5 UrhG (amtliche Werke) for the five pilot norms.

Plumbing

  NormTextCard (lib/widgets/norm_text_card.dart) is the renderer:
  per-paragraph hanging label, selectable body text, source URL
  in mono at the bottom, two action buttons. NormDetailPage
  drops the card between the metric row and the duties card so
  the eye lands on it during a 'why this score?' read-through.

macOS sandbox

  The Release entitlement file gains
  com.apple.security.network.client so url_launcher's NSWorkspace
  call and the HubRepository's gRPC channel can both reach the
  network. The DebugProfile already had server; client matches
  the production posture.

  GeneratedPluginRegistrant.swift was re-generated by
  flutter pub get after url_launcher was added — no hand edits.

flutter analyze: clean. flutter test: 2/2 passing. The macOS
build via build-macos.sh launches and the new card renders the
GewO § 14, KassenSichV, DSGVO Art. 30, BauO Bln § 60 ff and
MiLoG § 17 fixtures with their actual normative text.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-18 13:43:22 +02:00
parent e08cedcd3f
commit 116310b65b
7 changed files with 337 additions and 33 deletions

View file

@ -2,10 +2,11 @@ import 'package:flutter/material.dart';
import '../data/models.dart';
import '../data/repository.dart';
import '../theme/lawheatmap_tokens.dart';
import '../theme/reclaim_tokens.dart';
import '../widgets/demo_banner.dart';
import '../widgets/evidence_sidebar.dart';
import '../widgets/lawheatmap_card.dart';
import '../widgets/norm_text_card.dart';
import '../widgets/reclaim_card.dart';
import '../widgets/tier_badge.dart';
class NormDetailPage extends StatelessWidget {
@ -31,7 +32,7 @@ class NormDetailPage extends StatelessWidget {
const DemoBanner(),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(LawHeatmapSpace.xl),
padding: const EdgeInsets.all(ReclaimSpace.xl),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -55,8 +56,8 @@ class NormDetailPage extends StatelessWidget {
e.norm.eli,
style: t.bodyLarge?.copyWith(
fontFamily:
LawHeatmapTypography.mono,
color: LawHeatmapColors.mute,
ReclaimTypography.mono,
color: ReclaimColors.mute,
),
),
],
@ -65,34 +66,36 @@ class NormDetailPage extends StatelessWidget {
TierBadge(tier: e.tierLowest),
],
),
const SizedBox(height: LawHeatmapSpace.lg),
const SizedBox(height: ReclaimSpace.lg),
_MetricsRow(e: e),
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
const SizedBox(height: ReclaimSpace.lg),
NormTextCard(norm: e.norm),
const SizedBox(height: ReclaimSpace.lg),
ReclaimCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Pflichten',
style: t.headlineSmall),
const SizedBox(
height: LawHeatmapSpace.sm),
height: ReclaimSpace.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),
color: ReclaimColors.mute),
),
const SizedBox(
height: LawHeatmapSpace.md),
height: ReclaimSpace.md),
for (final d in e.duties)
_DutyTile(duty: d),
],
),
),
if (e.notes != null) ...[
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
const SizedBox(height: ReclaimSpace.lg),
ReclaimCard(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
@ -100,7 +103,7 @@ class NormDetailPage extends StatelessWidget {
Text('Methodik-Notiz',
style: t.headlineSmall),
const SizedBox(
height: LawHeatmapSpace.sm),
height: ReclaimSpace.sm),
Text(e.notes!, style: t.bodyLarge),
],
),
@ -109,7 +112,7 @@ class NormDetailPage extends StatelessWidget {
],
),
),
const SizedBox(width: LawHeatmapSpace.xl),
const SizedBox(width: ReclaimSpace.xl),
SizedBox(
width: 340,
child: EvidenceSidebar(evaluation: e),
@ -137,19 +140,19 @@ class _MetricsRow extends StatelessWidget {
value: _eur(e.skmEurPerYear),
sub: 'SKM P×F×T×h',
),
const SizedBox(width: LawHeatmapSpace.md),
const SizedBox(width: ReclaimSpace.md),
_MetricBox(
label: 'Nutzen',
value: '${e.benefitScore.toStringAsFixed(1)} / 5',
sub: '7-dim. Score',
),
const SizedBox(width: LawHeatmapSpace.md),
const SizedBox(width: ReclaimSpace.md),
_MetricBox(
label: 'Betroffene',
value: _kmu(e.affectedCount),
sub: 'DESTATIS',
),
const SizedBox(width: LawHeatmapSpace.md),
const SizedBox(width: ReclaimSpace.md),
_MetricBox(
label: 'Frust',
value: '${e.frustScore.toStringAsFixed(1)} / 10',
@ -187,19 +190,19 @@ class _MetricBox extends StatelessWidget {
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Expanded(
child: LawHeatmapCard(
child: ReclaimCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: t.labelSmall
?.copyWith(color: LawHeatmapColors.mute)),
?.copyWith(color: ReclaimColors.mute)),
const SizedBox(height: 4),
Text(value, style: t.displaySmall),
const SizedBox(height: 4),
Text(sub,
style: t.labelSmall
?.copyWith(color: LawHeatmapColors.mute)),
?.copyWith(color: ReclaimColors.mute)),
],
),
),
@ -215,16 +218,16 @@ class _DutyTile extends StatelessWidget {
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(bottom: LawHeatmapSpace.md),
padding: const EdgeInsets.only(bottom: ReclaimSpace.md),
child: Container(
padding: const EdgeInsets.all(LawHeatmapSpace.md),
padding: const EdgeInsets.all(ReclaimSpace.md),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.12)),
borderRadius: const BorderRadius.all(LawHeatmapRadius.sm),
borderRadius: const BorderRadius.all(ReclaimRadius.sm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -234,26 +237,26 @@ class _DutyTile extends StatelessWidget {
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: LawHeatmapColors.signal.withValues(alpha: 0.18),
color: ReclaimColors.signal.withValues(alpha: 0.18),
borderRadius:
const BorderRadius.all(LawHeatmapRadius.sm),
const BorderRadius.all(ReclaimRadius.sm),
),
child: Text(
duty.modality,
style: t.labelSmall?.copyWith(
color: LawHeatmapColors.signal,
color: ReclaimColors.signal,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: LawHeatmapSpace.sm),
const SizedBox(width: ReclaimSpace.sm),
Text(duty.sourceNorm,
style: t.labelSmall?.copyWith(
fontFamily: LawHeatmapTypography.mono,
color: LawHeatmapColors.mute,
fontFamily: ReclaimTypography.mono,
color: ReclaimColors.mute,
)),
]),
const SizedBox(height: LawHeatmapSpace.sm),
const SizedBox(height: ReclaimSpace.sm),
Text(duty.action, style: t.bodyLarge),
const SizedBox(height: 4),
Text(
@ -261,7 +264,7 @@ class _DutyTile extends StatelessWidget {
'Frequenz: ${duty.frequency}'
'${duty.authority != null ? " · Behörde: ${duty.authority}" : ""}'
'${duty.consequence != null ? " · Sanktion: ${duty.consequence}" : ""}',
style: t.labelSmall?.copyWith(color: LawHeatmapColors.mute),
style: t.labelSmall?.copyWith(color: ReclaimColors.mute),
),
],
),