feat: wire chain_client_sdk gRPC client + HubRepository scaffold

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 <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-18 11:45:48 +02:00
parent fa8acd41b8
commit 39733628b6
6 changed files with 509 additions and 31 deletions

View file

@ -0,0 +1,83 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Persisted user settings for the ChIn 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<HubSettings> 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<void> 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,
);
}

View file

@ -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 ChIn 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<HubRepository> 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<Evaluation>> 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<Evaluation?> byEli(String eli) async => null;
@override
String get mode => _healthy ? 'hub-live' : 'hub-down';
@override
String get hubLabel => '${settings.url}'
'${_healthy ? '' : ' — Verbindung gestört'}';
}

View file

@ -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<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();
}
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),
);
}
}

View file

@ -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<HubStatusPage> createState() => _HubStatusPageState();
}
class _HubStatusPageState extends State<HubStatusPage> {
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<void> _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<void> _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)),