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
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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue