feat(useragent::evm): basic list & creation
This commit is contained in:
@@ -4,15 +4,18 @@ import 'dart:convert';
|
||||
import 'package:arbiter/features/connection/server_info_storage.dart';
|
||||
import 'package:arbiter/features/identity/pk_manager.dart';
|
||||
import 'package:arbiter/proto/arbiter.pbgrpc.dart';
|
||||
import 'package:arbiter/proto/evm.pb.dart';
|
||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:grpc/grpc.dart';
|
||||
import 'package:mtcore/markettakers.dart';
|
||||
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
|
||||
|
||||
class Connection {
|
||||
final ClientChannel channel;
|
||||
final StreamController<UserAgentRequest> _tx;
|
||||
final StreamIterator<UserAgentResponse> _rx;
|
||||
Future<void> _requestQueue = Future<void>.value();
|
||||
|
||||
Connection({
|
||||
required this.channel,
|
||||
@@ -69,6 +72,48 @@ List<int> formatChallenge(AuthChallenge challenge, List<int> pubkey) {
|
||||
|
||||
const _vaultKeyAssociatedData = 'arbiter.vault.password';
|
||||
|
||||
Future<List<WalletEntry>> listEvmWallets(Connection connection) async {
|
||||
await connection.send(UserAgentRequest(evmWalletList: Empty()));
|
||||
|
||||
final response = await connection.receive();
|
||||
if (!response.hasEvmWalletList()) {
|
||||
throw Exception(
|
||||
'Expected EVM wallet list response, got ${response.whichPayload()}',
|
||||
);
|
||||
}
|
||||
|
||||
final result = response.evmWalletList;
|
||||
switch (result.whichResult()) {
|
||||
case WalletListResponse_Result.wallets:
|
||||
return result.wallets.wallets.toList(growable: false);
|
||||
case WalletListResponse_Result.error:
|
||||
throw Exception(_describeEvmError(result.error));
|
||||
case WalletListResponse_Result.notSet:
|
||||
throw Exception('EVM wallet list response was empty.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createEvmWallet(Connection connection) async {
|
||||
await connection.send(UserAgentRequest(evmWalletCreate: Empty()));
|
||||
|
||||
final response = await connection.receive();
|
||||
if (!response.hasEvmWalletCreate()) {
|
||||
throw Exception(
|
||||
'Expected EVM wallet create response, got ${response.whichPayload()}',
|
||||
);
|
||||
}
|
||||
|
||||
final result = response.evmWalletCreate;
|
||||
switch (result.whichResult()) {
|
||||
case WalletCreateResponse_Result.wallet:
|
||||
return;
|
||||
case WalletCreateResponse_Result.error:
|
||||
throw Exception(_describeEvmError(result.error));
|
||||
case WalletCreateResponse_Result.notSet:
|
||||
throw Exception('Wallet creation returned no result.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<BootstrapResult> bootstrapVault(
|
||||
Connection connection,
|
||||
String password,
|
||||
@@ -241,3 +286,13 @@ Future<Connection> connectAndAuthorize(
|
||||
throw Exception('Failed to connect to server: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _describeEvmError(EvmError error) {
|
||||
return switch (error) {
|
||||
EvmError.EVM_ERROR_VAULT_SEALED =>
|
||||
'The vault is sealed. Unseal it before using EVM wallets.',
|
||||
EvmError.EVM_ERROR_INTERNAL || EvmError.EVM_ERROR_UNSPECIFIED =>
|
||||
'The server failed to process the EVM request.',
|
||||
_ => 'The server failed to process the EVM request.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import 'package:arbiter/features/connection/connection.dart';
|
||||
import 'package:arbiter/proto/evm.pb.dart';
|
||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'evm.g.dart';
|
||||
|
||||
@riverpod
|
||||
class Evm extends _$Evm {
|
||||
@override
|
||||
Future<List<WalletEntry>?> build() async {
|
||||
final connection = await ref.watch(connectionManagerProvider.future);
|
||||
if (connection == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await connection.send(UserAgentRequest(
|
||||
evmWalletList: Empty()
|
||||
));
|
||||
return listEvmWallets(connection);
|
||||
}
|
||||
|
||||
final response = await connection.receive();
|
||||
Future<void> refreshWallets() async {
|
||||
final connection = await ref.read(connectionManagerProvider.future);
|
||||
if (connection == null) {
|
||||
state = const AsyncData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => listEvmWallets(connection));
|
||||
}
|
||||
|
||||
Future<void> createWallet() async {
|
||||
final connection = await ref.read(connectionManagerProvider.future);
|
||||
if (connection == null) {
|
||||
throw Exception('Not connected to the server.');
|
||||
}
|
||||
|
||||
await createEvmWallet(connection);
|
||||
state = await AsyncValue.guard(() => listEvmWallets(connection));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ final class EvmProvider
|
||||
Evm create() => Evm();
|
||||
}
|
||||
|
||||
String _$evmHash() => r'6d2e0baf7b78a0850d7b99b0be7abde206e088c7';
|
||||
String _$evmHash() => r'f5d05bfa7b820d0b96026a47ca47702a3793af5d';
|
||||
|
||||
abstract class _$Evm extends $AsyncNotifier<List<WalletEntry>?> {
|
||||
FutureOr<List<WalletEntry>?> build();
|
||||
|
||||
@@ -15,8 +15,8 @@ class Router extends RootStackRouter {
|
||||
page: DashboardRouter.page,
|
||||
path: '/dashboard',
|
||||
children: [
|
||||
AutoRoute(page: EvmRoute.page, path: 'evm'),
|
||||
AutoRoute(page: AboutRoute.page, path: 'about'),
|
||||
AutoRoute(page: CalcRoute.page, path: 'calc'),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:arbiter/screens/bootstrap.dart' as _i2;
|
||||
import 'package:arbiter/screens/dashboard.dart' as _i4;
|
||||
import 'package:arbiter/screens/dashboard.dart' as _i3;
|
||||
import 'package:arbiter/screens/dashboard/about.dart' as _i1;
|
||||
import 'package:arbiter/screens/dashboard/calc.dart' as _i3;
|
||||
import 'package:arbiter/screens/dashboard/evm.dart' as _i4;
|
||||
import 'package:arbiter/screens/server_connection.dart' as _i5;
|
||||
import 'package:arbiter/screens/server_info_setup.dart' as _i6;
|
||||
import 'package:arbiter/screens/vault_setup.dart' as _i7;
|
||||
@@ -52,23 +52,7 @@ class Bootstrap extends _i8.PageRouteInfo<void> {
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i3.CalcScreen]
|
||||
class CalcRoute extends _i8.PageRouteInfo<void> {
|
||||
const CalcRoute({List<_i8.PageRouteInfo>? children})
|
||||
: super(CalcRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'CalcRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i3.CalcScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i4.DashboardRouter]
|
||||
/// [_i3.DashboardRouter]
|
||||
class DashboardRouter extends _i8.PageRouteInfo<void> {
|
||||
const DashboardRouter({List<_i8.PageRouteInfo>? children})
|
||||
: super(DashboardRouter.name, initialChildren: children);
|
||||
@@ -78,7 +62,23 @@ class DashboardRouter extends _i8.PageRouteInfo<void> {
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i4.DashboardRouter();
|
||||
return const _i3.DashboardRouter();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i4.EvmScreen]
|
||||
class EvmRoute extends _i8.PageRouteInfo<void> {
|
||||
const EvmRoute({List<_i8.PageRouteInfo>? children})
|
||||
: super(EvmRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'EvmRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i4.EvmScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
|
||||
|
||||
const breakpoints = MaterialAdaptiveBreakpoints();
|
||||
|
||||
final routes = [AboutRoute(), CalcRoute()];
|
||||
final routes = [EvmRoute(), AboutRoute()];
|
||||
|
||||
@RoutePage()
|
||||
class DashboardRouter extends StatelessWidget {
|
||||
@@ -24,16 +24,24 @@ class DashboardRouter extends StatelessWidget {
|
||||
final tabsRouter = AutoTabsRouter.of(context);
|
||||
final currentActive = tabsRouter.activeIndex;
|
||||
return AdaptiveScaffold(
|
||||
destinations: [
|
||||
NavigationDestination(icon: Icon(Icons.book), label: "About"),
|
||||
NavigationDestination(icon: Icon(Icons.calculate), label: "Calc"),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.account_balance_wallet_outlined),
|
||||
selectedIcon: Icon(Icons.account_balance_wallet),
|
||||
label: "Wallets",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.info_outline),
|
||||
selectedIcon: Icon(Icons.info),
|
||||
label: "About",
|
||||
),
|
||||
],
|
||||
body: (ctx) => child,
|
||||
onSelectedIndexChange: (index) {
|
||||
tabsRouter.navigate(routes[index]);
|
||||
},
|
||||
selectedIndex: currentActive,
|
||||
transitionDuration: Duration(milliseconds: 800),
|
||||
transitionDuration: const Duration(milliseconds: 800),
|
||||
internalAnimations: true,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@RoutePage()
|
||||
class CalcScreen extends StatefulWidget {
|
||||
const CalcScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CalcScreen> createState() => _CalcScreenState();
|
||||
}
|
||||
|
||||
class _CalcScreenState extends State<CalcScreen> {
|
||||
int _count = 0;
|
||||
|
||||
void _increment() {
|
||||
setState(() {
|
||||
_count++;
|
||||
});
|
||||
}
|
||||
|
||||
void _decrement() {
|
||||
setState(() {
|
||||
_count--;
|
||||
});
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
setState(() {
|
||||
_count = 0;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Counter'),
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Current count'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$_count',
|
||||
style: Theme.of(context).textTheme.displaySmall,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _decrement,
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
tooltip: 'Decrement',
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _reset,
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
onPressed: _increment,
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
tooltip: 'Increment',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
489
useragent/lib/screens/dashboard/evm.dart
Normal file
489
useragent/lib/screens/dashboard/evm.dart
Normal file
@@ -0,0 +1,489 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:arbiter/proto/evm.pb.dart';
|
||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||
import 'package:arbiter/providers/evm.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sizer/sizer.dart';
|
||||
|
||||
@RoutePage()
|
||||
class EvmScreen extends HookConsumerWidget {
|
||||
const EvmScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final walletsAsync = ref.watch(evmProvider);
|
||||
final isCreating = useState(false);
|
||||
|
||||
final wallets = walletsAsync.asData?.value;
|
||||
final loadedWallets = wallets ?? const <WalletEntry>[];
|
||||
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> refreshWallets() async {
|
||||
try {
|
||||
await ref.read(evmProvider.notifier).refreshWallets();
|
||||
} catch (error) {
|
||||
showMessage(_formatError(error));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createWallet() async {
|
||||
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(
|
||||
icon: Icons.hourglass_top,
|
||||
title: 'Loading wallets',
|
||||
body: 'Pulling wallet registry from Arbiter.',
|
||||
busy: true,
|
||||
),
|
||||
AsyncError(:final error) => _StatePanel(
|
||||
icon: Icons.sync_problem,
|
||||
title: 'Wallet registry unavailable',
|
||||
body: _formatError(error),
|
||||
actionLabel: 'Retry',
|
||||
onAction: refreshWallets,
|
||||
),
|
||||
_ when !isConnected => _StatePanel(
|
||||
icon: Icons.portable_wifi_off,
|
||||
title: 'No active server connection',
|
||||
body: 'Reconnect to Arbiter to list or create EVM wallets.',
|
||||
actionLabel: 'Refresh',
|
||||
onAction: refreshWallets,
|
||||
),
|
||||
_ when loadedWallets.isEmpty => _StatePanel(
|
||||
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(
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator.adaptive(
|
||||
color: _Palette.ink,
|
||||
backgroundColor: Colors.white,
|
||||
onRefresh: refreshWallets,
|
||||
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: walletsAsync.isLoading,
|
||||
isCreating: isCreating.value,
|
||||
onCreate: createWallet,
|
||||
onRefresh: refreshWallets,
|
||||
),
|
||||
SizedBox(height: 1.8.h),
|
||||
content,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Palette {
|
||||
static const ink = Color(0xFF15263C);
|
||||
static const coral = Color(0xFFE26254);
|
||||
static const cream = Color(0xFFFFFAF4);
|
||||
static const line = Color(0x1A15263C);
|
||||
}
|
||||
|
||||
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 _Header extends StatelessWidget {
|
||||
const _Header({
|
||||
required this.isBusy,
|
||||
required this.isCreating,
|
||||
required this.onCreate,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
final bool isBusy;
|
||||
final bool isCreating;
|
||||
final Future<void> Function() onCreate;
|
||||
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(
|
||||
'EVM Wallet Vault',
|
||||
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),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: isCreating ? null : () => onCreate(),
|
||||
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'),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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: 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!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
final message = error.toString();
|
||||
if (message.startsWith('Exception: ')) {
|
||||
return message.substring('Exception: '.length);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||
import 'package:arbiter/router.gr.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sizer/sizer.dart';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user