reclaim/app/lib/pages/norms_list_page.dart
flemming-it ae9fc45177 feat(ui): show SKM component-tier summary on the Normen list
Each Evaluation grows a componentSummary field — a small list of
plain strings, one per SKM ingredient (P / F / T / h), tagged
with its tier in the form 'P · T1 — DESTATIS Gewerbeanzeigen'.
The fixtures fill it in for all five pilot norms; the field
defaults to an empty list so flow-produced Evaluations remain
backwards-compatible until a future flow step writes one.

NormsListPage re-arranges the row to make use of the wide-screen
real-estate on the right that used to sit empty:

  ┌─────────────────────────────────────────────────────────────┐
  │  GewO § 14 — Anzeigepflicht  │ Schaden    │ Komponenten      │
  │  eli/bund/gewo/14             │ 2.8 Mio €  │ P · T1 …         │
  │  [T1 · amtlich]               │ Nutzen     │ F · T1 …         │
  │                               │ 2.5 / 5    │ T · T1 …         │
  │                               │ Betroffene │ h · T3 …         │
  │                               │ 180 k      │                  │
  └─────────────────────────────────────────────────────────────┘

The TierBadge moved into the title column (right under the eli)
so the eye lands on the tier of the figure right where the
figure's identity is named. The three metric columns stack
vertically (no more horizontal-only row) which leaves the right
third of the row free for the componentSummary lines — the
information a reviewer was always asking for ('which Komponente
is the bottleneck here?') is now visible without opening the
detail page.

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

225 lines
6.7 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/reclaim_card.dart';
import '../widgets/tier_badge.dart';
import 'norm_detail_page.dart';
class NormsListPage extends StatelessWidget {
const NormsListPage({super.key, required this.repository});
final EvaluationRepository repository;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Column(
children: [
const DemoBanner(),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(ReclaimSpace.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Normen-Pilotkorpus', style: t.displaySmall),
const SizedBox(height: ReclaimSpace.xs),
Text(
'5 Normen für den vertikalen Durchstich. '
'Korpus erweiterbar in Phase 1.',
style: t.bodyLarge?.copyWith(
color: ReclaimColors.mute,
),
),
const SizedBox(height: ReclaimSpace.lg),
FutureBuilder<List<Evaluation>>(
future: repository.list(),
builder: (context, snap) {
if (!snap.hasData) {
return const Center(
child: CircularProgressIndicator());
}
return Column(
children: [
for (final e in snap.data!)
Padding(
padding: const EdgeInsets.only(
bottom: ReclaimSpace.md),
child: _NormRow(
evaluation: e,
repository: repository,
),
),
],
);
},
),
],
),
),
),
],
);
}
}
class _NormRow extends StatelessWidget {
const _NormRow({required this.evaluation, required this.repository});
final Evaluation evaluation;
final EvaluationRepository repository;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NormDetailPage(
repository: repository,
evaluation: evaluation,
),
),
);
},
child: ReclaimCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${evaluation.norm.jurabk} ${evaluation.norm.paragraph}${evaluation.norm.title}',
style: t.titleMedium,
),
const SizedBox(height: 4),
Text(
evaluation.norm.eli,
style: t.labelSmall?.copyWith(
fontFamily: ReclaimTypography.mono,
color: ReclaimColors.mute,
),
),
const SizedBox(height: ReclaimSpace.sm),
TierBadge(tier: evaluation.tierLowest),
],
),
),
const SizedBox(width: ReclaimSpace.lg),
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MetricColumn(
label: 'Schaden / Jahr',
value: _eur(evaluation.skmEurPerYear),
),
const SizedBox(height: ReclaimSpace.sm),
_MetricColumn(
label: 'Nutzen',
value:
'${evaluation.benefitScore.toStringAsFixed(1)} / 5',
),
const SizedBox(height: ReclaimSpace.sm),
_MetricColumn(
label: 'Betroffene',
value: _kmu(evaluation.affectedCount),
),
],
),
),
const SizedBox(width: ReclaimSpace.lg),
Expanded(
flex: 3,
child: _ComponentSummary(
summary: evaluation.componentSummary,
),
),
],
),
),
);
}
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)}';
}
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 _ComponentSummary extends StatelessWidget {
const _ComponentSummary({required this.summary});
final List<String> summary;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
if (summary.isEmpty) {
return Text(
'',
style: t.labelSmall?.copyWith(color: ReclaimColors.mute),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Komponenten',
style: t.labelSmall?.copyWith(color: ReclaimColors.mute),
),
const SizedBox(height: 4),
for (final line in summary)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
line,
style: t.bodyLarge?.copyWith(
height: 1.35,
fontSize: 12.5,
),
),
),
],
);
}
}
class _MetricColumn extends StatelessWidget {
const _MetricColumn({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: t.labelSmall?.copyWith(color: ReclaimColors.mute),
),
const SizedBox(height: 2),
Text(value, style: t.titleMedium),
],
);
}
}