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>
274 lines
9.3 KiB
Dart
274 lines
9.3 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../data/models.dart';
|
||
import '../data/repository.dart';
|
||
import '../theme/reclaim_tokens.dart';
|
||
import '../widgets/demo_banner.dart';
|
||
import '../widgets/evidence_sidebar.dart';
|
||
import '../widgets/norm_text_card.dart';
|
||
import '../widgets/reclaim_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(ReclaimSpace.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:
|
||
ReclaimTypography.mono,
|
||
color: ReclaimColors.mute,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
TierBadge(tier: e.tierLowest),
|
||
],
|
||
),
|
||
const SizedBox(height: ReclaimSpace.lg),
|
||
_MetricsRow(e: e),
|
||
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: ReclaimSpace.sm),
|
||
Text(
|
||
'Atomare deontische Aussagen, extrahiert '
|
||
'aus dem Norm-Text (Phase 1: regex + LLM-'
|
||
'Vorschlag, Juristen-Review obligatorisch).',
|
||
style: t.labelSmall?.copyWith(
|
||
color: ReclaimColors.mute),
|
||
),
|
||
const SizedBox(
|
||
height: ReclaimSpace.md),
|
||
for (final d in e.duties)
|
||
_DutyTile(duty: d),
|
||
],
|
||
),
|
||
),
|
||
if (e.notes != null) ...[
|
||
const SizedBox(height: ReclaimSpace.lg),
|
||
ReclaimCard(
|
||
child: Column(
|
||
crossAxisAlignment:
|
||
CrossAxisAlignment.start,
|
||
children: [
|
||
Text('Methodik-Notiz',
|
||
style: t.headlineSmall),
|
||
const SizedBox(
|
||
height: ReclaimSpace.sm),
|
||
Text(e.notes!, style: t.bodyLarge),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: ReclaimSpace.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: ReclaimSpace.md),
|
||
_MetricBox(
|
||
label: 'Nutzen',
|
||
value: '${e.benefitScore.toStringAsFixed(1)} / 5',
|
||
sub: '7-dim. Score',
|
||
),
|
||
const SizedBox(width: ReclaimSpace.md),
|
||
_MetricBox(
|
||
label: 'Betroffene',
|
||
value: _kmu(e.affectedCount),
|
||
sub: 'DESTATIS',
|
||
),
|
||
const SizedBox(width: ReclaimSpace.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: ReclaimCard(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(label,
|
||
style: t.labelSmall
|
||
?.copyWith(color: ReclaimColors.mute)),
|
||
const SizedBox(height: 4),
|
||
Text(value, style: t.displaySmall),
|
||
const SizedBox(height: 4),
|
||
Text(sub,
|
||
style: t.labelSmall
|
||
?.copyWith(color: ReclaimColors.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: ReclaimSpace.md),
|
||
child: Container(
|
||
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(ReclaimRadius.sm),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: ReclaimColors.signal.withValues(alpha: 0.18),
|
||
borderRadius:
|
||
const BorderRadius.all(ReclaimRadius.sm),
|
||
),
|
||
child: Text(
|
||
duty.modality,
|
||
style: t.labelSmall?.copyWith(
|
||
color: ReclaimColors.signal,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: ReclaimSpace.sm),
|
||
Text(duty.sourceNorm,
|
||
style: t.labelSmall?.copyWith(
|
||
fontFamily: ReclaimTypography.mono,
|
||
color: ReclaimColors.mute,
|
||
)),
|
||
]),
|
||
const SizedBox(height: ReclaimSpace.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: ReclaimColors.mute),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|