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>
This commit is contained in:
flemming-it 2026-06-18 15:57:50 +02:00
parent e6e9c0753d
commit 51beb332c4
6 changed files with 138 additions and 67 deletions

View file

@ -16,16 +16,18 @@ void main() async {
/// Decide which repository to start with.
///
/// useHub=false Mock, no probe (fast offline start)
/// useHub=true, healthy HubRepository
/// useHub=true, NOT ok MockRepository (graceful fallback;
/// UI banner shows hub-down so the
/// user can re-try from settings)
/// useHub=false MockRepository, explicit opt-in to
/// demo data via the Hub-Reiter.
/// useHub=true HubRepository regardless of probe
/// outcome. When unhealthy the
/// repository's list() returns [] and
/// the ModeBanner shows 'hub-down'
/// no silent mock fallback that would
/// quietly demote the app to demo
/// mode.
Future<EvaluationRepository> _pickRepository(HubSettings settings) async {
if (!settings.useHub) return const MockRepository();
final hub = await HubRepository.connect(settings);
if (hub.isHealthy) return hub;
return const MockRepository();
return HubRepository.connect(settings);
}
class ReclaimApp extends StatelessWidget {

View file

@ -3,7 +3,7 @@ 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/mode_banner.dart';
import '../widgets/heatmap_grid.dart';
import '../widgets/reclaim_card.dart';
import '../widgets/tier_badge.dart';
@ -32,7 +32,7 @@ class _HeatmapPageState extends State<HeatmapPage> {
final t = Theme.of(context).textTheme;
return Column(
children: [
const DemoBanner(),
ModeBanner(repository: widget.repository),
Expanded(
child: Padding(
padding: const EdgeInsets.all(ReclaimSpace.xl),
@ -190,8 +190,8 @@ class _LegendCardState extends State<_LegendCard> {
spacing: ReclaimSpace.lg,
runSpacing: ReclaimSpace.xs,
children: [
_MiniLabel(symbol: '', text: 'Schaden'),
_MiniLabel(symbol: '', text: 'Nutzen'),
_MiniLabel(symbol: '', text: 'Schaden'),
_MiniLabel(symbol: '', text: 'Nutzen'),
_MiniLabel(
symbol: '', text: 'Betroffenheit'),
_MiniLabel(
@ -219,7 +219,7 @@ class _LegendCardState extends State<_LegendCard> {
CrossAxisAlignment.start,
children: [
_LegendRow(
symbol: '',
symbol: '',
axis: 'Abszisse (X)',
label: 'Schaden',
description:
@ -231,7 +231,7 @@ class _LegendCardState extends State<_LegendCard> {
),
SizedBox(height: ReclaimSpace.sm),
_LegendRow(
symbol: '',
symbol: '',
axis: 'Ordinate (Y)',
label: 'Nutzen',
description:

View file

@ -3,7 +3,7 @@ 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/mode_banner.dart';
import '../widgets/evidence_sidebar.dart';
import '../widgets/norm_text_card.dart';
import '../widgets/reclaim_card.dart';
@ -29,7 +29,7 @@ class NormDetailPage extends StatelessWidget {
),
body: Column(
children: [
const DemoBanner(),
ModeBanner(repository: repository),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(ReclaimSpace.xl),

View file

@ -3,7 +3,7 @@ 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/mode_banner.dart';
import '../widgets/reclaim_card.dart';
import '../widgets/tier_badge.dart';
import 'norm_detail_page.dart';
@ -18,7 +18,7 @@ class NormsListPage extends StatelessWidget {
final t = Theme.of(context).textTheme;
return Column(
children: [
const DemoBanner(),
ModeBanner(repository: repository),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(ReclaimSpace.xl),

View file

@ -1,49 +0,0 @@
import 'package:flutter/material.dart';
import '../theme/reclaim_tokens.dart';
/// Permanent banner shown above all data screens in mock mode.
/// Carries the political-defensibility caveat: this is demo data,
/// not peer-reviewed, not Juristen-approved.
class DemoBanner extends StatelessWidget {
const DemoBanner({super.key});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: ReclaimSpace.lg,
vertical: ReclaimSpace.sm,
),
decoration: BoxDecoration(
color: ReclaimColors.tierT4.withValues(alpha: 0.12),
border: Border(
bottom: BorderSide(
color: ReclaimColors.tierT4.withValues(alpha: 0.55),
),
),
),
child: Row(
children: [
const Icon(Icons.science_outlined,
size: 18, color: ReclaimColors.tierT4),
const SizedBox(width: ReclaimSpace.sm),
Expanded(
child: Text(
'Demo-Daten · KEINE Rechtsberatung. '
'Alle Zahlen sind als T4 markiert (qualitatives Signal), '
'bis NKR/Verbands-Pipeline + Juristen-Approval laufen. '
'Erläuterung der Stufen unter „Methodik", '
'Disclaimer unter „Recht".',
style: t.labelSmall?.copyWith(
color: ReclaimColors.tierT4,
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../data/repository.dart';
import '../theme/reclaim_tokens.dart';
/// Status banner above the data screens.
///
/// Picks one of three voices by the active repository's mode:
///
/// demo orange T4: 'Demo-Daten · keine Rechtsberatung'
/// (MockRepository on, Beirat + Juristen-Pipeline
/// still missing)
///
/// hub-live petrol-signal: 'Live aus Hub' + hub label
/// (HubRepository connected, list() reads real
/// flow.completed events)
///
/// hub-down warm-warning: 'Hub nicht erreichbar — leere
/// Liste, prüfe Hub-Tab'
/// (HubRepository.connect timed out; fallback to
/// empty list rather than mocked data)
class ModeBanner extends StatelessWidget {
const ModeBanner({super.key, required this.repository});
final EvaluationRepository repository;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final (color, icon, message) = switch (repository.mode) {
'hub-live' => (
ReclaimColors.signal,
Icons.bolt_outlined,
'Live aus Hub · ${repository.hubLabel}. '
'Werte stammen aus den flow.completed-Events des '
'Ch∆In-Audit-Logs. Volljuristen-Approval-Kette ist '
'in Phase 0 noch Selbst-Review — siehe „Recht".',
),
'hub-down' => (
ReclaimColors.harmWarm,
Icons.signal_wifi_off,
'Hub nicht erreichbar — leere Liste. Konfiguration prüfen '
'unter „Hub". Bis dahin: kein Recl∆Im-Datenpunkt '
'sichtbar.',
),
_ => (
ReclaimColors.tierT4,
Icons.science_outlined,
'Demo-Daten · KEINE Rechtsberatung. Alle Zahlen sind als '
'T4 markiert (qualitatives Signal), bis NKR-/Verbands-'
'Pipeline + Juristen-Approval laufen. Erläuterung der '
'Stufen unter „Methodik", Disclaimer unter „Recht".',
),
};
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: ReclaimSpace.lg,
vertical: ReclaimSpace.sm,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
border: Border(
bottom: BorderSide(color: color.withValues(alpha: 0.55)),
),
),
child: Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: ReclaimSpace.sm),
Expanded(
child: Text(
message,
style: t.labelSmall?.copyWith(color: color),
),
),
],
),
);
}
}
/// Backwards-compat shim for pages that still import DemoBanner.
/// Resolves to the dynamic ModeBanner if a repository is passed,
/// otherwise stays demo-flavoured.
class DemoBanner extends StatelessWidget {
const DemoBanner({super.key, this.repository});
final EvaluationRepository? repository;
@override
Widget build(BuildContext context) {
final repo = repository;
if (repo != null) return ModeBanner(repository: repo);
return const ModeBanner(repository: _DemoMockRepository());
}
}
/// Internal sentinel so the bare-form DemoBanner() still renders
/// the orange demo voice even when no repository is plumbed in.
class _DemoMockRepository implements EvaluationRepository {
const _DemoMockRepository();
@override
Future<List<Evaluation>> list() async => const [];
@override
Future<Evaluation?> byEli(String eli) async => null;
@override
String get mode => 'demo';
@override
String get hubLabel => '';
}