From c6400302dd44dad3bb7e11eb65b534a6754158bd Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 18 Jun 2026 15:06:13 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(brand):=20BrandWordmark=20renders=20?= =?UTF-8?q?=E2=88=86I=20in=20petrol,=20lifts=20the=20logo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated BrandWordmark widget that splits 'Recl∆Im' into three TextSpans and tints the middle ∆I in the petrol signal colour. The fai_web universe uses this same accent rule for the F∆I and Ch∆In glyphs — Recl∆Im now follows it, so the brand stays visually coherent across every F∆I surface. Where it lands: - Landing page: large DeltaMark (96 px) and BrandWordmark (56 px) sit on a single row, the wordmark to the right of the mark. The two together carry the brand identity that a viewer sees first when opening the app. - NavigationRail leading: the muted 'Recl∆Im' Text line is replaced with the petrol-accented BrandWordmark (18 px), keeping the vendor tag 'F∆I' as a small muted line under it. widget_test.dart adapts: the literal 'Recl∆Im'-find no longer matches because the wordmark is now three text spans, so the test checks for BrandWordmark by type — equivalent assertion, robust to glyph-level splits. Signed-off-by: flemming-it --- app/lib/pages/landing_page.dart | 18 +++++++--- app/lib/pages/shell_page.dart | 10 ++---- app/lib/widgets/brand_wordmark.dart | 55 +++++++++++++++++++++++++++++ app/test/widget_test.dart | 3 +- 4 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 app/lib/widgets/brand_wordmark.dart diff --git a/app/lib/pages/landing_page.dart b/app/lib/pages/landing_page.dart index 0f19bac..50fba50 100644 --- a/app/lib/pages/landing_page.dart +++ b/app/lib/pages/landing_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../data/repository.dart'; import '../theme/reclaim_theme.dart'; import '../theme/reclaim_tokens.dart'; +import '../widgets/brand_wordmark.dart'; import '../widgets/delta_mark.dart'; import 'shell_page.dart'; @@ -20,7 +21,7 @@ class LandingPage extends StatelessWidget { body: SafeArea( child: Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 720), + constraints: const BoxConstraints(maxWidth: 820), child: Padding( padding: const EdgeInsets.symmetric( horizontal: ReclaimSpace.xxl, @@ -30,9 +31,18 @@ class LandingPage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const DeltaMark(size: 96), - const SizedBox(height: ReclaimSpace.xxl), - Text('Recl∆Im', style: t.displaySmall), + const Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + DeltaMark(size: 96), + SizedBox(width: ReclaimSpace.xl), + BrandWordmark( + size: 56, + letterSpacing: -1.0, + ), + ], + ), + const SizedBox(height: ReclaimSpace.xl), const SizedBox(height: ReclaimSpace.sm), Text( 'Pflicht-basierte Wirkungsanalyse von Rechtsnormen — ' diff --git a/app/lib/pages/shell_page.dart b/app/lib/pages/shell_page.dart index 5cf3da4..2f7b51a 100644 --- a/app/lib/pages/shell_page.dart +++ b/app/lib/pages/shell_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../data/repository.dart'; import '../theme/reclaim_tokens.dart'; +import '../widgets/brand_wordmark.dart'; import '../widgets/delta_mark.dart'; import 'about_page.dart'; import 'heatmap_page.dart'; @@ -51,14 +52,7 @@ class _ShellPageState extends State { children: [ const DeltaMark(size: 36), const SizedBox(height: ReclaimSpace.sm), - Text( - 'Recl∆Im', - style: t.labelLarge?.copyWith( - letterSpacing: 1.5, - color: ReclaimColors.signal, - fontWeight: FontWeight.w600, - ), - ), + const BrandWordmark(size: 18), Text( 'F∆I', style: t.labelSmall?.copyWith( diff --git a/app/lib/widgets/brand_wordmark.dart b/app/lib/widgets/brand_wordmark.dart new file mode 100644 index 0000000..14d6783 --- /dev/null +++ b/app/lib/widgets/brand_wordmark.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import '../theme/reclaim_tokens.dart'; + +/// "Recl∆Im" wordmark with the ∆I glyphs petrol-coloured — the +/// brand rule of thumb in the fai_web universe: wherever the name +/// stands as a brand, the ∆I tinted petrol carries the F∆I-family +/// identity. As plain text it would all share the surrounding ink +/// colour. +/// +/// Pass [size] for the base font size; the ∆I scales relative. +class BrandWordmark extends StatelessWidget { + const BrandWordmark({ + super.key, + this.size = 24, + this.fontWeight = FontWeight.w600, + this.color, + this.letterSpacing = -0.5, + }); + + final double size; + final FontWeight fontWeight; + + /// Color of the non-∆I letters. Defaults to the surface's + /// onSurface so the wordmark reads against whichever ink/paper + /// backdrop it sits on. + final Color? color; + final double letterSpacing; + + @override + Widget build(BuildContext context) { + final base = color ?? Theme.of(context).colorScheme.onSurface; + final style = TextStyle( + fontSize: size, + fontWeight: fontWeight, + fontFamily: ReclaimTypography.display, + color: base, + letterSpacing: letterSpacing, + height: 1.0, + ); + return RichText( + text: TextSpan( + style: style, + children: [ + const TextSpan(text: 'Recl'), + TextSpan( + text: '∆I', + style: style.copyWith(color: ReclaimColors.signal), + ), + const TextSpan(text: 'm'), + ], + ), + ); + } +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index eb83a95..30b5b41 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:reclaim_app/main.dart'; +import 'package:reclaim_app/widgets/brand_wordmark.dart'; void main() { // Generous surface so NavigationRail + Heatmap fit without @@ -20,7 +21,7 @@ void main() { await tester.binding.setSurfaceSize(surfaceSize); await tester.pumpWidget(const ReclaimApp()); await tester.pump(); - expect(find.text('Recl∆Im'), findsOneWidget); + expect(find.byType(BrandWordmark), findsWidgets); expect( find.widgetWithText(FilledButton, 'App öffnen (Demo-Modus)'), findsOneWidget, From ae9fc45177f483894628a0e4ca6de7c2f86a1a2f Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 18 Jun 2026 15:06:33 +0200 Subject: [PATCH 2/2] feat(ui): show SKM component-tier summary on the Normen list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/lib/data/fixtures.dart | 33 +++++++++++++ app/lib/data/models.dart | 7 +++ app/lib/pages/norms_list_page.dart | 79 ++++++++++++++++++++++++------ 3 files changed, 104 insertions(+), 15 deletions(-) diff --git a/app/lib/data/fixtures.dart b/app/lib/data/fixtures.dart index 3afdea3..abf5685 100644 --- a/app/lib/data/fixtures.dart +++ b/app/lib/data/fixtures.dart @@ -90,6 +90,12 @@ class Fixtures { 'T (Tarif) T1 — DESTATIS Verdiensterhebung kaufm. Tätigkeit ~30 €/h; ' 'h (Zeitaufwand) T3 — eigene Schätzung ~30 min, NKR-Validierung ausstehend. ' 'Regel der niedrigsten Stufe: Gesamt → T3 (in der Demo konservativ als T4 markiert).', + componentSummary: const [ + 'P · T1 — DESTATIS Gewerbeanzeigen', + 'F · T1 — anlassbezogen, je Vorgang', + 'T · T1 — DESTATIS Verdiensterhebung', + 'h · T3 — eigene Schätzung ~30 min', + ], ); static final _kassenSichV = Evaluation( @@ -177,6 +183,12 @@ class Fixtures { 'Laufende Folgekosten (TSE-Ausfallmeldungen, Hardware-Erneuerung): T2 ' 'aus DIHK-Bürokratie-Index + DEHOGA-Gastronomie-Stichprobe. ' 'Hoher Frust-Score wegen Pluralität TSE-Hersteller × Finanzamt-Vollzug.', + componentSummary: const [ + 'P · T1 — Kassen-pflichtige Betriebe', + 'F · T1 — kontinuierlich + Ausfälle', + 'T · T2 — DIHK Bürokratie-Index', + 'h · T2 — DEHOGA-Stichprobe', + ], ); static final _dsgvoArt30 = Evaluation( @@ -272,6 +284,13 @@ class Fixtures { 'pro Unternehmen liegt im Streit zwischen BfDI-Position (gering, weil ' '<250-MA-Ausnahme) und DIHK-Position (hoch, weil 5-Abs.-Ausnahme ' 'praktisch nicht greift). Tier T2 für Magnitude.', + componentSummary: const [ + 'P · T1 — DESTATIS Unternehmensregister', + 'F · T1 — jährlich überprüfen', + 'T · T2 — DIHK-Position', + 'h · T2 — Streit BfDI ↔ DIHK', + 'EU-determiniert · Reform-Hebel Brüssel', + ], ); static final _berlinBauO60 = Evaluation( @@ -351,6 +370,13 @@ class Fixtures { '→ Genehmigung sind im Land getrennt geregelt). Höchster Frust-Score ' 'aus Verweistiefe + Behörden-Pluralität (Bezirksamt × SenStadt × ' 'Beteiligungsverfahren).', + componentSummary: const [ + 'P · T2 — Handwerkskammer Berlin', + 'F · T1 — pro Bauvorhaben', + 'T · T2 — Bauwirtschaft Berlin', + 'h · T3 — Verfahren mehrstufig', + 'Frust · T2 — Bezirk × Senat × Beteiligung', + ], ); static final _miLoG17 = Evaluation( @@ -428,5 +454,12 @@ class Fixtures { 'dokumentierte den initial-Erfüllungsaufwand; aktuelle Zahlen ' 'aus ZDH Bürokratiekosten-Handwerk. Bürokratieentlastungs-' 'gesetz IV adressierte den § 17 — Reform-Bewegung ist im Lauf.', + componentSummary: const [ + 'P · T1 — DESTATIS MiLoG-pflichtig', + 'F · T1 — arbeitstäglich', + 'T · T1 — DESTATIS Tariflohn', + 'h · T1 — NKR-Stellungnahme 2014', + 'Reform · BEG IV in Lauf', + ], ); } diff --git a/app/lib/data/models.dart b/app/lib/data/models.dart index ae7caf2..d75f950 100644 --- a/app/lib/data/models.dart +++ b/app/lib/data/models.dart @@ -142,6 +142,7 @@ class Evaluation { required this.sources, required this.auditEventId, this.notes, + this.componentSummary = const [], }); final Norm norm; @@ -172,4 +173,10 @@ class Evaluation { final String auditEventId; final String? notes; + + /// Compact lines for at-a-glance reading on list views. + /// One line per SKM-Komponente (P / F / T / h) plus optional + /// extras — each carries its tier-tag inline. Empty list ⇒ + /// fall back to the notes field for the list view. + final List componentSummary; } diff --git a/app/lib/pages/norms_list_page.dart b/app/lib/pages/norms_list_page.dart index 189a1fb..d6156a4 100644 --- a/app/lib/pages/norms_list_page.dart +++ b/app/lib/pages/norms_list_page.dart @@ -107,31 +107,42 @@ class _NormRow extends StatelessWidget { color: ReclaimColors.mute, ), ), + const SizedBox(height: ReclaimSpace.sm), + TierBadge(tier: evaluation.tierLowest), ], ), ), const SizedBox(width: ReclaimSpace.lg), Expanded( - child: _MetricColumn( - label: 'Schaden / Jahr', - value: _eur(evaluation.skmEurPerYear), + 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( - child: _MetricColumn( - label: 'Nutzen', - value: - '${evaluation.benefitScore.toStringAsFixed(1)} / 5', + flex: 3, + child: _ComponentSummary( + summary: evaluation.componentSummary, ), ), - Expanded( - child: _MetricColumn( - label: 'Betroffene', - value: _kmu(evaluation.affectedCount), - ), - ), - const SizedBox(width: ReclaimSpace.md), - TierBadge(tier: evaluation.tierLowest), ], ), ), @@ -152,6 +163,44 @@ class _NormRow extends StatelessWidget { } } +class _ComponentSummary extends StatelessWidget { + const _ComponentSummary({required this.summary}); + + final List 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});