import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../data/error_presentation.dart'; import '../widgets/widgets.dart'; import 'welcome.dart' show showFaiDoc; /// Federation panel: the satellites connected to this hub (primary /// side) and a one-step "add satellite" flow that issues a bootstrap /// token + the primary CA as a ready-to-paste config. class FederationPage extends StatefulWidget { const FederationPage({super.key}); @override State createState() => _FederationPageState(); } class _FederationPageState extends State { late Future> _future; @override void initState() { super.initState(); _refresh(); } void _refresh() { setState(() { _future = HubService.instance.listSatellites(); }); } Future _addSatellite() async { final name = await _promptName(context); if (name == null || name.isEmpty) return; if (!mounted) return; final l = AppLocalizations.of(context)!; try { final enrollment = await HubService.instance.issueSatelliteToken(name); if (!mounted) return; await showDialog( context: context, builder: (_) => _EnrollmentDialog(name: name, enrollment: enrollment), ); _refresh(); } catch (e) { if (!mounted) return; showFaiErrorSnack(context, 'federation.issue', e, title: l.federationIssueFailed('')); } } Future _promptName(BuildContext context) async { final controller = TextEditingController(); final l = AppLocalizations.of(context)!; return showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l.federationAddDialogTitle), content: TextField( controller: controller, autofocus: true, decoration: InputDecoration( labelText: l.federationNameLabel, hintText: 'satellite-a', border: const OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, null), child: Text(l.buttonCancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, controller.text.trim()), child: Text(l.federationIssueButton), ), ], ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( title: Text(l.federationTitle), actions: [ IconButton( icon: const Icon(Icons.help_outline, size: 18), tooltip: l.helpTooltip, onPressed: () => showFaiDoc(context, 'federation'), ), IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: l.federationReloadTooltip, onPressed: _refresh, ), const SizedBox(width: ChainSpace.sm), ], ), floatingActionButton: FloatingActionButton.extended( onPressed: _addSatellite, icon: const Icon(Icons.add_link), label: Text(l.federationAddSatellite), ), body: FutureBuilder>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return ChainEmptyState( icon: Icons.cloud_off_outlined, iconColor: theme.colorScheme.error, title: l.hubUnreachable, hint: l.hubUnreachableHint, action: FilledButton.tonal( onPressed: _refresh, child: Text(l.buttonRetry), ), ); } final sats = snapshot.data ?? []; if (sats.isEmpty) { // The "add satellite" action lives in the FAB (always // visible); don't repeat it here — that showed the button // twice on the empty page. return ChainEmptyState( icon: Icons.hub_outlined, title: l.federationEmptyTitle, hint: l.federationEmptyHint, ); } return ListView.separated( padding: const EdgeInsets.all(ChainSpace.xl), itemCount: sats.length, separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.md), itemBuilder: (context, i) => _SatelliteCard(satellite: sats[i]), ); }, ), ); } } class _SatelliteCard extends StatelessWidget { final Satellite satellite; const _SatelliteCard({required this.satellite}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return ChainCard( accentTop: true, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ ChainPill( label: l.federationPillConnected, tone: ChainPillTone.success, icon: Icons.link, ), const SizedBox(width: ChainSpace.sm), Expanded( child: Text( satellite.displayName, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), ), ChainPill( label: satellite.region.isEmpty ? l.federationRegionNone : satellite.region, tone: ChainPillTone.neutral, icon: Icons.public, ), ], ), const SizedBox(height: ChainSpace.md), Wrap( spacing: ChainSpace.md, runSpacing: ChainSpace.xs, children: [ _meta(theme, 'v${satellite.hubVersion}'), _meta(theme, 'wire ${satellite.wireVersion}'), _meta(theme, satellite.hubId), ], ), if (satellite.capabilities.isNotEmpty) ...[ const SizedBox(height: ChainSpace.md), Text( l.federationCapabilities(satellite.capabilities.length), style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), const SizedBox(height: 4), Wrap( spacing: ChainSpace.xs, runSpacing: ChainSpace.xs, children: satellite.capabilities .map( (c) => ChainPill( label: c, tone: ChainPillTone.accent, icon: Icons.extension_outlined, ), ) .toList(), ), ], ], ), ); } Widget _meta(ThemeData theme, String text) => Text( text, style: ChainTheme.mono(size: 10, color: theme.colorScheme.onSurfaceVariant), ); } /// Shows the freshly issued token + a ready-to-paste satellite config /// (with the primary CA inlined) the operator copies over. class _EnrollmentDialog extends StatelessWidget { final String name; final SatelliteEnrollment enrollment; const _EnrollmentDialog({required this.name, required this.enrollment}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final config = enrollment.toConfigYaml(name); return AlertDialog( title: Text(l.federationEnrollmentTitle(name)), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560, maxHeight: 520), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(l.federationTokenLabel, style: theme.textTheme.labelSmall), const SizedBox(height: 4), _CopyableBlock(text: enrollment.token, copiedMsg: l.federationCopied), const SizedBox(height: ChainSpace.md), Text(l.federationConfigLabel, style: theme.textTheme.labelSmall), const SizedBox(height: 4), _CopyableBlock(text: config, copiedMsg: l.federationCopied), const SizedBox(height: ChainSpace.md), Text( l.federationEnrollmentHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(l.buttonClose), ), ], ); } } class _CopyableBlock extends StatelessWidget { final String text; final String copiedMsg; const _CopyableBlock({required this.text, required this.copiedMsg}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Stack( children: [ Container( width: double.infinity, padding: const EdgeInsets.fromLTRB( ChainSpace.md, ChainSpace.md, 40, ChainSpace.md, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: SelectableText( text, style: ChainTheme.mono(size: 11, color: theme.colorScheme.onSurface), ), ), Positioned( top: 2, right: 2, child: IconButton( icon: const Icon(Icons.copy, size: 16), tooltip: copiedMsg, onPressed: () { Clipboard.setData(ClipboardData(text: text)); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(copiedMsg))); }, ), ), ], ); } }