reclaim/app/lib/pages/norm_detail_page.dart
flemming-it 51beb332c4 feat(ui): ModeBanner replaces DemoBanner + no silent mock-fallback
Two related fixes so the app never silently presents itself as
a demo when it is in fact running against a real hub:

  - The orange 'Demo-Daten · KEINE Rechtsberatung' banner was
    hard-coded into every data screen, regardless of whether
    the active repository was the in-memory MockRepository or
    the live HubRepository. Replaced with ModeBanner, which
    picks one of three voices by repository.mode:

      demo      orange T4 — 'Demo-Daten · KEINE Rechtsberatung'
      hub-live  petrol     — 'Live aus Hub · <endpoint>'
      hub-down  warm-warn  — 'Hub nicht erreichbar — leere Liste'

  - main.dart's _pickRepository used to fall back to
    MockRepository whenever HubRepository.connect() came back
    unhealthy. That silently downgraded a live deployment to
    demo data, which is exactly what the user is trying to avoid.
    Removed: when useHub=true, the HubRepository is returned
    regardless. Its list() returns [] on hub-down, and the
    'hub-down' banner explains why the screens are empty.
    Mock data only shows when the user explicitly turns useHub
    off in the Hub-Reiter.

Also: legend arrow icons now point in the actual axis direction
(→ for Schaden, ↑ for Nutzen) instead of the bidirectional ↔/↕,
which suggested the axes ran both ways. Mini-chips and the
expanded legend rows are consistent.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-18 15:57:50 +02:00

274 lines
9.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import '../data/models.dart';
import '../data/repository.dart';
import '../theme/reclaim_tokens.dart';
import '../widgets/mode_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: [
ModeBanner(repository: repository),
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),
),
],
),
),
);
}
}