refactor(useragent::evm::table): broke down into more widgets

This commit is contained in:
hdbg
2026-03-26 20:46:15 +01:00
parent e1b1c857fa
commit 1abb5fa006
5 changed files with 337 additions and 292 deletions

View File

@@ -1,6 +1,8 @@
import 'package:arbiter/features/connection/evm.dart'; import 'package:arbiter/features/connection/evm.dart' as evm;
import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'evm.g.dart'; part 'evm.g.dart';
@@ -14,7 +16,7 @@ class Evm extends _$Evm {
return null; return null;
} }
return listEvmWallets(connection); return evm.listEvmWallets(connection);
} }
Future<void> refreshWallets() async { Future<void> refreshWallets() async {
@@ -25,16 +27,21 @@ class Evm extends _$Evm {
} }
state = const AsyncLoading(); state = const AsyncLoading();
state = await AsyncValue.guard(() => listEvmWallets(connection)); state = await AsyncValue.guard(() => evm.listEvmWallets(connection));
} }
}
Future<void> createWallet() async { final createEvmWallet = Mutation();
final connection = await ref.read(connectionManagerProvider.future);
Future<void> executeCreateEvmWallet(MutationTarget target) async {
return await createEvmWallet.run(target, (tsx) async {
final connection = await tsx.get(connectionManagerProvider.future);
if (connection == null) { if (connection == null) {
throw Exception('Not connected to the server.'); throw Exception('Not connected to the server.');
} }
await createEvmWallet(connection); await evm.createEvmWallet(connection);
state = await AsyncValue.guard(() => listEvmWallets(connection));
} await tsx.get(evmProvider.notifier).refreshWallets();
} });
}

View File

@@ -33,7 +33,7 @@ final class EvmProvider
Evm create() => Evm(); Evm create() => Evm();
} }
String _$evmHash() => r'f5d05bfa7b820d0b96026a47ca47702a3793af5d'; String _$evmHash() => r'ca2c9736065c5dc7cc45d8485000dd85dfbfa572';
abstract class _$Evm extends $AsyncNotifier<List<WalletEntry>?> { abstract class _$Evm extends $AsyncNotifier<List<WalletEntry>?> {
FutureOr<List<WalletEntry>?> build(); FutureOr<List<WalletEntry>?> build();

View File

@@ -1,13 +1,11 @@
import 'dart:math' as math;
import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/screens/dashboard/evm/wallets/header.dart';
import 'package:arbiter/screens/dashboard/evm/wallets/table.dart';
import 'package:arbiter/theme/palette.dart'; import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:arbiter/providers/evm/evm.dart'; import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/widgets/page_header.dart'; import 'package:arbiter/widgets/page_header.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart'; import 'package:sizer/sizer.dart';
@@ -17,13 +15,10 @@ class EvmScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final walletsAsync = ref.watch(evmProvider); final evm = ref.watch(evmProvider);
final isCreating = useState(false);
final wallets = walletsAsync.asData?.value; final wallets = evm.asData?.value;
final loadedWallets = wallets ?? const <WalletEntry>[]; final loadedWallets = wallets ?? const <WalletEntry>[];
final isConnected =
ref.watch(connectionManagerProvider).asData?.value != null;
void showMessage(String message) { void showMessage(String message) {
if (!context.mounted) return; if (!context.mounted) return;
@@ -35,28 +30,12 @@ class EvmScreen extends HookConsumerWidget {
Future<void> refreshWallets() async { Future<void> refreshWallets() async {
try { try {
await ref.read(evmProvider.notifier).refreshWallets(); await ref.read(evmProvider.notifier).refreshWallets();
} catch (error) { } catch (e) {
showMessage(_formatError(error)); showMessage('Failed to refresh wallets: ${_formatError(e)}');
} }
} }
Future<void> createWallet() async { final content = switch (evm) {
if (isCreating.value) {
return;
}
isCreating.value = true;
try {
await ref.read(evmProvider.notifier).createWallet();
showMessage('Wallet created.');
} catch (error) {
showMessage(_formatError(error));
} finally {
isCreating.value = false;
}
}
final content = switch (walletsAsync) {
AsyncLoading() when wallets == null => const _StatePanel( AsyncLoading() when wallets == null => const _StatePanel(
icon: Icons.hourglass_top, icon: Icons.hourglass_top,
title: 'Loading wallets', title: 'Loading wallets',
@@ -70,22 +49,14 @@ class EvmScreen extends HookConsumerWidget {
actionLabel: 'Retry', actionLabel: 'Retry',
onAction: refreshWallets, onAction: refreshWallets,
), ),
_ when !isConnected => _StatePanel( AsyncData(:final value) when value == null => _StatePanel(
icon: Icons.portable_wifi_off, icon: Icons.portable_wifi_off,
title: 'No active server connection', title: 'No active server connection',
body: 'Reconnect to Arbiter to list or create EVM wallets.', body: 'Reconnect to Arbiter to list or create EVM wallets.',
actionLabel: 'Refresh', actionLabel: 'Refresh',
onAction: refreshWallets, onAction: () => refreshWallets(),
), ),
_ when loadedWallets.isEmpty => _StatePanel( _ => WalletTable(wallets: loadedWallets),
icon: Icons.account_balance_wallet_outlined,
title: 'No wallets yet',
body:
'Create the first vault-backed wallet to start building your EVM registry.',
actionLabel: isCreating.value ? 'Creating...' : 'Create wallet',
onAction: isCreating.value ? null : createWallet,
),
_ => _WalletTable(wallets: loadedWallets),
}; };
return Scaffold( return Scaffold(
@@ -102,47 +73,11 @@ class EvmScreen extends HookConsumerWidget {
children: [ children: [
PageHeader( PageHeader(
title: 'EVM Wallet Vault', title: 'EVM Wallet Vault',
isBusy: walletsAsync.isLoading, isBusy: evm.isLoading,
actions: [ actions: [
FilledButton.icon( const CreateWalletButton(),
onPressed: isCreating.value ? null : () => createWallet(),
style: FilledButton.styleFrom(
backgroundColor: Palette.ink,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(
horizontal: 1.4.w,
vertical: 1.2.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: isCreating.value
? SizedBox(
width: 1.6.h,
height: 1.6.h,
child: CircularProgressIndicator(strokeWidth: 2.2),
)
: const Icon(Icons.add_circle_outline, size: 18),
label: Text(isCreating.value ? 'Creating...' : 'Create'),
),
SizedBox(width: 1.w), SizedBox(width: 1.w),
OutlinedButton.icon( const RefreshWalletButton(),
onPressed: () => refreshWallets(),
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'),
),
], ],
), ),
SizedBox(height: 1.8.h), SizedBox(height: 1.8.h),
@@ -155,197 +90,6 @@ class EvmScreen extends HookConsumerWidget {
} }
} }
double get _accentStripWidth => 0.8.w;
double get _cellHorizontalPadding => 1.8.w;
double get _walletColumnWidth => 18.w;
double get _columnGap => 1.8.w;
double get _tableMinWidth => 72.w;
class _WalletTable extends StatelessWidget {
const _WalletTable({required this.wallets});
final List<WalletEntry> wallets;
@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(
'Managed wallets',
style: theme.textTheme.titleLarge?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 0.6.h),
Text(
'Every address here is generated and held by Arbiter.',
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 _WalletTableHeader(),
SizedBox(height: 1.h),
for (var i = 0; i < wallets.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == wallets.length - 1 ? 0 : 1.h,
),
child: _WalletTableRow(
wallet: wallets[i],
index: i,
),
),
],
),
),
),
],
);
},
),
),
);
}
}
class _WalletTableHeader extends StatelessWidget {
const _WalletTableHeader();
@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 + _cellHorizontalPadding),
SizedBox(
width: _walletColumnWidth,
child: Text('Wallet', style: style),
),
SizedBox(width: _columnGap),
Expanded(child: Text('Address', style: style)),
SizedBox(width: _cellHorizontalPadding),
],
),
);
}
}
class _WalletTableRow extends StatelessWidget {
const _WalletTableRow({required this.wallet, required this.index});
final WalletEntry wallet;
final int index;
@override
Widget build(BuildContext context) {
final accent = _accentColor(wallet.address);
final address = _hexAddress(wallet.address);
final rowHeight = 5.h;
final walletStyle = Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: Palette.ink);
final addressStyle = Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Palette.ink);
return Container(
height: rowHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: accent.withValues(alpha: 0.10),
border: Border.all(color: accent.withValues(alpha: 0.28)),
),
child: Row(
children: [
Container(
width: _accentStripWidth,
height: rowHeight,
decoration: BoxDecoration(
color: accent,
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(18),
),
),
),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: _cellHorizontalPadding),
child: Row(
children: [
SizedBox(
width: _walletColumnWidth,
child: Row(
children: [
Container(
width: 1.2.h,
height: 1.2.h,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: accent,
),
),
SizedBox(width: 1.w),
Text(
'Wallet ${(index + 1).toString().padLeft(2, '0')}',
style: walletStyle,
),
],
),
),
SizedBox(width: _columnGap),
Expanded(
child: Text(
address,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: addressStyle,
),
),
],
),
),
),
],
),
);
}
}
class _StatePanel extends StatelessWidget { class _StatePanel extends StatelessWidget {
const _StatePanel({ const _StatePanel({
required this.icon, required this.icon,
@@ -417,19 +161,6 @@ class _StatePanel extends StatelessWidget {
} }
} }
String _hexAddress(List<int> bytes) {
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '0x$hex';
}
Color _accentColor(List<int> bytes) {
final seed = bytes.fold<int>(0, (value, byte) => value + byte);
final hue = (seed * 17) % 360;
return HSLColor.fromAHSL(1, hue.toDouble(), 0.68, 0.54).toColor();
}
String _formatError(Object error) { String _formatError(Object error) {
final message = error.toString(); final message = error.toString();
if (message.startsWith('Exception: ')) { if (message.startsWith('Exception: ')) {

View File

@@ -0,0 +1,98 @@
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
class CreateWalletButton extends ConsumerWidget {
const CreateWalletButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final createWallet = ref.watch(createEvmWallet);
final isCreating = createWallet is MutationPending;
Future<void> handleCreateWallet() async {
try {
await executeCreateEvmWallet(ref);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('New wallet created successfully.'),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to create wallet: ${_formatError(e)}'),
behavior: SnackBarBehavior.floating,
),
);
}
}
return FilledButton.icon(
onPressed: isCreating ? null : () => handleCreateWallet(),
style: FilledButton.styleFrom(
backgroundColor: Palette.ink,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
icon: isCreating
? SizedBox(
width: 1.6.h,
height: 1.6.h,
child: CircularProgressIndicator(strokeWidth: 2.2),
)
: const Icon(Icons.add_circle_outline, size: 18),
label: Text(isCreating ? 'Creating...' : 'Create'),
);
}
}
class RefreshWalletButton extends ConsumerWidget {
const RefreshWalletButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> handleRefreshWallets() async {
try {
await ref.read(evmProvider.notifier).refreshWallets();
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to refresh wallets: ${_formatError(e)}'),
behavior: SnackBarBehavior.floating,
),
);
}
}
return OutlinedButton.icon(
onPressed: () => handleRefreshWallets(),
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'),
);
}
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}

View File

@@ -0,0 +1,209 @@
import 'dart:math' as math;
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
double get _accentStripWidth => 0.8.w;
double get _cellHorizontalPadding => 1.8.w;
double get _walletColumnWidth => 18.w;
double get _columnGap => 1.8.w;
double get _tableMinWidth => 72.w;
String _hexAddress(List<int> bytes) {
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '0x$hex';
}
Color _accentColor(List<int> bytes) {
final seed = bytes.fold<int>(0, (value, byte) => value + byte);
final hue = (seed * 17) % 360;
return HSLColor.fromAHSL(1, hue.toDouble(), 0.68, 0.54).toColor();
}
class WalletTable extends StatelessWidget {
const WalletTable({super.key, required this.wallets});
final List<WalletEntry> wallets;
@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(
'Managed wallets',
style: theme.textTheme.titleLarge?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 0.6.h),
Text(
'Every address here is generated and held by Arbiter.',
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 _WalletTableHeader(),
SizedBox(height: 1.h),
for (var i = 0; i < wallets.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == wallets.length - 1 ? 0 : 1.h,
),
child: _WalletTableRow(
wallet: wallets[i],
index: i,
),
),
],
),
),
),
],
);
},
),
),
);
}
}
class _WalletTableHeader extends StatelessWidget {
const _WalletTableHeader();
@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 + _cellHorizontalPadding),
SizedBox(
width: _walletColumnWidth,
child: Text('Wallet', style: style),
),
SizedBox(width: _columnGap),
Expanded(child: Text('Address', style: style)),
SizedBox(width: _cellHorizontalPadding),
],
),
);
}
}
class _WalletTableRow extends StatelessWidget {
const _WalletTableRow({required this.wallet, required this.index});
final WalletEntry wallet;
final int index;
@override
Widget build(BuildContext context) {
final accent = _accentColor(wallet.address);
final address = _hexAddress(wallet.address);
final rowHeight = 5.h;
final walletStyle = Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: Palette.ink);
final addressStyle = Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Palette.ink);
return Container(
height: rowHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: accent.withValues(alpha: 0.10),
border: Border.all(color: accent.withValues(alpha: 0.28)),
),
child: Row(
children: [
Container(
width: _accentStripWidth,
height: rowHeight,
decoration: BoxDecoration(
color: accent,
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(18),
),
),
),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: _cellHorizontalPadding),
child: Row(
children: [
SizedBox(
width: _walletColumnWidth,
child: Row(
children: [
Container(
width: 1.2.h,
height: 1.2.h,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: accent,
),
),
SizedBox(width: 1.w),
Text(
'Wallet ${(index + 1).toString().padLeft(2, '0')}',
style: walletStyle,
),
],
),
),
SizedBox(width: _columnGap),
Expanded(
child: Text(
address,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: addressStyle,
),
),
],
),
),
),
],
),
);
}
}