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/connection/server_info_storage.dart';
|
||||||
import 'package:arbiter/features/identity/pk_manager.dart';
|
import 'package:arbiter/features/identity/pk_manager.dart';
|
||||||
import 'package:arbiter/proto/arbiter.pbgrpc.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:arbiter/proto/user_agent.pb.dart';
|
||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
import 'package:grpc/grpc.dart';
|
import 'package:grpc/grpc.dart';
|
||||||
import 'package:mtcore/markettakers.dart';
|
import 'package:mtcore/markettakers.dart';
|
||||||
|
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
|
||||||
|
|
||||||
class Connection {
|
class Connection {
|
||||||
final ClientChannel channel;
|
final ClientChannel channel;
|
||||||
final StreamController<UserAgentRequest> _tx;
|
final StreamController<UserAgentRequest> _tx;
|
||||||
final StreamIterator<UserAgentResponse> _rx;
|
final StreamIterator<UserAgentResponse> _rx;
|
||||||
|
Future<void> _requestQueue = Future<void>.value();
|
||||||
|
|
||||||
Connection({
|
Connection({
|
||||||
required this.channel,
|
required this.channel,
|
||||||
@@ -69,6 +72,48 @@ List<int> formatChallenge(AuthChallenge challenge, List<int> pubkey) {
|
|||||||
|
|
||||||
const _vaultKeyAssociatedData = 'arbiter.vault.password';
|
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(
|
Future<BootstrapResult> bootstrapVault(
|
||||||
Connection connection,
|
Connection connection,
|
||||||
String password,
|
String password,
|
||||||
@@ -241,3 +286,13 @@ Future<Connection> connectAndAuthorize(
|
|||||||
throw Exception('Failed to connect to server: $e');
|
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/evm.pb.dart';
|
||||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
|
||||||
import 'package:arbiter/providers/connection/connection_manager.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';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
part 'evm.g.dart';
|
part 'evm.g.dart';
|
||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
class Evm extends _$Evm {
|
class Evm extends _$Evm {
|
||||||
|
@override
|
||||||
Future<List<WalletEntry>?> build() async {
|
Future<List<WalletEntry>?> build() async {
|
||||||
final connection = await ref.watch(connectionManagerProvider.future);
|
final connection = await ref.watch(connectionManagerProvider.future);
|
||||||
if (connection == null) {
|
if (connection == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await connection.send(UserAgentRequest(
|
return listEvmWallets(connection);
|
||||||
evmWalletList: Empty()
|
}
|
||||||
));
|
|
||||||
|
|
||||||
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();
|
Evm create() => Evm();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$evmHash() => r'6d2e0baf7b78a0850d7b99b0be7abde206e088c7';
|
String _$evmHash() => r'f5d05bfa7b820d0b96026a47ca47702a3793af5d';
|
||||||
|
|
||||||
abstract class _$Evm extends $AsyncNotifier<List<WalletEntry>?> {
|
abstract class _$Evm extends $AsyncNotifier<List<WalletEntry>?> {
|
||||||
FutureOr<List<WalletEntry>?> build();
|
FutureOr<List<WalletEntry>?> build();
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ class Router extends RootStackRouter {
|
|||||||
page: DashboardRouter.page,
|
page: DashboardRouter.page,
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
children: [
|
children: [
|
||||||
|
AutoRoute(page: EvmRoute.page, path: 'evm'),
|
||||||
AutoRoute(page: AboutRoute.page, path: 'about'),
|
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
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
import 'package:arbiter/screens/bootstrap.dart' as _i2;
|
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/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_connection.dart' as _i5;
|
||||||
import 'package:arbiter/screens/server_info_setup.dart' as _i6;
|
import 'package:arbiter/screens/server_info_setup.dart' as _i6;
|
||||||
import 'package:arbiter/screens/vault_setup.dart' as _i7;
|
import 'package:arbiter/screens/vault_setup.dart' as _i7;
|
||||||
@@ -52,23 +52,7 @@ class Bootstrap extends _i8.PageRouteInfo<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i3.CalcScreen]
|
/// [_i3.DashboardRouter]
|
||||||
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]
|
|
||||||
class DashboardRouter extends _i8.PageRouteInfo<void> {
|
class DashboardRouter extends _i8.PageRouteInfo<void> {
|
||||||
const DashboardRouter({List<_i8.PageRouteInfo>? children})
|
const DashboardRouter({List<_i8.PageRouteInfo>? children})
|
||||||
: super(DashboardRouter.name, initialChildren: children);
|
: super(DashboardRouter.name, initialChildren: children);
|
||||||
@@ -78,7 +62,23 @@ class DashboardRouter extends _i8.PageRouteInfo<void> {
|
|||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i8.PageInfo page = _i8.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
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();
|
const breakpoints = MaterialAdaptiveBreakpoints();
|
||||||
|
|
||||||
final routes = [AboutRoute(), CalcRoute()];
|
final routes = [EvmRoute(), AboutRoute()];
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class DashboardRouter extends StatelessWidget {
|
class DashboardRouter extends StatelessWidget {
|
||||||
@@ -24,17 +24,25 @@ class DashboardRouter extends StatelessWidget {
|
|||||||
final tabsRouter = AutoTabsRouter.of(context);
|
final tabsRouter = AutoTabsRouter.of(context);
|
||||||
final currentActive = tabsRouter.activeIndex;
|
final currentActive = tabsRouter.activeIndex;
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
destinations: [
|
destinations: const [
|
||||||
NavigationDestination(icon: Icon(Icons.book), label: "About"),
|
NavigationDestination(
|
||||||
NavigationDestination(icon: Icon(Icons.calculate), label: "Calc"),
|
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,
|
body: (ctx) => child,
|
||||||
onSelectedIndexChange: (index) {
|
onSelectedIndexChange: (index) {
|
||||||
tabsRouter.navigate(routes[index]);
|
tabsRouter.navigate(routes[index]);
|
||||||
},
|
},
|
||||||
selectedIndex: currentActive,
|
selectedIndex: currentActive,
|
||||||
transitionDuration: Duration(milliseconds: 800),
|
transitionDuration: const Duration(milliseconds: 800),
|
||||||
internalAnimations: true,
|
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:arbiter/router.gr.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';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user