import 'dart:async'; import 'package:flutter/material.dart'; import 'package:iop_console/iop_console.dart'; import 'client_config.dart'; import 'control_plane_status_client.dart'; import 'control_plane_status_controller.dart'; import 'control_plane_status_widgets.dart'; import 'iop_wire/client_wire_client.dart'; import 'main.dart'; import 'src/integrations/nexo/nexo_notification_host_integration.dart'; class ClientHomePage extends StatefulWidget { final ClientWireClient? testClient; final NexoNotificationHostIntegration? notificationHost; final ControlPlaneStatusRepository? statusRepository; const ClientHomePage({ super.key, this.testClient, this.notificationHost, this.statusRepository, }); @override State createState() => _ClientHomePageState(); } class _ClientHomePageState extends State { String _wireStatus = 'Disconnected'; ClientWireClient? _client; StreamSubscription? _notificationSubscription; HttpControlPlaneStatusRepository? _ownedStatusRepository; late final ControlPlaneStatusController _controller; @override void initState() { super.initState(); final ownedStatusRepository = widget.statusRepository == null ? HttpControlPlaneStatusRepository( baseUrl: ClientConfig.controlPlaneHttpUrl, ) : null; _ownedStatusRepository = ownedStatusRepository; final statusRepo = widget.statusRepository ?? ownedStatusRepository!; _controller = ControlPlaneStatusController(statusRepository: statusRepo); // Auto-connect on start WidgetsBinding.instance.addPostFrameCallback((_) { applyFullscreenMode(); _connectAndFetch(); }); final host = widget.notificationHost; if (host != null) { _notificationSubscription = host.onNotification.listen( _showNexoNotification, ); } } @override void dispose() { _notificationSubscription?.cancel(); _client?.close(); _controller.dispose(); _ownedStatusRepository?.close(); super.dispose(); } void _showNexoNotification(Map data) { if (data['type'] != 'message' || !mounted) return; final message = data['message'] as String? ?? ''; final channel = data['channel_name'] as String? ?? ''; final sender = data['sender_name'] as String? ?? ''; final content = sender.isNotEmpty ? '[$channel] $sender: $message' : message; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(content, maxLines: 2, overflow: TextOverflow.ellipsis), duration: const Duration(seconds: 4), behavior: SnackBarBehavior.floating, ), ); } Future _connectAndFetch() async { await Future.wait([_connectWire(), _controller.fetchEdges()]); } Future _connectWire() async { if (!mounted) return; setState(() { _wireStatus = 'Connecting'; }); try { ClientWireClient client; if (widget.testClient != null) { client = widget.testClient!; } else { client = await ClientWireClient.connectToUrl( ClientConfig.controlPlaneWireUrl, ); } _client = client; // Hello handshake final response = await client.hello( clientId: 'client-ui', clientVersion: '1.0.0', ); if (!mounted) return; setState(() { _wireStatus = response.ready ? 'Connected' : 'Error'; }); client.addDisconnectListener((_) { if (!mounted) return; setState(() { _wireStatus = 'Disconnected'; _client = null; }); }); } catch (e) { if (!mounted) return; setState(() { _wireStatus = 'Error'; _client = null; }); } } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: _controller, builder: (context, _) { final config = IopConsoleConfig( controlPlaneHttpUrl: ClientConfig.controlPlaneHttpUrl, controlPlaneWireUrl: ClientConfig.controlPlaneWireUrl, ); final currentEdgeId = _controller.selectedEdgeId; return IopConsoleShell( config: config, capabilities: iopDefaultCapabilityPack, overview: IopConsoleOverview( config: config, statusText: _wireStatus, onRefresh: _connectAndFetch, ), edges: EdgesPanel( edges: _controller.edges, isLoading: _controller.loadingEdges, error: _controller.edgesError, selectedEdgeId: currentEdgeId, onSelectEdge: _controller.selectEdge, onRefresh: _controller.fetchEdges, ), nodes: NodesPanel( edges: _controller.edges, selectedEdgeId: currentEdgeId, selectedEdgeStatus: currentEdgeId != null ? _controller.edgeStatuses[currentEdgeId] : null, isLoading: currentEdgeId != null ? (_controller.loadingStatuses[currentEdgeId] ?? false) : false, error: currentEdgeId != null ? _controller.statusErrors[currentEdgeId] : null, onSelectEdge: _controller.selectEdge, onRefresh: () { if (currentEdgeId != null) _controller.fetchEdgeStatus(currentEdgeId); }, ), runtime: RuntimePanel( edges: _controller.edges, selectedEdgeId: currentEdgeId, onSelectEdge: _controller.selectEdge, statusRepository: _controller.statusRepository, ), executionLogs: ExecutionLogsPanel( edges: _controller.edges, selectedEdgeId: currentEdgeId, events: currentEdgeId != null ? (_controller.edgeEvents[currentEdgeId] ?? []) : [], isLoading: currentEdgeId != null ? (_controller.loadingEvents[currentEdgeId] ?? false) : false, error: currentEdgeId != null ? _controller.eventErrors[currentEdgeId] : null, onSelectEdge: _controller.selectEdge, onRefresh: () { if (currentEdgeId != null) _controller.fetchEdgeEvents(currentEdgeId); }, ), agent: const IopAgentPanel(capabilities: iopDefaultCapabilityPack), ); }, ); } }