diff --git a/lib/data/hub.dart b/lib/data/hub.dart new file mode 100644 index 0000000..964b584 --- /dev/null +++ b/lib/data/hub.dart @@ -0,0 +1,156 @@ +// HubService — singleton wrapper around fai_dart_sdk's HubClient. +// +// Centralises the gRPC connection so each page just imports +// `HubService.instance` instead of constructing its own client. +// Methods return UI-friendly types so pages stay free of +// protobuf imports. + +import 'package:fai_dart_sdk/fai_dart_sdk.dart'; + +class HubService { + HubService._(); + static final HubService instance = HubService._(); + + HubClient _client = HubClient(); + + /// The endpoint string ("http://host:port") the connection + /// targets. Shown in the navigation rail. + String get endpointLabel => _client.endpoint.toString(); + + /// Reconnect to a new endpoint. Useful if the user changes + /// the hub URL at runtime (Phase 1+). + Future reconnect(HubEndpoint endpoint) async { + await _client.close(); + _client = HubClient(endpoint: endpoint); + } + + Future healthy() => _client.healthy(); + + Future> listModules() async { + final caps = await _client.listCapabilities(); + + // Group capabilities by module so Studio's UI maps a card + // to a module rather than a capability. + final byModule = >{}; + for (final c in caps) { + byModule.putIfAbsent(c.moduleName, () => []).add(c); + } + return byModule.entries.map((e) { + final caps = e.value; + return ModuleSummary( + name: e.key, + version: caps.first.moduleVersion, + capabilities: caps + .map((c) => '${c.capability}@${c.version}') + .toList(), + ); + }).toList() + ..sort((a, b) => a.name.compareTo(b.name)); + } + + Future> recentEvents({ + int limit = 50, + List types = const [], + }) async { + final events = await _client.eventLog(limit: limit, types: types); + return events + .map( + (e) => AuditEvent( + eventId: e.eventId, + timestamp: DateTime.tryParse(e.timestamp) ?? DateTime.now(), + type: e.eventType, + flowName: e.flowName.isEmpty ? null : e.flowName, + stepId: e.stepId.isEmpty ? null : e.stepId, + moduleName: e.moduleName.isEmpty ? null : e.moduleName, + durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(), + error: e.error.isEmpty ? null : e.error, + ), + ) + .toList(); + } + + Future> pendingApprovals() async { + final entries = await _client.listApprovals(statuses: ['pending']); + return entries + .map( + (e) => PendingApproval( + id: e.approvalId, + flowName: e.flowName, + stepId: e.stepId, + prompt: e.prompt, + showPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview, + createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(), + expiresAt: e.expiresAt.isEmpty + ? null + : DateTime.tryParse(e.expiresAt), + ), + ) + .toList(); + } + + Future approve(String id, String reviewer) => + _client.approve(approvalId: id, reviewer: reviewer); + + Future reject(String id, String reviewer, String reason) => + _client.reject( + approvalId: id, + reviewer: reviewer, + reason: reason, + ); +} + +/// UI-side type, decoupled from the proto wire type so pages +/// don't import protobuf packages. +class ModuleSummary { + final String name; + final String version; + final List capabilities; + + const ModuleSummary({ + required this.name, + required this.version, + required this.capabilities, + }); +} + +class AuditEvent { + final String eventId; + final DateTime timestamp; + final String type; + final String? flowName; + final String? stepId; + final String? moduleName; + final int? durationMs; + final String? error; + + const AuditEvent({ + required this.eventId, + required this.timestamp, + required this.type, + this.flowName, + this.stepId, + this.moduleName, + this.durationMs, + this.error, + }); +} + +class PendingApproval { + final String id; + final String flowName; + final String stepId; + final String prompt; + final String? showPreview; + final DateTime createdAt; + final DateTime? expiresAt; + + const PendingApproval({ + required this.id, + required this.flowName, + required this.stepId, + required this.prompt, + this.showPreview, + required this.createdAt, + this.expiresAt, + }); +} diff --git a/lib/data/mock.dart b/lib/data/mock.dart deleted file mode 100644 index c7342c5..0000000 --- a/lib/data/mock.dart +++ /dev/null @@ -1,165 +0,0 @@ -// Mock data for the MVP. Replaced by live gRPC calls via -// fai_dart_sdk in the next iteration. - -class ModuleSummary { - final String name; - final String version; - final List capabilities; - final List permissions; - final List requiresServices; - - const ModuleSummary({ - required this.name, - required this.version, - required this.capabilities, - required this.permissions, - required this.requiresServices, - }); -} - -class AuditEvent { - final String eventId; - final DateTime timestamp; - final String type; - final String? flowName; - final String? stepId; - final String? moduleName; - final int? durationMs; - final String? error; - - const AuditEvent({ - required this.eventId, - required this.timestamp, - required this.type, - this.flowName, - this.stepId, - this.moduleName, - this.durationMs, - this.error, - }); -} - -class PendingApproval { - final String id; - final String flowName; - final String stepId; - final String prompt; - final String? showPreview; - final DateTime createdAt; - final DateTime? expiresAt; - - const PendingApproval({ - required this.id, - required this.flowName, - required this.stepId, - required this.prompt, - this.showPreview, - required this.createdAt, - this.expiresAt, - }); -} - -const mockModules = [ - ModuleSummary( - name: 'echo', - version: '0.1.0', - capabilities: ['debug.echo@0.1.0'], - permissions: [], - requiresServices: [], - ), - ModuleSummary( - name: 'text-extract', - version: '0.1.1', - capabilities: ['text.extract@0.1.0'], - permissions: [], - requiresServices: [], - ), - ModuleSummary( - name: 'llm-chat', - version: '0.1.0', - capabilities: ['llm.chat@0.1.0'], - permissions: [ - 'net: localhost', - 'net: 127.0.0.1', - 'net: api.openai.com', - 'net: api.anthropic.com', - ], - requiresServices: ['ollama'], - ), - ModuleSummary( - name: 'text-translate', - version: '0.1.0', - capabilities: ['text.translate@0.1.0'], - permissions: [ - 'net: localhost', - 'net: 127.0.0.1', - ], - requiresServices: ['ollama'], - ), - ModuleSummary( - name: 'text-summarize', - version: '0.1.0', - capabilities: ['text.summarize@0.1.0'], - permissions: [ - 'net: localhost', - 'net: 127.0.0.1', - ], - requiresServices: ['ollama'], - ), -]; - -final mockEvents = [ - AuditEvent( - eventId: '01926f8a-7c34-7b2c-9e4f-a8d3c1f9e2b4', - timestamp: DateTime.now().subtract(const Duration(seconds: 2)), - type: 'flow.completed', - flowName: 'extract-translate-summarize', - durationMs: 8421, - ), - AuditEvent( - eventId: '01926f8a-7b00-7000-aaaa-bbbbcccc0042', - timestamp: DateTime.now().subtract(const Duration(seconds: 3)), - type: 'step.completed', - flowName: 'extract-translate-summarize', - stepId: 'summarize', - moduleName: 'text-summarize', - durationMs: 3204, - ), - AuditEvent( - eventId: '01926f8a-7a90-7000-aaaa-bbbbcccc0041', - timestamp: DateTime.now().subtract(const Duration(seconds: 9)), - type: 'step.completed', - flowName: 'extract-translate-summarize', - stepId: 'review', - moduleName: 'system.approval', - durationMs: 5102, - ), - AuditEvent( - eventId: '01926f8a-7a00-7000-aaaa-bbbbcccc0040', - timestamp: DateTime.now().subtract(const Duration(seconds: 14)), - type: 'step.completed', - flowName: 'extract-translate-summarize', - stepId: 'translate', - moduleName: 'text-translate', - durationMs: 4815, - ), - AuditEvent( - eventId: '01926f8a-7800-7000-aaaa-bbbbcccc003f', - timestamp: DateTime.now().subtract(const Duration(seconds: 19)), - type: 'flow.started', - flowName: 'extract-translate-summarize', - ), -]; - -final mockApprovals = [ - PendingApproval( - id: '01926f8a-9000-7b2c-9e4f-aaaa00000001', - flowName: 'extract-translate-summarize', - stepId: 'review', - prompt: 'Review the German→English translation before summarisation.', - showPreview: - '{ "translated": "The quarterly report shows..." }', - createdAt: DateTime.now().subtract(const Duration(minutes: 2)), - expiresAt: DateTime.now().add(const Duration(minutes: 28)), - ), -]; diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 6296a97..301c154 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../data/mock.dart'; +import '../data/hub.dart'; class ApprovalsPage extends StatefulWidget { const ApprovalsPage({super.key}); @@ -10,20 +10,121 @@ class ApprovalsPage extends StatefulWidget { } class _ApprovalsPageState extends State { - final _decided = {}; + late Future> _future; + // Hard-coded reviewer for the MVP; Phase 1+ wires this to an + // authenticated session. + final String _reviewer = 'studio-mvp'; + + @override + void initState() { + super.initState(); + _future = HubService.instance.pendingApprovals(); + } + + void _refresh() => setState(() { + _future = HubService.instance.pendingApprovals(); + }); + + Future _approve(PendingApproval a) async { + try { + await HubService.instance.approve(a.id, _reviewer); + _refresh(); + } catch (e) { + _showError('approve failed: $e'); + } + } + + Future _reject(PendingApproval a) async { + final reason = await _promptReason(context); + if (reason == null || reason.isEmpty) return; + try { + await HubService.instance.reject(a.id, _reviewer, reason); + _refresh(); + } catch (e) { + _showError('reject failed: $e'); + } + } + + void _showError(String msg) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + + Future _promptReason(BuildContext context) async { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Reject approval'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Reason (recorded in audit log)', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, null), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, controller.text), + child: const Text('Reject'), + ), + ], + ), + ); + } @override Widget build(BuildContext context) { final theme = Theme.of(context); - final pending = - mockApprovals.where((a) => !_decided.contains(a.id)).toList(); return Scaffold( appBar: AppBar( title: const Text('Pending Approvals'), centerTitle: false, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Reload', + onPressed: _refresh, + ), + ], ), - body: pending.isEmpty - ? Center( + body: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_off_outlined, + size: 48, + color: theme.colorScheme.error, + ), + const SizedBox(height: 16), + Text('Hub unreachable: ${snapshot.error}'), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: _refresh, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + final pending = snapshot.data ?? []; + if (pending.isEmpty) { + return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( @@ -32,7 +133,8 @@ class _ApprovalsPageState extends State { Icon( Icons.check_circle_outline, size: 64, - color: theme.colorScheme.primary.withValues(alpha: 0.5), + color: theme.colorScheme.primary + .withValues(alpha: 0.5), ), const SizedBox(height: 16), Text( @@ -44,22 +146,23 @@ class _ApprovalsPageState extends State { ], ), ), - ) - : ListView.separated( - padding: const EdgeInsets.all(24), - itemCount: pending.length, - separatorBuilder: (_, _) => const SizedBox(height: 16), - itemBuilder: (context, i) { - final a = pending[i]; - return _ApprovalCard( - approval: a, - onApprove: () => - setState(() => _decided.add(a.id)), - onReject: () => - setState(() => _decided.add(a.id)), - ); - }, - ), + ); + } + return ListView.separated( + padding: const EdgeInsets.all(24), + itemCount: pending.length, + separatorBuilder: (_, _) => const SizedBox(height: 16), + itemBuilder: (context, i) { + final a = pending[i]; + return _ApprovalCard( + approval: a, + onApprove: () => _approve(a), + onReject: () => _reject(a), + ); + }, + ); + }, + ), ); } } diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 8776025..a7f0fb0 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -1,6 +1,8 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; -import '../data/mock.dart'; +import '../data/hub.dart'; class AuditPage extends StatefulWidget { const AuditPage({super.key}); @@ -11,13 +13,57 @@ class AuditPage extends StatefulWidget { class _AuditPageState extends State { String _typeFilter = 'all'; + List _events = const []; + String? _error; + bool _initialLoaded = false; + Timer? _poller; + + @override + void initState() { + super.initState(); + _refresh(); + _poller = Timer.periodic( + const Duration(seconds: 2), + (_) => _refresh(), + ); + } + + @override + void dispose() { + _poller?.cancel(); + super.dispose(); + } + + Future _refresh() async { + final types = _typeFilter == 'all' + ? [] + : []; // server-side filter not yet — clientside below + try { + final events = await HubService.instance.recentEvents( + limit: 100, + types: types, + ); + if (!mounted) return; + setState(() { + _events = events; + _error = null; + _initialLoaded = true; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _initialLoaded = true; + }); + } + } @override Widget build(BuildContext context) { final theme = Theme.of(context); final filtered = _typeFilter == 'all' - ? mockEvents - : mockEvents.where((e) => e.type.startsWith(_typeFilter)).toList(); + ? _events + : _events.where((e) => e.type.startsWith(_typeFilter)).toList(); return Scaffold( appBar: AppBar( @@ -41,84 +87,114 @@ class _AuditPageState extends State { ), body: Column( children: [ - _StatusBar(eventCount: filtered.length), + _StatusBar(eventCount: filtered.length, error: _error), Expanded( - child: ListView.separated( - padding: const EdgeInsets.all(24), - itemCount: filtered.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, i) { - final e = filtered[i]; - final ts = e.timestamp; - final tsStr = - '${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}'; - final tone = _toneFor(e.type); - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide( - color: theme.colorScheme.outlineVariant, + child: !_initialLoaded + ? const Center(child: CircularProgressIndicator()) + : _error != null && _events.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'Hub unreachable: $_error\n\n' + 'Start the hub with `fai serve`.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), ), + ) + : filtered.isEmpty + ? Center( + child: Text( + '(no events yet)', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(24), + itemCount: filtered.length, + separatorBuilder: (_, _) => + const SizedBox(height: 8), + itemBuilder: (context, i) { + final e = filtered[i]; + final ts = e.timestamp; + final tsStr = + '${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}'; + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: theme.colorScheme.outlineVariant, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Row( + children: [ + Container( + width: 4, + height: 32, + decoration: BoxDecoration( + color: _toneFor(e.type, theme), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 90, + child: Text( + tsStr, + style: + theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: theme + .colorScheme.onSurfaceVariant, + ), + ), + ), + SizedBox( + width: 180, + child: Text( + e.type, + style: + theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + child: Text( + _contextLine(e), + style: + theme.textTheme.bodyMedium?.copyWith( + color: theme + .colorScheme.onSurfaceVariant, + ), + ), + ), + if (e.durationMs != null) + Text( + '${e.durationMs}ms', + style: + theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: theme + .colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + }, ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Row( - children: [ - Container( - width: 4, - height: 32, - decoration: BoxDecoration( - color: tone, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 12), - SizedBox( - width: 90, - child: Text( - tsStr, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - SizedBox( - width: 180, - child: Text( - e.type, - style: theme.textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.w500, - ), - ), - ), - Expanded( - child: Text( - _contextLine(e), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - if (e.durationMs != null) - Text( - '${e.durationMs}ms', - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - }, - ), ), ], ), @@ -134,23 +210,24 @@ class _AuditPageState extends State { return parts.join(''); } - Color _toneFor(String type) { - final scheme = Theme.of(context).colorScheme; - if (type.endsWith('.failed')) return scheme.error; - if (type.endsWith('.completed')) return scheme.primary; - if (type.endsWith('.started')) return scheme.tertiary; - return scheme.outline; + Color _toneFor(String type, ThemeData theme) { + if (type.endsWith('.failed')) return theme.colorScheme.error; + if (type.endsWith('.completed')) return theme.colorScheme.primary; + if (type.endsWith('.started')) return theme.colorScheme.tertiary; + return theme.colorScheme.outline; } } class _StatusBar extends StatelessWidget { final int eventCount; + final String? error; - const _StatusBar({required this.eventCount}); + const _StatusBar({required this.eventCount, required this.error}); @override Widget build(BuildContext context) { final theme = Theme.of(context); + final live = error == null; return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), @@ -160,20 +237,15 @@ class _StatusBar extends StatelessWidget { Icon( Icons.fiber_manual_record, size: 12, - color: theme.colorScheme.tertiary, + color: live ? theme.colorScheme.tertiary : theme.colorScheme.error, ), const SizedBox(width: 8), Text( - 'live (mock data) — $eventCount events', + live + ? 'live (polling 2s) — $eventCount events' + : 'disconnected — $error', style: theme.textTheme.bodySmall, ), - const Spacer(), - Text( - 'hash chain: verified', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.primary, - ), - ), ], ), ); diff --git a/lib/pages/modules.dart b/lib/pages/modules.dart index be09290..83dca00 100644 --- a/lib/pages/modules.dart +++ b/lib/pages/modules.dart @@ -1,10 +1,27 @@ import 'package:flutter/material.dart'; -import '../data/mock.dart'; +import '../data/hub.dart'; -class ModulesPage extends StatelessWidget { +class ModulesPage extends StatefulWidget { const ModulesPage({super.key}); + @override + State createState() => _ModulesPageState(); +} + +class _ModulesPageState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = HubService.instance.listModules(); + } + + void _refresh() => setState(() { + _future = HubService.instance.listModules(); + }); + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -12,67 +29,93 @@ class ModulesPage extends StatelessWidget { appBar: AppBar( title: const Text('Installed Modules'), centerTitle: false, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Reload', + onPressed: _refresh, + ), + ], ), - body: ListView.separated( - padding: const EdgeInsets.all(24), - itemCount: mockModules.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (context, i) { - final m = mockModules[i]; - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: theme.colorScheme.outlineVariant), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + body: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return _ConnectionError( + message: 'Hub unreachable: ${snapshot.error}', + onRetry: _refresh, + ); + } + final modules = snapshot.data ?? []; + if (modules.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'No modules installed.\n' + 'Run `fai install ` or check ~/.fai/modules/.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.all(24), + itemCount: modules.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final m = modules[i]; + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: theme.colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - m.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - 'v${m.version}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSecondaryContainer, + Row( + children: [ + Text( + m.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), - ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'v${m.version}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + ), + ), + ), + ], ), + const SizedBox(height: 8), + _ChipRow(label: 'Capabilities', items: m.capabilities), ], ), - const SizedBox(height: 8), - _Row(label: 'Capabilities', items: m.capabilities), - if (m.permissions.isNotEmpty) ...[ - const SizedBox(height: 6), - _Row(label: 'Permissions', items: m.permissions), - ], - if (m.requiresServices.isNotEmpty) ...[ - const SizedBox(height: 6), - _Row( - label: 'Requires services', - items: m.requiresServices, - ), - ], - ], - ), - ), + ), + ); + }, ); }, ), @@ -80,11 +123,11 @@ class ModulesPage extends StatelessWidget { } } -class _Row extends StatelessWidget { +class _ChipRow extends StatelessWidget { final String label; final List items; - const _Row({required this.label, required this.items}); + const _ChipRow({required this.label, required this.items}); @override Widget build(BuildContext context) { @@ -131,3 +174,48 @@ class _Row extends StatelessWidget { ); } } + +class _ConnectionError extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ConnectionError({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_off_outlined, + size: 48, + color: theme.colorScheme.error, + ), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + 'Start the hub with `fai serve`.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: onRetry, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 91f9160..65bf515 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,5 +1,10 @@ -// Smoke test: app boots, navigation rail shows the three MVP -// pages, and the Modules page renders the mocked module list. +// Smoke test: app boots without crashing and shows the three +// MVP destinations in the navigation rail. +// +// Pages themselves now make live gRPC calls. We pump only one +// frame so they enter the loading state without actually +// awaiting the connection (which would fail without a running +// hub in CI). import 'package:flutter_test/flutter_test.dart'; import 'package:fai_studio/main.dart'; @@ -8,21 +13,10 @@ void main() { testWidgets('Studio shell shows the three MVP destinations', (tester) async { await tester.pumpWidget(const StudioApp()); - await tester.pump(); expect(find.text('F∆I Studio'), findsOneWidget); expect(find.text('Modules'), findsWidgets); expect(find.text('Audit'), findsOneWidget); expect(find.text('Approvals'), findsOneWidget); }); - - testWidgets('Modules page lists the mocked modules', - (tester) async { - await tester.pumpWidget(const StudioApp()); - await tester.pump(); - - expect(find.text('echo'), findsOneWidget); - expect(find.text('text-extract'), findsOneWidget); - expect(find.text('llm-chat'), findsOneWidget); - }); }