diff --git a/app/lib/data/fixtures.dart b/app/lib/data/fixtures.dart index 57fcd25..18980ee 100644 --- a/app/lib/data/fixtures.dart +++ b/app/lib/data/fixtures.dart @@ -29,6 +29,26 @@ class Fixtures { standDate: DateTime.utc(2025, 12, 1), sourceUrl: 'https://www.gesetze-im-internet.de/gewo/__14.html', sourceSha256: 'demo-${'a' * 60}', + paragraphs: const [ + NormParagraph( + number: '(1)', + text: 'Wer den selbständigen Betrieb eines stehenden Gewerbes, ' + 'einer Zweigniederlassung oder einer unselbständigen ' + 'Zweigstelle anfängt, muss dies der zuständigen Behörde ' + 'gleichzeitig anzeigen. Das Gleiche gilt, wenn ' + '1. der Betrieb verlegt wird, ' + '2. der Gegenstand des Gewerbes gewechselt oder ' + 'auf Waren oder Leistungen ausgedehnt wird, die bei ' + 'Gewerbebetrieben der angemeldeten Art nicht geschäftsüblich ' + 'sind, oder ' + '3. der Betrieb aufgegeben wird.', + ), + NormParagraph( + number: '(2)', + text: 'Die Anzeige hat unverzüglich zu erfolgen. Sie ist nach ' + 'amtlich vorgeschriebenem Vordruck zu erstatten.', + ), + ], ), skmEurPerYear: 2_800_000, benefitScore: 2.5, @@ -78,6 +98,30 @@ class Fixtures { sourceUrl: 'https://www.gesetze-im-internet.de/kassensichv/index.html', sourceSha256: 'demo-${'b' * 60}', + paragraphs: const [ + NormParagraph( + number: '§ 1', + text: 'Diese Verordnung gilt für elektronische ' + 'Aufzeichnungssysteme im Sinne des § 146a Absatz 1 ' + 'Satz 1 der Abgabenordnung, mit denen aufzeichnungspflichtige ' + 'Geschäftsvorfälle oder andere Vorgänge erfasst werden.', + ), + NormParagraph( + number: '§ 2', + text: 'Das elektronische Aufzeichnungssystem muss jeden ' + 'einzelnen aufzeichnungspflichtigen Vorgang vollständig ' + 'aufzeichnen. Die Aufzeichnungen sind durch eine ' + 'zertifizierte technische Sicherheitseinrichtung ' + 'unmittelbar zu protokollieren.', + ), + NormParagraph( + number: '§ 3', + text: 'Den Steuerpflichtigen treffen Mitteilungspflichten ' + 'gegenüber dem zuständigen Finanzamt; der Ausfall einer ' + 'zertifizierten technischen Sicherheitseinrichtung ist ' + 'unverzüglich anzuzeigen.', + ), + ], ), skmEurPerYear: 25_000_000, benefitScore: 3.0, @@ -136,6 +180,48 @@ class Fixtures { standDate: DateTime.utc(2025, 10, 1), sourceUrl: 'https://eur-lex.europa.eu/eli/reg/2016/679', sourceSha256: 'demo-${'c' * 60}', + paragraphs: const [ + NormParagraph( + number: '(1)', + text: 'Jeder Verantwortliche und gegebenenfalls sein Vertreter ' + 'führen ein Verzeichnis aller Verarbeitungstätigkeiten, ' + 'die ihrer Zuständigkeit unterliegen. Dieses Verzeichnis ' + 'enthält sämtliche folgenden Angaben: a) den Namen und ' + 'die Kontaktdaten des Verantwortlichen … b) die Zwecke ' + 'der Verarbeitung; c) eine Beschreibung der Kategorien ' + 'betroffener Personen und der Kategorien personenbezogener ' + 'Daten; d) die Kategorien von Empfängern …', + ), + NormParagraph( + number: '(2)', + text: 'Jeder Auftragsverarbeiter und gegebenenfalls sein ' + 'Vertreter führen ein Verzeichnis zu allen Kategorien von ' + 'im Auftrag eines Verantwortlichen durchgeführten ' + 'Verarbeitungstätigkeiten.', + ), + NormParagraph( + number: '(3)', + text: 'Das in den Absätzen 1 und 2 genannte Verzeichnis ist ' + 'schriftlich zu führen, was auch in einem elektronischen ' + 'Format erfolgen kann.', + ), + NormParagraph( + number: '(4)', + text: 'Der Verantwortliche oder der Auftragsverarbeiter sowie ' + 'gegebenenfalls deren Vertreter stellen das Verzeichnis ' + 'der Aufsichtsbehörde auf Anfrage zur Verfügung.', + ), + NormParagraph( + number: '(5)', + text: 'Die in den Absätzen 1 und 2 genannten Pflichten gelten ' + 'nicht für Unternehmen oder Einrichtungen, die weniger als ' + '250 Mitarbeiter beschäftigen, es sei denn die von ihnen ' + 'vorgenommene Verarbeitung birgt ein Risiko für die Rechte ' + 'und Freiheiten der betroffenen Personen, die Verarbeitung ' + 'erfolgt nicht nur gelegentlich oder umfasst besondere ' + 'Datenkategorien gemäß Artikel 9 Absatz 1 …', + ), + ], ), skmEurPerYear: 450_000_000, benefitScore: 3.8, @@ -185,6 +271,31 @@ class Fixtures { sourceUrl: 'https://gesetze.berlin.de/bsbe/document/jlr-BauOBE2005V25P60', sourceSha256: 'demo-${'d' * 60}', + paragraphs: const [ + NormParagraph( + number: '§ 60 (1)', + text: 'Für die Errichtung, Änderung und Nutzungsänderung ' + 'baulicher Anlagen sowie anderer Anlagen und ' + 'Einrichtungen im Sinne des § 1 Absatz 1 Satz 2 ist die ' + 'Baugenehmigung erforderlich, soweit in den §§ 61 bis 65 ' + 'sowie 76 und 77 nichts anderes bestimmt ist.', + ), + NormParagraph( + number: '§ 60 (2)', + text: 'Bauliche Anlagen und andere Anlagen, an die in dieser ' + 'Bauordnung oder in Vorschriften aufgrund dieser ' + 'Bauordnung Anforderungen gestellt werden, dürfen nur so ' + 'errichtet, geändert und in der Nutzung geändert werden, ' + 'dass sie diesen Anforderungen entsprechen.', + ), + NormParagraph( + number: '§ 63', + text: 'Der Bauantrag ist schriftlich bei der Bauaufsichtsbehörde ' + 'unter Beifügung der für die Beurteilung des Vorhabens und ' + 'die Bearbeitung des Bauantrags erforderlichen Bauvorlagen ' + 'einzureichen.', + ), + ], ), skmEurPerYear: 72_000_000, benefitScore: 3.5, @@ -233,6 +344,30 @@ class Fixtures { standDate: DateTime.utc(2025, 11, 1), sourceUrl: 'https://www.gesetze-im-internet.de/milog/__17.html', sourceSha256: 'demo-${'e' * 60}', + paragraphs: const [ + NormParagraph( + number: '(1)', + text: 'Ein Arbeitgeber, der Arbeitnehmerinnen und Arbeitnehmer ' + 'nach § 8 Absatz 1 des Vierten Buches Sozialgesetzbuch ' + 'geringfügig beschäftigt oder in den in § 2a des ' + 'Schwarzarbeitsbekämpfungsgesetzes genannten ' + 'Wirtschaftsbereichen oder Wirtschaftszweigen Personen ' + 'beschäftigt, ist verpflichtet, Beginn, Ende und Dauer der ' + 'täglichen Arbeitszeit dieser Arbeitnehmerinnen und ' + 'Arbeitnehmer spätestens bis zum Ablauf des siebten auf ' + 'den Tag der Arbeitsleistung folgenden Kalendertages ' + 'aufzuzeichnen und diese Aufzeichnungen mindestens zwei ' + 'Jahre beginnend ab dem für die Aufzeichnung maßgeblichen ' + 'Zeitpunkt aufzubewahren.', + ), + NormParagraph( + number: '(2)', + text: 'Arbeitgeber im Sinne des Absatzes 1 haben die für die ' + 'Kontrolle der Einhaltung der Verpflichtungen nach § 20 ' + 'in Verbindung mit § 2 Absatz 1 erforderlichen Unterlagen ' + 'im Inland in deutscher Sprache bereitzuhalten.', + ), + ], ), skmEurPerYear: 270_000_000, benefitScore: 3.2, diff --git a/app/lib/data/models.dart b/app/lib/data/models.dart index d439635..309835f 100644 --- a/app/lib/data/models.dart +++ b/app/lib/data/models.dart @@ -1,4 +1,4 @@ -// Domain model for the F∆I Law-Heatmap client. +// Domain model for the F∆I Recl∆Im client. // // These types are the lingua franca between the (eventually // gRPC-fed) repository and the UI. Names mirror the Studie's @@ -75,6 +75,16 @@ class Duty { final String? consequence; } +/// One Absatz/paragraph of the actual normative text. +class NormParagraph { + const NormParagraph({required this.number, required this.text}); + + /// Source numbering as it appears, e.g. "(1)", "(2a)". Empty + /// when the source has no Absatz numbering. + final String number; + final String text; +} + /// One norm version — a versioned legal text identified by its /// (synthetic for now) ELI/CELEX. class Norm { @@ -86,6 +96,7 @@ class Norm { required this.standDate, required this.sourceUrl, required this.sourceSha256, + this.paragraphs = const [], }); final String eli; @@ -95,6 +106,12 @@ class Norm { final DateTime standDate; final String sourceUrl; final String sourceSha256; + + /// 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 + /// (amtliche Werke). + final List paragraphs; } /// A heatmap point: one norm with its computed scores. diff --git a/app/lib/pages/norm_detail_page.dart b/app/lib/pages/norm_detail_page.dart index e94accb..0185ab5 100644 --- a/app/lib/pages/norm_detail_page.dart +++ b/app/lib/pages/norm_detail_page.dart @@ -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), ), ], ), diff --git a/app/lib/widgets/norm_text_card.dart b/app/lib/widgets/norm_text_card.dart new file mode 100644 index 0000000..7c98ce2 --- /dev/null +++ b/app/lib/widgets/norm_text_card.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../data/models.dart'; +import '../theme/reclaim_tokens.dart'; +import 'reclaim_card.dart'; + +/// Renders the actual paragraphs of a norm right next to the +/// evaluation. Solves the "I want to read the law in place" +/// requirement so a reviewer never has to context-switch to a +/// browser tab just to confirm what the score is about. +/// +/// Two side-buttons at the top: +/// - "Im Browser öffnen" — pop the canonical source URL +/// - "URL kopieren" — clipboard, useful for citations +/// +/// On macOS the browser-open uses the system `open` command — +/// no extra package, no permission prompt. iOS/Linux/Windows +/// branches would need `url_launcher` later; Phase 0 ships +/// macOS first. +class NormTextCard extends StatelessWidget { + const NormTextCard({super.key, required this.norm}); + + final Norm norm; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + final paragraphs = norm.paragraphs; + return ReclaimCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Gesetzestext', style: t.headlineSmall), + const SizedBox(height: 4), + Text( + '${norm.jurabk} ${norm.paragraph} — Stand ' + '${_isoDate(norm.standDate)}', + style: t.labelSmall?.copyWith( + color: ReclaimColors.mute, + ), + ), + ], + ), + ), + IconButton( + tooltip: 'URL kopieren', + onPressed: () => + Clipboard.setData(ClipboardData(text: norm.sourceUrl)), + icon: const Icon(Icons.link), + ), + FilledButton.icon( + onPressed: () => _openInBrowser(context, norm.sourceUrl), + icon: const Icon(Icons.open_in_new), + label: const Text('Im Browser öffnen'), + ), + ], + ), + const SizedBox(height: ReclaimSpace.lg), + if (paragraphs.isEmpty) + Text( + 'Für diese Norm ist noch kein normalisierter Text ' + 'eingebunden. Wenn Ch∆In mit `text.akoma_normalize@^0` ' + 'gegen die Quell-URL läuft, erscheinen die Absätze hier.', + style: t.bodyLarge?.copyWith(color: ReclaimColors.mute), + ) + else + for (final p in paragraphs) + Padding( + padding: + const EdgeInsets.only(bottom: ReclaimSpace.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 64, + child: Text( + p.number, + style: t.labelLarge?.copyWith( + color: ReclaimColors.signal, + fontFamily: ReclaimTypography.mono, + ), + ), + ), + Expanded( + child: SelectableText( + p.text, + style: t.bodyLarge?.copyWith(height: 1.55), + ), + ), + ], + ), + ), + const SizedBox(height: ReclaimSpace.sm), + SelectableText( + norm.sourceUrl, + style: t.labelSmall?.copyWith( + color: ReclaimColors.mute, + fontFamily: ReclaimTypography.mono, + ), + ), + ], + ), + ); + } + + String _isoDate(DateTime d) => + '${d.year.toString().padLeft(4, "0")}-' + '${d.month.toString().padLeft(2, "0")}-' + '${d.day.toString().padLeft(2, "0")}'; + + Future _openInBrowser(BuildContext context, String url) async { + final messenger = ScaffoldMessenger.of(context); + final uri = Uri.tryParse(url); + if (uri == null) { + messenger.showSnackBar( + SnackBar(content: Text('Ungültige URL: $url')), + ); + return; + } + try { + final ok = + await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!ok) { + messenger.showSnackBar( + const SnackBar(content: Text('Browser-Aufruf abgelehnt.')), + ); + } + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Konnte Browser nicht öffnen: $e')), + ); + } + } +} diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index 724bb2a..997e35d 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,9 @@ import FlutterMacOS import Foundation import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/app/macos/Runner/DebugProfile.entitlements b/app/macos/Runner/DebugProfile.entitlements index dddb8a3..08c3ab1 100644 --- a/app/macos/Runner/DebugProfile.entitlements +++ b/app/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,7 @@ com.apple.security.network.server + com.apple.security.network.client + diff --git a/app/macos/Runner/Release.entitlements b/app/macos/Runner/Release.entitlements index 852fa1a..ee95ab7 100644 --- a/app/macos/Runner/Release.entitlements +++ b/app/macos/Runner/Release.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.network.client +