import 'dart:math' as math; import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/router.gr.dart'; import 'package:arbiter/providers/sdk_clients/list.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:arbiter/theme/palette.dart'; import 'package:sizer/sizer.dart'; // ─── Column width getters ───────────────────────────────────────────────────── double get _accentStripWidth => 0.8.w; double get _cellHPad => 1.8.w; double get _colGap => 1.8.w; double get _idColWidth => 8.w; double get _nameColWidth => 20.w; double get _versionColWidth => 12.w; double get _registeredColWidth => 18.w; double get _chevronColWidth => 4.w; double get _tableMinWidth => 72.w; // ─── Helpers ────────────────────────────────────────────────────────────────── Color _accentColor(List bytes) { final seed = bytes.fold(0, (v, b) => v + b); final hue = (seed * 17) % 360; return HSLColor.fromAHSL(1, hue.toDouble(), 0.68, 0.54).toColor(); } // ed25519 public keys are always 32 bytes (64 hex chars); guard is defensive. String _shortPubkey(List bytes) { final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); if (hex.length < 12) return '0x$hex'; return '0x${hex.substring(0, 8)}...${hex.substring(hex.length - 4)}'; } String _fullPubkey(List bytes) { final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); return '0x$hex'; } String _formatDate(int unixSecs) { final dt = DateTime.fromMillisecondsSinceEpoch(unixSecs * 1000).toLocal(); return '${dt.year}-' '${dt.month.toString().padLeft(2, '0')}-' '${dt.day.toString().padLeft(2, '0')}'; } String _formatError(Object error) { final message = error.toString(); if (message.startsWith('Exception: ')) { return message.substring('Exception: '.length); } return message; } // ─── State panel ───────────────────────────────────────────────────────────── class _StatePanel extends StatelessWidget { const _StatePanel({ required this.icon, required this.title, required this.body, this.actionLabel, this.onAction, this.busy = false, }); final IconData icon; final String title; final String body; final String? actionLabel; final Future Function()? onAction; final bool busy; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), color: Palette.cream.withValues(alpha: 0.92), border: Border.all(color: Palette.line), ), child: Padding( padding: EdgeInsets.all(2.8.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (busy) SizedBox( width: 2.8.h, height: 2.8.h, child: const CircularProgressIndicator(strokeWidth: 2.5), ) else Icon(icon, size: 34, color: Palette.coral), SizedBox(height: 1.8.h), Text( title, style: theme.textTheme.headlineSmall?.copyWith( color: Palette.ink, fontWeight: FontWeight.w800, ), ), SizedBox(height: 1.h), Text( body, style: theme.textTheme.bodyLarge?.copyWith( color: Palette.ink.withValues(alpha: 0.72), height: 1.5, ), ), if (actionLabel != null && onAction != null) ...[ SizedBox(height: 2.h), OutlinedButton.icon( onPressed: () => onAction!(), icon: const Icon(Icons.refresh), label: Text(actionLabel!), ), ], ], ), ), ); } } // ─── Header ─────────────────────────────────────────────────────────────────── class _Header extends StatelessWidget { const _Header({required this.isBusy, required this.onRefresh}); final bool isBusy; final Future Function() onRefresh; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( padding: EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), color: Palette.cream, border: Border.all(color: Palette.line), ), child: Row( children: [ Expanded( child: Text( 'SDK Clients', style: theme.textTheme.titleMedium?.copyWith( color: Palette.ink, fontWeight: FontWeight.w800, ), ), ), if (isBusy) ...[ Text( 'Syncing', style: theme.textTheme.bodySmall?.copyWith( color: Palette.ink.withValues(alpha: 0.62), fontWeight: FontWeight.w700, ), ), SizedBox(width: 1.w), ], OutlinedButton.icon( onPressed: () => onRefresh(), style: OutlinedButton.styleFrom( foregroundColor: Palette.ink, side: BorderSide(color: Palette.line), padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), ), icon: const Icon(Icons.refresh, size: 18), label: const Text('Refresh'), ), ], ), ); } } // ─── Table header row ───────────────────────────────────────────────────────── class _ClientTableHeader extends StatelessWidget { const _ClientTableHeader(); @override Widget build(BuildContext context) { final style = Theme.of(context).textTheme.labelLarge?.copyWith( color: Palette.ink.withValues(alpha: 0.72), fontWeight: FontWeight.w800, letterSpacing: 0.3, ); return Container( padding: EdgeInsets.symmetric(vertical: 1.4.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: Palette.ink.withValues(alpha: 0.04), ), child: Row( children: [ SizedBox(width: _accentStripWidth + _cellHPad), SizedBox( width: _idColWidth, child: Text('ID', style: style), ), SizedBox(width: _colGap), SizedBox( width: _nameColWidth, child: Text('Name', style: style), ), SizedBox(width: _colGap), SizedBox( width: _versionColWidth, child: Text('Version', style: style), ), SizedBox(width: _colGap), SizedBox( width: _registeredColWidth, child: Text('Registered', style: style), ), SizedBox(width: _colGap), SizedBox(width: _chevronColWidth), SizedBox(width: _cellHPad), ], ), ); } } // ─── Table row (owns its own expand state) ──────────────────────────────────── class _ClientTableRow extends HookWidget { const _ClientTableRow({required this.client}); final SdkClientEntry client; @override Widget build(BuildContext context) { final expanded = useState(false); final accent = _accentColor(client.pubkey); final theme = Theme.of(context); final muted = Palette.ink.withValues(alpha: 0.62); final name = client.info.name.isEmpty ? '—' : client.info.name; final version = client.info.version.isEmpty ? '—' : client.info.version; final registered = _formatDate(client.createdAt); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Collapsed row ────────────────────────────────────────────────────── GestureDetector( onTap: () => expanded.value = !expanded.value, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), color: accent.withValues(alpha: 0.10), border: Border.all(color: accent.withValues(alpha: 0.28)), ), child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( width: _accentStripWidth, decoration: BoxDecoration( color: accent, borderRadius: const BorderRadius.horizontal( left: Radius.circular(18), ), ), ), Expanded( child: Padding( padding: EdgeInsets.symmetric( horizontal: _cellHPad, vertical: 1.4.h, ), child: Row( children: [ SizedBox( width: _idColWidth, child: Text( '${client.id}', style: theme.textTheme.bodyLarge?.copyWith( color: Palette.ink, ), ), ), SizedBox(width: _colGap), SizedBox( width: _nameColWidth, child: Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( color: Palette.ink, ), ), ), SizedBox(width: _colGap), SizedBox( width: _versionColWidth, child: Text( version, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: muted, ), ), ), SizedBox(width: _colGap), SizedBox( width: _registeredColWidth, child: Text( registered, style: theme.textTheme.bodySmall?.copyWith( color: muted, ), ), ), SizedBox(width: _colGap), SizedBox( width: _chevronColWidth, child: Icon( expanded.value ? Icons.expand_more : Icons.chevron_right, color: muted, ), ), ], ), ), ), ], ), ), ), ), // ── Expansion panel (AnimatedSize wraps only this section) ──────────── AnimatedSize( duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, child: expanded.value ? Container( margin: EdgeInsets.only(top: 0.6.h), padding: EdgeInsets.symmetric( horizontal: 1.6.w, vertical: 1.4.h, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), color: accent.withValues(alpha: 0.06), border: Border( left: BorderSide(color: accent, width: 0.4.w), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( client.info.description.isEmpty ? 'No description provided.' : client.info.description, style: theme.textTheme.bodyMedium?.copyWith( color: muted, height: 1.5, ), ), SizedBox(height: 1.h), Row( children: [ Expanded( child: Text( _shortPubkey(client.pubkey), style: theme.textTheme.bodySmall?.copyWith( color: Palette.ink, fontFamily: 'monospace', ), ), ), IconButton( icon: const Icon(Icons.copy_rounded, size: 18), color: muted, onPressed: () async { await Clipboard.setData( ClipboardData(text: _fullPubkey(client.pubkey)), ); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Public key copied.'), behavior: SnackBarBehavior.floating, ), ); }, ), FilledButton.tonal( onPressed: () { context.router.push( ClientDetailsRoute(clientId: client.id), ); }, child: const Text('Manage access'), ), ], ), ], ), ) : const SizedBox.shrink(), ), ], ); } } // ─── Table container ────────────────────────────────────────────────────────── class _ClientTable extends StatelessWidget { const _ClientTable({required this.clients}); final List clients; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), color: Palette.cream.withValues(alpha: 0.92), border: Border.all(color: Palette.line), ), child: Padding( padding: EdgeInsets.all(2.h), child: LayoutBuilder( builder: (context, constraints) { final tableWidth = math.max(_tableMinWidth, constraints.maxWidth); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Registered clients', style: theme.textTheme.titleLarge?.copyWith( color: Palette.ink, fontWeight: FontWeight.w800, ), ), SizedBox(height: 0.6.h), Text( 'Every entry here has authenticated with Arbiter at least once.', style: theme.textTheme.bodyMedium?.copyWith( color: Palette.ink.withValues(alpha: 0.70), height: 1.4, ), ), SizedBox(height: 1.6.h), SingleChildScrollView( scrollDirection: Axis.horizontal, child: SizedBox( width: tableWidth, child: Column( children: [ const _ClientTableHeader(), SizedBox(height: 1.h), for (var i = 0; i < clients.length; i++) Padding( padding: EdgeInsets.only( bottom: i == clients.length - 1 ? 0 : 1.h, ), child: _ClientTableRow(client: clients[i]), ), ], ), ), ), ], ); }, ), ), ); } } // ─── Screen ─────────────────────────────────────────────────────────────────── @RoutePage() class ClientsScreen extends HookConsumerWidget { const ClientsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final clientsAsync = ref.watch(sdkClientsProvider); final isConnected = ref.watch(connectionManagerProvider).asData?.value != null; void showMessage(String message) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), ); } Future refresh() async { try { final future = ref.refresh(sdkClientsProvider.future); await future; } catch (error) { showMessage(_formatError(error)); } } final clients = clientsAsync.asData?.value; final content = switch (clientsAsync) { AsyncLoading() when clients == null => const _StatePanel( icon: Icons.hourglass_top, title: 'Loading clients', body: 'Pulling client registry from Arbiter.', busy: true, ), AsyncError(:final error) => _StatePanel( icon: Icons.sync_problem, title: 'Client registry unavailable', body: _formatError(error), actionLabel: 'Retry', onAction: refresh, ), _ when !isConnected => _StatePanel( icon: Icons.portable_wifi_off, title: 'No active server connection', body: 'Reconnect to Arbiter to list SDK clients.', actionLabel: 'Refresh', onAction: refresh, ), _ when clients != null && clients.isEmpty => _StatePanel( icon: Icons.devices_other_outlined, title: 'No clients yet', body: 'SDK clients appear here once they register with Arbiter.', actionLabel: 'Refresh', onAction: refresh, ), _ => _ClientTable(clients: clients ?? const []), }; return Scaffold( body: SafeArea( child: RefreshIndicator.adaptive( color: Palette.ink, backgroundColor: Colors.white, onRefresh: refresh, child: ListView( physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h), children: [ _Header(isBusy: clientsAsync.isLoading, onRefresh: refresh), SizedBox(height: 1.8.h), content, ], ), ), ), ); } }