feat(useragent): add SDK clients table screen
This commit is contained in:
585
useragent/lib/screens/dashboard/clients/table.dart
Normal file
585
useragent/lib/screens/dashboard/clients/table.dart
Normal file
@@ -0,0 +1,585 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||
import 'package:arbiter/providers/connection/connection_manager.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:sizer/sizer.dart';
|
||||
|
||||
// ─── Palette ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _Palette {
|
||||
static const ink = Color(0xFF15263C);
|
||||
static const coral = Color(0xFFE26254);
|
||||
static const cream = Color(0xFFFFFAF4);
|
||||
static const line = Color(0x1A15263C);
|
||||
}
|
||||
|
||||
// ─── 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<int> bytes) {
|
||||
final seed = bytes.fold<int>(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<int> 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<int> 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<void> 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<void> 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Table container ──────────────────────────────────────────────────────────
|
||||
|
||||
class _ClientTable extends StatelessWidget {
|
||||
const _ClientTable({required this.clients});
|
||||
|
||||
final List<SdkClientEntry> 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<void> 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user