diff --git a/app/lib/main.dart b/app/lib/main.dart index 733c55c..83e9b52 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -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 _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 { diff --git a/app/lib/pages/heatmap_page.dart b/app/lib/pages/heatmap_page.dart index 75524c7..f5832ee 100644 --- a/app/lib/pages/heatmap_page.dart +++ b/app/lib/pages/heatmap_page.dart @@ -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 { 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: diff --git a/app/lib/pages/norm_detail_page.dart b/app/lib/pages/norm_detail_page.dart index 0185ab5..c0ff4ca 100644 --- a/app/lib/pages/norm_detail_page.dart +++ b/app/lib/pages/norm_detail_page.dart @@ -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), diff --git a/app/lib/pages/norms_list_page.dart b/app/lib/pages/norms_list_page.dart index d6156a4..2d1ff3e 100644 --- a/app/lib/pages/norms_list_page.dart +++ b/app/lib/pages/norms_list_page.dart @@ -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), diff --git a/app/lib/widgets/demo_banner.dart b/app/lib/widgets/demo_banner.dart deleted file mode 100644 index e2cfc49..0000000 --- a/app/lib/widgets/demo_banner.dart +++ /dev/null @@ -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, - ), - ), - ), - ], - ), - ); - } -} diff --git a/app/lib/widgets/mode_banner.dart b/app/lib/widgets/mode_banner.dart new file mode 100644 index 0000000..1a187fb --- /dev/null +++ b/app/lib/widgets/mode_banner.dart @@ -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() async => const []; + + @override + Future byEli(String eli) async => null; + + @override + String get mode => 'demo'; + + @override + String get hubLabel => '—'; +}