// FaiSettingsDialog — modal for changing the hub endpoint. // Reads current values from HubService, persists on save via // HubService.reconnect. import 'package:fai_dart_sdk/fai_dart_sdk.dart'; import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; class FaiSettingsDialog extends StatefulWidget { const FaiSettingsDialog({super.key}); /// Convenience launcher used from the sidebar gear icon. static Future show(BuildContext context) async { final ok = await showDialog( context: context, builder: (_) => const FaiSettingsDialog(), ); return ok ?? false; } @override State createState() => _FaiSettingsDialogState(); } class _FaiSettingsDialogState extends State { late final TextEditingController _host; late final TextEditingController _port; bool _secure = false; bool _saving = false; String? _error; ChannelStatusSnapshot? _channels; @override void initState() { super.initState(); final ep = HubService.instance.currentEndpoint; _host = TextEditingController(text: ep.host); _port = TextEditingController(text: ep.port.toString()); _secure = ep.secure; _loadChannels(); } Future _loadChannels() async { try { final snap = await HubService.instance.channelStatus(); if (!mounted) return; setState(() => _channels = snap); } catch (_) { // Silent: channels block stays hidden when the hub is // unreachable (the endpoint section is the operator's // path back to a working connection). } } Future _connectToChannel(ChannelInfo ch) async { setState(() { _host.text = '127.0.0.1'; _port.text = ch.port.toString(); _secure = false; }); await _save(); } @override void dispose() { _host.dispose(); _port.dispose(); super.dispose(); } Future _save() async { setState(() { _saving = true; _error = null; }); final port = int.tryParse(_port.text.trim()); if (port == null || port <= 0 || port > 65535) { setState(() { _saving = false; _error = 'port must be 1–65535'; }); return; } final endpoint = HubEndpoint( host: _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(), port: port, secure: _secure, ); try { await HubService.instance.reconnect(endpoint); if (!mounted) return; Navigator.pop(context, true); } catch (e) { setState(() { _saving = false; _error = e.toString(); }); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); return AlertDialog( title: const Text('Hub endpoint'), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(FaiRadius.md), ), contentPadding: const EdgeInsets.symmetric( horizontal: FaiSpace.xl, vertical: FaiSpace.lg, ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 380), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Where should Studio connect? Default is the local hub at 127.0.0.1:50051.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.lg), TextField( controller: _host, decoration: const InputDecoration( labelText: 'Host', border: OutlineInputBorder(), isDense: true, ), autofocus: true, ), const SizedBox(height: FaiSpace.md), Row( children: [ Expanded( flex: 2, child: TextField( controller: _port, decoration: const InputDecoration( labelText: 'Port', border: OutlineInputBorder(), isDense: true, ), keyboardType: TextInputType.number, ), ), const SizedBox(width: FaiSpace.md), Expanded( flex: 3, child: SwitchListTile( contentPadding: EdgeInsets.zero, dense: true, title: const Text('TLS'), subtitle: Text( 'https/grpc-secure', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), value: _secure, onChanged: (v) => setState(() => _secure = v), ), ), ], ), if (_error != null) ...[ const SizedBox(height: FaiSpace.md), Text( _error!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ), ], const SizedBox(height: FaiSpace.md), Container( padding: const EdgeInsets.all(FaiSpace.sm), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), ), child: Row( children: [ Icon( Icons.link, size: 14, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.sm), Text( _previewUrl(), style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurface, ), ), ], ), ), if (_channels != null) ...[ const SizedBox(height: FaiSpace.lg), Text( 'CHANNELS', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(height: 4), for (final ch in _channels!.channels) _ChannelRow( channel: ch, active: ch.name == _channels!.active, onConnect: _saving ? null : () => _connectToChannel(ch), ), ], ], ), ), actions: [ TextButton( onPressed: _saving ? null : () => Navigator.pop(context, false), child: const Text('Cancel'), ), FilledButton( onPressed: _saving ? null : _save, child: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save & connect'), ), ], ); } String _previewUrl() { final scheme = _secure ? 'https' : 'http'; final host = _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(); final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim(); return '$scheme://$host:$port'; } } class _ChannelRow extends StatelessWidget { final ChannelInfo channel; final bool active; final VoidCallback? onConnect; const _ChannelRow({ required this.channel, required this.active, required this.onConnect, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ Icon( channel.running ? Icons.circle : Icons.circle_outlined, size: 10, color: channel.running ? FaiColors.success : theme.colorScheme.outline, ), const SizedBox(width: FaiSpace.sm), SizedBox( width: 88, child: Text( channel.name, style: FaiTheme.mono( size: 11, weight: active ? FontWeight.w600 : FontWeight.w400, color: theme.colorScheme.onSurface, ), ), ), SizedBox( width: 64, child: Text( ':${channel.port}', style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded( child: Text( channel.running ? 'running' : 'stopped', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), if (active) Padding( padding: const EdgeInsets.only(right: FaiSpace.sm), child: Text( 'active', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.primary, ), ), ), TextButton( onPressed: channel.running ? onConnect : null, child: const Text('Connect'), ), ], ), ); } }