From 39733628b6932b442c8fbd69104b8406c99204e9 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 18 Jun 2026 11:45:48 +0200 Subject: [PATCH] feat: wire chain_client_sdk gRPC client + HubRepository scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a real Ch∆In hub backend to the Flutter client as a sibling-path dependency on chain_client_sdk_dart, alongside the existing MockRepository (which stays the default until the flow.completed-to-Evaluation pipeline lands in Phase 0 week 1). Wiring HubSettings (lib/data/hub_endpoint.dart) — persists host / port / secure / token / useHub flags via SharedPreferences under the Studio-compatible hub.* keys. Distinct from the SDK's chain.HubEndpoint, which is a transport-level value type — HubSettings adapts in that direction. HubRepository (lib/data/hub_repository.dart) — implements EvaluationRepository on top of HubClient. connect() runs a 4-second healthy() probe; on failure returns a repository with isHealthy=false rather than throwing, so the UI can surface "hub-down" without an exception-handling round-trip. list() / byEli() return empty data in Phase 0 because no hub-side flow yet produces Evaluation objects in the lawheatmap-specific shape. main.dart — loads HubSettings on startup; if useHub=false starts with MockRepository (fast offline default), otherwise probes the hub and falls back to MockRepository on probe failure. No mid-session swap to keep the Phase 0 wiring simple; a settings save instructs the user to restart. HubStatusPage — read-only summary card at the top, editable endpoint form, "Verbindung testen" probe button, "Speichern" persist button. Live-flow runbook below describes the chain serve / install / run / approve sequence so a user can wire the real flow themselves once Phase 1 lands. flutter analyze: clean. flutter test: 2/2 passing. Smoke-launch on macOS: app boots, Dart VM service comes up, no runtime errors in mock mode. Signed-off-by: flemming-it --- app/lib/data/hub_endpoint.dart | 83 ++++++++++ app/lib/data/hub_repository.dart | 74 +++++++++ app/lib/main.dart | 38 ++++- app/lib/pages/hub_status_page.dart | 239 ++++++++++++++++++++++++++--- app/pubspec.lock | 95 ++++++++++++ app/pubspec.yaml | 11 +- 6 files changed, 509 insertions(+), 31 deletions(-) create mode 100644 app/lib/data/hub_endpoint.dart create mode 100644 app/lib/data/hub_repository.dart diff --git a/app/lib/data/hub_endpoint.dart b/app/lib/data/hub_endpoint.dart new file mode 100644 index 0000000..b20f433 --- /dev/null +++ b/app/lib/data/hub_endpoint.dart @@ -0,0 +1,83 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Persisted user settings for the Ch∆In hub connection. +/// +/// Stored under four SharedPreferences keys matching the Studio +/// convention (`hub.host`, `hub.port`, `hub.secure`, `hub.token`) +/// so a hub once configured stays configured across app launches. +/// Distinct from the SDK's `chain.HubEndpoint`, which is a +/// transport-level value type — [HubSettings.toEndpoint] adapts +/// in that direction. +class HubSettings { + const HubSettings({ + required this.host, + required this.port, + required this.secure, + this.authToken, + this.useHub = false, + }); + + final String host; + final int port; + final bool secure; + + /// Bearer token; read from `~/.chain/hub-auth-token` by the + /// hub-status page in Phase 1. Optional in Phase 0 because the + /// default `chain serve` runs without auth. + final String? authToken; + + /// User intent: try to connect to the hub on startup. False + /// keeps the app in MockRepository mode regardless of host/port. + final bool useHub; + + static const defaultHost = '127.0.0.1'; + static const defaultPort = 50051; + + String get scheme => secure ? 'https' : 'http'; + String get url => '$scheme://$host:$port'; + + static const _kHost = 'hub.host'; + static const _kPort = 'hub.port'; + static const _kSecure = 'hub.secure'; + static const _kToken = 'hub.token'; + static const _kUseHub = 'hub.use'; + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return HubSettings( + host: prefs.getString(_kHost) ?? defaultHost, + port: prefs.getInt(_kPort) ?? defaultPort, + secure: prefs.getBool(_kSecure) ?? false, + authToken: prefs.getString(_kToken), + useHub: prefs.getBool(_kUseHub) ?? false, + ); + } + + Future save() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kHost, host); + await prefs.setInt(_kPort, port); + await prefs.setBool(_kSecure, secure); + await prefs.setBool(_kUseHub, useHub); + if (authToken != null) { + await prefs.setString(_kToken, authToken!); + } else { + await prefs.remove(_kToken); + } + } + + HubSettings copyWith({ + String? host, + int? port, + bool? secure, + String? authToken, + bool? useHub, + }) => + HubSettings( + host: host ?? this.host, + port: port ?? this.port, + secure: secure ?? this.secure, + authToken: authToken ?? this.authToken, + useHub: useHub ?? this.useHub, + ); +} diff --git a/app/lib/data/hub_repository.dart b/app/lib/data/hub_repository.dart new file mode 100644 index 0000000..4d15458 --- /dev/null +++ b/app/lib/data/hub_repository.dart @@ -0,0 +1,74 @@ +import 'package:chain_client_sdk/chain_client_sdk.dart' as chain; + +import 'hub_endpoint.dart'; +import 'models.dart'; +import 'repository.dart'; + +/// Live `EvaluationRepository` backed by a Ch∆In hub over gRPC. +/// +/// Phase 0 scope: open a HubClient, run a healthy() probe, and +/// surface the connection state to the UI. The list() / byEli() +/// methods return empty data because no flow on the hub yet +/// produces Evaluation objects in the lawheatmap-specific shape — +/// that pipeline lands in Phase 0 week 1 once a chain run wraps +/// flows/durchstich-gewo-14.yaml output into the Evaluation +/// schema. +/// +/// Until then [MockRepository] stays the default. Pick this +/// repository explicitly via the Hub-settings page; on first +/// healthy() failure the app falls back to MockRepository so the +/// UI keeps working. +class HubRepository implements EvaluationRepository { + HubRepository._(this._client, this.settings, this._healthy); + + final chain.HubClient _client; + final HubSettings settings; + final bool _healthy; + + /// Try to connect. Returns a repository with `isHealthy=false` + /// if the probe times out or the hub is unreachable; the + /// caller decides whether to use it or fall back to + /// MockRepository. + static Future connect(HubSettings settings) async { + final endpoint = chain.HubEndpoint( + host: settings.host, + port: settings.port, + secure: settings.secure, + ); + final client = chain.HubClient( + endpoint: endpoint, + authToken: settings.authToken, + ); + final ok = await client.healthy().timeout( + const Duration(seconds: 4), + onTimeout: () => false, + ); + return HubRepository._(client, settings, ok); + } + + bool get isHealthy => _healthy; + + /// Underlying SDK client — exposed so the hub-status page can + /// list capabilities, query the audit log, and verify the + /// event-chain without leaking gRPC types to other pages. + chain.HubClient get client => _client; + + @override + Future> list() async { + // Phase 0 placeholder. The pipeline that turns + // flow.completed events into Evaluation objects lives in the + // surrounding flow (flows/durchstich-gewo-14.yaml) and lands + // in Phase 0 week 1. + return const []; + } + + @override + Future byEli(String eli) async => null; + + @override + String get mode => _healthy ? 'hub-live' : 'hub-down'; + + @override + String get hubLabel => '${settings.url}' + '${_healthy ? '' : ' — Verbindung gestört'}'; +} diff --git a/app/lib/main.dart b/app/lib/main.dart index 3488de0..82db2aa 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,23 +1,45 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'data/hub_endpoint.dart'; +import 'data/hub_repository.dart'; import 'data/repository.dart'; import 'pages/landing_page.dart'; import 'theme/lawheatmap_theme.dart'; -void main() { - runApp(const LawHeatmapApp()); +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + final settings = await HubSettings.load(); + final repository = await _pickRepository(settings); + runApp(LawHeatmapApp(repository: repository, settings: settings)); +} + +/// 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) +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(); } class LawHeatmapApp extends StatelessWidget { - const LawHeatmapApp({super.key}); + const LawHeatmapApp({ + super.key, + this.repository = const MockRepository(), + this.settings, + }); + + final EvaluationRepository repository; + final HubSettings? settings; @override Widget build(BuildContext context) { - // Phase 0: in-memory fixtures only. Swap to a HubRepository - // (gRPC against `chain serve`) without changing the UI. - const EvaluationRepository repository = MockRepository(); - return MaterialApp( title: 'F∆I Law-Heatmap', debugShowCheckedModeBanner: false, @@ -30,7 +52,7 @@ class LawHeatmapApp extends StatelessWidget { GlobalCupertinoLocalizations.delegate, ], supportedLocales: const [Locale('de'), Locale('en')], - home: const LandingPage(repository: repository), + home: LandingPage(repository: repository), ); } } diff --git a/app/lib/pages/hub_status_page.dart b/app/lib/pages/hub_status_page.dart index 49a1c47..82454c8 100644 --- a/app/lib/pages/hub_status_page.dart +++ b/app/lib/pages/hub_status_page.dart @@ -1,17 +1,112 @@ import 'package:flutter/material.dart'; +import '../data/hub_endpoint.dart'; +import '../data/hub_repository.dart'; import '../data/repository.dart'; import '../theme/lawheatmap_tokens.dart'; import '../widgets/lawheatmap_card.dart'; -class HubStatusPage extends StatelessWidget { +/// Hub connection + settings. Read-only summary at the top, then +/// an editable form for endpoint host/port/secure/token, then a +/// "Verbinden / Test"-button. Changes are persisted via +/// HubSettings and take effect on the next app start (Phase 0 +/// keeps the running repository to avoid mid-session swap +/// headaches). +class HubStatusPage extends StatefulWidget { const HubStatusPage({super.key, required this.repository}); final EvaluationRepository repository; + @override + State createState() => _HubStatusPageState(); +} + +class _HubStatusPageState extends State { + HubSettings? _settings; + late final TextEditingController _hostCtl; + late final TextEditingController _portCtl; + late final TextEditingController _tokenCtl; + bool _secure = false; + bool _useHub = false; + String? _probeMessage; + bool _probing = false; + + @override + void initState() { + super.initState(); + _hostCtl = TextEditingController(); + _portCtl = TextEditingController(); + _tokenCtl = TextEditingController(); + HubSettings.load().then((s) { + setState(() { + _settings = s; + _hostCtl.text = s.host; + _portCtl.text = s.port.toString(); + _tokenCtl.text = s.authToken ?? ''; + _secure = s.secure; + _useHub = s.useHub; + }); + }); + } + + @override + void dispose() { + _hostCtl.dispose(); + _portCtl.dispose(); + _tokenCtl.dispose(); + super.dispose(); + } + + HubSettings _formSettings() => HubSettings( + host: _hostCtl.text.trim().isEmpty + ? HubSettings.defaultHost + : _hostCtl.text.trim(), + port: int.tryParse(_portCtl.text.trim()) ?? + HubSettings.defaultPort, + secure: _secure, + authToken: + _tokenCtl.text.trim().isEmpty ? null : _tokenCtl.text.trim(), + useHub: _useHub, + ); + + Future _save() async { + final s = _formSettings(); + await s.save(); + if (!mounted) return; + setState(() => _settings = s); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Einstellungen gespeichert. ' + 'Beim nächsten Start wird die App den Hub verwenden, ' + 'wenn der Schalter „Hub nutzen" aktiv ist.'), + ), + ); + } + + Future _probe() async { + setState(() { + _probing = true; + _probeMessage = null; + }); + try { + final hub = await HubRepository.connect(_formSettings()); + setState(() { + _probeMessage = hub.isHealthy + ? 'Verbindung erfolgreich (healthy=true).' + : 'Hub nicht erreichbar oder antwortet nicht SERVING.'; + }); + } catch (e) { + setState(() => _probeMessage = 'Fehler: $e'); + } finally { + if (mounted) setState(() => _probing = false); + } + } + @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; + final repo = widget.repository; + final s = _settings; return SingleChildScrollView( padding: const EdgeInsets.all(LawHeatmapSpace.xl), child: Column( @@ -20,7 +115,7 @@ class HubStatusPage extends StatelessWidget { Text('Ch∆In Hub', style: t.displaySmall), const SizedBox(height: LawHeatmapSpace.xs), Text( - 'Status der Verbindung zum lokalen Hub und des Audit-Logs.', + 'Status der Verbindung und Konfiguration des lokalen Hubs.', style: t.bodyLarge?.copyWith(color: LawHeatmapColors.mute), ), const SizedBox(height: LawHeatmapSpace.lg), @@ -29,10 +124,19 @@ class HubStatusPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _row('Modus', repository.mode.toUpperCase(), t), - _row('Quelle', repository.hubLabel, t), - _row('gRPC-Endpunkt', '— (Phase-0-Stub)', t), - _row('Audit-DB', '~/.chain/chain.db (geplant)', t), + _row('Aktiver Modus', repo.mode.toUpperCase(), t), + _row('Datenquelle', repo.hubLabel, t), + _row( + 'gRPC-Endpunkt', + s == null ? '…' : s.url, + t, + ), + _row( + 'Hub-Nutzung beim Start', + s == null ? '…' : (s.useHub ? 'ja' : 'nein'), + t, + ), + _row('Audit-DB', '~/.chain/chain.db (Hub-seitig)', t), _row('Hash-Tag', 'CHAIN-EVENT-V3', t), ], ), @@ -42,21 +146,120 @@ class HubStatusPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Geplante Live-Anbindung', style: t.headlineSmall), + Text('Verbindung konfigurieren', + style: t.headlineSmall), const SizedBox(height: LawHeatmapSpace.sm), Text( - '1. `chain serve` startet lokal auf Port 50051.\n' - '2. Diese App liest `~/.chain/hub-auth-token` und ' - 'verbindet via gRPC.\n' - '3. Capability `text.akoma_normalize@^0` muss ' - 'installiert sein (`chain install text.akoma_normalize`).\n' - '4. Pilot-Flow `flows/durchstich-gewo-14.yaml` läuft ' - 'gegen GewO §14.\n' - '5. Jedes step.completed-Event landet im Audit-Log; ' - 'die Repository-Implementierung tauscht von MockRepository ' - 'auf HubRepository (gleiches Interface, kein UI-Wechsel).', + '`chain serve` läuft standardmäßig auf ' + '127.0.0.1:50051 ohne Auth. ' + 'Bei aktiver Auth den Bearer-Token aus ' + '~/.chain/hub-auth-token hier eintragen.', + style: t.labelSmall + ?.copyWith(color: LawHeatmapColors.mute), + ), + const SizedBox(height: LawHeatmapSpace.lg), + Row(children: [ + Expanded( + flex: 3, + child: TextField( + controller: _hostCtl, + decoration: const InputDecoration( + labelText: 'Host', + hintText: '127.0.0.1', + ), + ), + ), + const SizedBox(width: LawHeatmapSpace.md), + Expanded( + child: TextField( + controller: _portCtl, + decoration: const InputDecoration( + labelText: 'Port', + hintText: '50051', + ), + keyboardType: TextInputType.number, + ), + ), + ]), + const SizedBox(height: LawHeatmapSpace.md), + TextField( + controller: _tokenCtl, + decoration: const InputDecoration( + labelText: 'Auth-Token (optional)', + ), + obscureText: true, + ), + const SizedBox(height: LawHeatmapSpace.md), + Row(children: [ + Switch( + value: _secure, + onChanged: (v) => setState(() => _secure = v), + ), + const SizedBox(width: LawHeatmapSpace.sm), + const Text('TLS (https)'), + const SizedBox(width: LawHeatmapSpace.xl), + Switch( + value: _useHub, + onChanged: (v) => setState(() => _useHub = v), + ), + const SizedBox(width: LawHeatmapSpace.sm), + const Text('Hub beim Start nutzen'), + ]), + const SizedBox(height: LawHeatmapSpace.lg), + Row(children: [ + FilledButton.icon( + onPressed: _probing ? null : _probe, + icon: _probing + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.bolt_outlined), + label: const Text('Verbindung testen'), + ), + const SizedBox(width: LawHeatmapSpace.md), + OutlinedButton.icon( + onPressed: _save, + icon: const Icon(Icons.save_outlined), + label: const Text('Speichern'), + ), + ]), + if (_probeMessage != null) ...[ + const SizedBox(height: LawHeatmapSpace.md), + Text(_probeMessage!, + style: t.labelLarge?.copyWith( + color: LawHeatmapColors.signal, + )), + ], + ], + ), + ), + const SizedBox(height: LawHeatmapSpace.lg), + LawHeatmapCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Geplanter Live-Flow', style: t.headlineSmall), + const SizedBox(height: LawHeatmapSpace.sm), + Text( + '1. `chain serve` auf localhost:50051 starten.\n' + '2. Module installieren (`chain install`).\n' + '3. Flow ausführen:\n' + ' chain run flows/durchstich-gewo-14.yaml \\\n' + ' --input norm_url=https://www.gesetze-im-internet.de/gewo/__14.xml \\\n' + ' --input cohort_id=berlin-kmu\n' + '4. `chain admin approvals list` → ' + 'Juristen-Gate freigeben.\n' + '5. App neu starten — Hub-Modus aktiv, ' + 'flow.completed-Events werden in ' + 'Evaluation-Objekte übersetzt.', style: t.bodyLarge?.copyWith( height: 1.6, + fontFamily: LawHeatmapTypography.mono, + fontSize: 12, ), ), ], @@ -73,7 +276,7 @@ class HubStatusPage extends StatelessWidget { child: Row( children: [ SizedBox( - width: 160, + width: 200, child: Text(label, style: t.labelLarge ?.copyWith(color: LawHeatmapColors.mute)), diff --git a/app/pubspec.lock b/app/pubspec.lock index 9c03ace..5cb87e4 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -17,6 +17,13 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + chain_client_sdk: + dependency: "direct main" + description: + path: "../../fai_chain_client_sdk_dart" + relative: true + source: path + version: "0.18.0" characters: dependency: transitive description: @@ -41,6 +48,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" fake_async: dependency: transitive description: @@ -65,6 +80,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -93,6 +116,62 @@ packages: description: flutter source: sdk version: "0.0.0" + google_cloud: + dependency: transitive + description: + name: google_cloud + sha256: b385e20726ef5315d302c5933bfb728103116c5be2d3d17094b01a82da538c1f + url: "https://pub.dev" + source: hosted + version: "0.5.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + googleapis_auth: + dependency: transitive + description: + name: googleapis_auth + sha256: "1417d8846663df5e7b77ca56591c5edd442c66ffc9c01ab036e138a21a148e86" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + grpc: + dependency: transitive + description: + name: grpc + sha256: "86be3a7d39ad865b214a7370021ac80e68939238b507730de6d97fc662cb2723" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http2: + dependency: transitive + description: + name: http2 + sha256: "382d3aefc5bd6dc68c6b892d7664f29b5beb3251611ae946a98d35158a82bbfa" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" intl: dependency: "direct main" description: @@ -205,6 +284,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "75ec242d22e950bdcc79ee38dd520ce4ee0bc491d7fadc4ea47694604d22bf06" + url: "https://pub.dev" + source: hosted + version: "6.0.0" shared_preferences: dependency: "direct main" description: @@ -314,6 +401,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e9683e8..0eb45b5 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: 'none' version: 0.1.0+1 environment: - sdk: ">=3.6.0 <4.0.0" + sdk: ">=3.11.0 <4.0.0" flutter: ">=3.24.0" dependencies: @@ -15,10 +15,11 @@ dependencies: intl: ^0.20.2 shared_preferences: ^2.3.0 - # Ch∆In wire — same sibling-path pattern as fai_chain_studio. - # Uncomment once the gRPC integration starts (Phase 0, week 1+). - # chain_client_sdk: - # path: ../../fai_chain_client_sdk_dart + # Ch∆In gRPC wire — sibling-path dep, same pattern as + # fai_chain_studio. Adapt the chain_client_sdk_dart relative + # path when this repo is cloned outside the fai/ workspace. + chain_client_sdk: + path: ../../fai_chain_client_sdk_dart dev_dependencies: flutter_test: