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:
parent
e08cedcd3f
commit
116310b65b
7 changed files with 337 additions and 33 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<NormParagraph> paragraphs;
|
||||
}
|
||||
|
||||
/// A heatmap point: one norm with its computed scores.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
143
app/lib/widgets/norm_text_card.dart
Normal file
143
app/lib/widgets/norm_text_card.dart
Normal file
|
|
@ -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<void> _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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@
|
|||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -4,5 +4,7 @@
|
|||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue