From 54b2183be555212e17ca6a3c6b687fe17e5543dd Mon Sep 17 00:00:00 2001 From: hdbg Date: Sat, 28 Mar 2026 14:00:13 +0100 Subject: [PATCH] feat(evm): add EVM grants screen with create UI and list --- .../memory/feedback_widget_decomposition.md | 11 + .gitignore | 1 + .../lib/features/connection/evm/grants.dart | 28 ++- .../connection/evm/wallet_access.dart | 16 +- useragent/lib/providers/evm/evm_grants.dart | 19 +- .../sdk_clients/wallet_access_list.dart | 22 ++ .../sdk_clients/wallet_access_list.g.dart | 51 ++++ useragent/lib/router.dart | 1 + useragent/lib/router.gr.dart | 133 +++++----- useragent/lib/screens/dashboard.dart | 12 +- .../dashboard/evm/grants/grant_create.dart | 168 +++++++++---- .../screens/dashboard/evm/grants/grants.dart | 231 ++++++++++++++++++ .../evm/grants/widgets/grant_card.dart | 225 +++++++++++++++++ useragent/lib/theme/palette.dart | 1 + 14 files changed, 789 insertions(+), 130 deletions(-) create mode 100644 .claude/memory/feedback_widget_decomposition.md create mode 100644 useragent/lib/providers/sdk_clients/wallet_access_list.dart create mode 100644 useragent/lib/providers/sdk_clients/wallet_access_list.g.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/grants.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart diff --git a/.claude/memory/feedback_widget_decomposition.md b/.claude/memory/feedback_widget_decomposition.md new file mode 100644 index 0000000..a6ea5f0 --- /dev/null +++ b/.claude/memory/feedback_widget_decomposition.md @@ -0,0 +1,11 @@ +--- +name: Widget decomposition and provider subscriptions +description: Prefer splitting screens into multiple focused files/widgets; each widget subscribes to its own relevant providers +type: feedback +--- + +Split screens into multiple smaller widgets across multiple files. Each widget should subscribe only to the providers it needs (`ref.watch` at lowest possible level), rather than having one large screen widget that watches everything and passes data down as parameters. + +**Why:** Reduces unnecessary rebuilds; improves readability; each file has one clear responsibility. + +**How to apply:** When building a new screen, identify which sub-widgets need their own provider subscriptions and extract them into separate files (e.g., `widgets/grant_card.dart` watches enrichment providers itself, rather than the screen doing it and passing resolved strings down). diff --git a/.gitignore b/.gitignore index 57db88f..6777228 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ scripts/__pycache__/ .DS_Store .cargo/config.toml .vscode/ +docs/ diff --git a/useragent/lib/features/connection/evm/grants.dart b/useragent/lib/features/connection/evm/grants.dart index 168644d..050a1d2 100644 --- a/useragent/lib/features/connection/evm/grants.dart +++ b/useragent/lib/features/connection/evm/grants.dart @@ -29,17 +29,27 @@ Future> listEvmGrants(Connection connection) async { Future createEvmGrant( Connection connection, { - required int clientId, - required int walletId, - required Int64 chainId, - DateTime? validFrom, - DateTime? validUntil, - List? maxGasFeePerGas, - List? maxPriorityFeePerGas, - TransactionRateLimit? rateLimit, + required SharedSettings sharedSettings, required SpecificGrant specific, }) async { - throw UnimplementedError('EVM grant creation is not yet implemented.'); + final request = UserAgentRequest( + evmGrantCreate: EvmGrantCreateRequest( + shared: sharedSettings, + specific: specific, + ), + ); + + final resp = await connection.ask(request); + + if (!resp.hasEvmGrantCreate()) { + throw Exception( + 'Expected EVM grant create response, got ${resp.whichPayload()}', + ); + } + + final result = resp.evmGrantCreate; + + return result.grantId; } Future deleteEvmGrant(Connection connection, int grantId) async { diff --git a/useragent/lib/features/connection/evm/wallet_access.dart b/useragent/lib/features/connection/evm/wallet_access.dart index 66dbb56..8f38344 100644 --- a/useragent/lib/features/connection/evm/wallet_access.dart +++ b/useragent/lib/features/connection/evm/wallet_access.dart @@ -16,10 +16,24 @@ Future> readClientWalletAccess( } return { for (final entry in response.listWalletAccessResponse.accesses) - if (entry.access != null && entry.access.sdkClientId == clientId) entry.access.walletId, + if (entry.access.sdkClientId == clientId) entry.access.walletId, }; } +Future> listAllWalletAccesses( + Connection connection, +) async { + final response = await connection.ask( + UserAgentRequest(listWalletAccess: Empty()), + ); + if (!response.hasListWalletAccessResponse()) { + throw Exception( + 'Expected list wallet access response, got ${response.whichPayload()}', + ); + } + return response.listWalletAccessResponse.accesses.toList(growable: false); +} + Future writeClientWalletAccess( Connection connection, { required int clientId, diff --git a/useragent/lib/providers/evm/evm_grants.dart b/useragent/lib/providers/evm/evm_grants.dart index ae4a817..6d7747e 100644 --- a/useragent/lib/providers/evm/evm_grants.dart +++ b/useragent/lib/providers/evm/evm_grants.dart @@ -5,6 +5,7 @@ import 'package:fixnum/fixnum.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/experimental/mutation.dart'; import 'package:mtcore/markettakers.dart'; +import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'evm_grants.freezed.dart'; @@ -73,14 +74,7 @@ class EvmGrants extends _$EvmGrants { Future executeCreateEvmGrant( MutationTarget ref, { - required int clientId, - required int walletId, - required Int64 chainId, - DateTime? validFrom, - DateTime? validUntil, - List? maxGasFeePerGas, - List? maxPriorityFeePerGas, - TransactionRateLimit? rateLimit, + required SharedSettings sharedSettings, required SpecificGrant specific, }) { return createEvmGrantMutation.run(ref, (tsx) async { @@ -91,14 +85,7 @@ Future executeCreateEvmGrant( final grantId = await createEvmGrant( connection, - clientId: clientId, - walletId: walletId, - chainId: chainId, - validFrom: validFrom, - validUntil: validUntil, - maxGasFeePerGas: maxGasFeePerGas, - maxPriorityFeePerGas: maxPriorityFeePerGas, - rateLimit: rateLimit, + sharedSettings: sharedSettings, specific: specific, ); diff --git a/useragent/lib/providers/sdk_clients/wallet_access_list.dart b/useragent/lib/providers/sdk_clients/wallet_access_list.dart new file mode 100644 index 0000000..f126c97 --- /dev/null +++ b/useragent/lib/providers/sdk_clients/wallet_access_list.dart @@ -0,0 +1,22 @@ +import 'package:arbiter/features/connection/evm/wallet_access.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:mtcore/markettakers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'wallet_access_list.g.dart'; + +@riverpod +Future?> walletAccessList(Ref ref) async { + final connection = await ref.watch(connectionManagerProvider.future); + if (connection == null) { + return null; + } + + try { + return await listAllWalletAccesses(connection); + } catch (e, st) { + talker.handle(e, st); + rethrow; + } +} diff --git a/useragent/lib/providers/sdk_clients/wallet_access_list.g.dart b/useragent/lib/providers/sdk_clients/wallet_access_list.g.dart new file mode 100644 index 0000000..314ce1d --- /dev/null +++ b/useragent/lib/providers/sdk_clients/wallet_access_list.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'wallet_access_list.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(walletAccessList) +final walletAccessListProvider = WalletAccessListProvider._(); + +final class WalletAccessListProvider + extends + $FunctionalProvider< + AsyncValue?>, + List?, + FutureOr?> + > + with + $FutureModifier?>, + $FutureProvider?> { + WalletAccessListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'walletAccessListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$walletAccessListHash(); + + @$internal + @override + $FutureProviderElement?> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr?> create(Ref ref) { + return walletAccessList(ref); + } +} + +String _$walletAccessListHash() => r'c06006d6792ae463105a539723e9bb396192f96b'; diff --git a/useragent/lib/router.dart b/useragent/lib/router.dart index 5342ff5..ab06b07 100644 --- a/useragent/lib/router.dart +++ b/useragent/lib/router.dart @@ -19,6 +19,7 @@ class Router extends RootStackRouter { children: [ AutoRoute(page: EvmRoute.page, path: 'evm'), AutoRoute(page: ClientsRoute.page, path: 'clients'), + AutoRoute(page: EvmGrantsRoute.page, path: 'grants'), AutoRoute(page: AboutRoute.page, path: 'about'), ], ), diff --git a/useragent/lib/router.gr.dart b/useragent/lib/router.gr.dart index b661a9d..e4d05bb 100644 --- a/useragent/lib/router.gr.dart +++ b/useragent/lib/router.gr.dart @@ -9,7 +9,7 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:arbiter/proto/user_agent.pb.dart' as _i14; +import 'package:arbiter/proto/user_agent.pb.dart' as _i15; import 'package:arbiter/screens/bootstrap.dart' as _i2; import 'package:arbiter/screens/dashboard.dart' as _i7; import 'package:arbiter/screens/dashboard/about.dart' as _i1; @@ -17,23 +17,24 @@ import 'package:arbiter/screens/dashboard/clients/details.dart' as _i3; import 'package:arbiter/screens/dashboard/clients/details/client_details.dart' as _i4; import 'package:arbiter/screens/dashboard/clients/table.dart' as _i5; -import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i8; +import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i9; import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i6; -import 'package:arbiter/screens/server_connection.dart' as _i9; -import 'package:arbiter/screens/server_info_setup.dart' as _i10; -import 'package:arbiter/screens/vault_setup.dart' as _i11; -import 'package:auto_route/auto_route.dart' as _i12; -import 'package:flutter/material.dart' as _i13; +import 'package:arbiter/screens/dashboard/evm/grants/grants.dart' as _i8; +import 'package:arbiter/screens/server_connection.dart' as _i10; +import 'package:arbiter/screens/server_info_setup.dart' as _i11; +import 'package:arbiter/screens/vault_setup.dart' as _i12; +import 'package:auto_route/auto_route.dart' as _i13; +import 'package:flutter/material.dart' as _i14; /// generated route for /// [_i1.AboutScreen] -class AboutRoute extends _i12.PageRouteInfo { - const AboutRoute({List<_i12.PageRouteInfo>? children}) +class AboutRoute extends _i13.PageRouteInfo { + const AboutRoute({List<_i13.PageRouteInfo>? children}) : super(AboutRoute.name, initialChildren: children); static const String name = 'AboutRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { return const _i1.AboutScreen(); @@ -43,13 +44,13 @@ class AboutRoute extends _i12.PageRouteInfo { /// generated route for /// [_i2.Bootstrap] -class Bootstrap extends _i12.PageRouteInfo { - const Bootstrap({List<_i12.PageRouteInfo>? children}) +class Bootstrap extends _i13.PageRouteInfo { + const Bootstrap({List<_i13.PageRouteInfo>? children}) : super(Bootstrap.name, initialChildren: children); static const String name = 'Bootstrap'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { return const _i2.Bootstrap(); @@ -59,11 +60,11 @@ class Bootstrap extends _i12.PageRouteInfo { /// generated route for /// [_i3.ClientDetails] -class ClientDetails extends _i12.PageRouteInfo { +class ClientDetails extends _i13.PageRouteInfo { ClientDetails({ - _i13.Key? key, - required _i14.SdkClientEntry client, - List<_i12.PageRouteInfo>? children, + _i14.Key? key, + required _i15.SdkClientEntry client, + List<_i13.PageRouteInfo>? children, }) : super( ClientDetails.name, args: ClientDetailsArgs(key: key, client: client), @@ -72,7 +73,7 @@ class ClientDetails extends _i12.PageRouteInfo { static const String name = 'ClientDetails'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -84,9 +85,9 @@ class ClientDetails extends _i12.PageRouteInfo { class ClientDetailsArgs { const ClientDetailsArgs({this.key, required this.client}); - final _i13.Key? key; + final _i14.Key? key; - final _i14.SdkClientEntry client; + final _i15.SdkClientEntry client; @override String toString() { @@ -106,11 +107,11 @@ class ClientDetailsArgs { /// generated route for /// [_i4.ClientDetailsScreen] -class ClientDetailsRoute extends _i12.PageRouteInfo { +class ClientDetailsRoute extends _i13.PageRouteInfo { ClientDetailsRoute({ - _i13.Key? key, + _i14.Key? key, required int clientId, - List<_i12.PageRouteInfo>? children, + List<_i13.PageRouteInfo>? children, }) : super( ClientDetailsRoute.name, args: ClientDetailsRouteArgs(key: key, clientId: clientId), @@ -120,7 +121,7 @@ class ClientDetailsRoute extends _i12.PageRouteInfo { static const String name = 'ClientDetailsRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { final pathParams = data.inheritedPathParams; @@ -136,7 +137,7 @@ class ClientDetailsRoute extends _i12.PageRouteInfo { class ClientDetailsRouteArgs { const ClientDetailsRouteArgs({this.key, required this.clientId}); - final _i13.Key? key; + final _i14.Key? key; final int clientId; @@ -158,13 +159,13 @@ class ClientDetailsRouteArgs { /// generated route for /// [_i5.ClientsScreen] -class ClientsRoute extends _i12.PageRouteInfo { - const ClientsRoute({List<_i12.PageRouteInfo>? children}) +class ClientsRoute extends _i13.PageRouteInfo { + const ClientsRoute({List<_i13.PageRouteInfo>? children}) : super(ClientsRoute.name, initialChildren: children); static const String name = 'ClientsRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { return const _i5.ClientsScreen(); @@ -174,13 +175,13 @@ class ClientsRoute extends _i12.PageRouteInfo { /// generated route for /// [_i6.CreateEvmGrantScreen] -class CreateEvmGrantRoute extends _i12.PageRouteInfo { - const CreateEvmGrantRoute({List<_i12.PageRouteInfo>? children}) +class CreateEvmGrantRoute extends _i13.PageRouteInfo { + const CreateEvmGrantRoute({List<_i13.PageRouteInfo>? children}) : super(CreateEvmGrantRoute.name, initialChildren: children); static const String name = 'CreateEvmGrantRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { return const _i6.CreateEvmGrantScreen(); @@ -190,13 +191,13 @@ class CreateEvmGrantRoute extends _i12.PageRouteInfo { /// generated route for /// [_i7.DashboardRouter] -class DashboardRouter extends _i12.PageRouteInfo { - const DashboardRouter({List<_i12.PageRouteInfo>? children}) +class DashboardRouter extends _i13.PageRouteInfo { + const DashboardRouter({List<_i13.PageRouteInfo>? children}) : super(DashboardRouter.name, initialChildren: children); static const String name = 'DashboardRouter'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { return const _i7.DashboardRouter(); @@ -205,29 +206,45 @@ class DashboardRouter extends _i12.PageRouteInfo { } /// generated route for -/// [_i8.EvmScreen] -class EvmRoute extends _i12.PageRouteInfo { - const EvmRoute({List<_i12.PageRouteInfo>? children}) - : super(EvmRoute.name, initialChildren: children); +/// [_i8.EvmGrantsScreen] +class EvmGrantsRoute extends _i13.PageRouteInfo { + const EvmGrantsRoute({List<_i13.PageRouteInfo>? children}) + : super(EvmGrantsRoute.name, initialChildren: children); - static const String name = 'EvmRoute'; + static const String name = 'EvmGrantsRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { - return const _i8.EvmScreen(); + return const _i8.EvmGrantsScreen(); }, ); } /// generated route for -/// [_i9.ServerConnectionScreen] +/// [_i9.EvmScreen] +class EvmRoute extends _i13.PageRouteInfo { + const EvmRoute({List<_i13.PageRouteInfo>? children}) + : super(EvmRoute.name, initialChildren: children); + + static const String name = 'EvmRoute'; + + static _i13.PageInfo page = _i13.PageInfo( + name, + builder: (data) { + return const _i9.EvmScreen(); + }, + ); +} + +/// generated route for +/// [_i10.ServerConnectionScreen] class ServerConnectionRoute - extends _i12.PageRouteInfo { + extends _i13.PageRouteInfo { ServerConnectionRoute({ - _i13.Key? key, + _i14.Key? key, String? arbiterUrl, - List<_i12.PageRouteInfo>? children, + List<_i13.PageRouteInfo>? children, }) : super( ServerConnectionRoute.name, args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl), @@ -236,13 +253,13 @@ class ServerConnectionRoute static const String name = 'ServerConnectionRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { final args = data.argsAs( orElse: () => const ServerConnectionRouteArgs(), ); - return _i9.ServerConnectionScreen( + return _i10.ServerConnectionScreen( key: args.key, arbiterUrl: args.arbiterUrl, ); @@ -253,7 +270,7 @@ class ServerConnectionRoute class ServerConnectionRouteArgs { const ServerConnectionRouteArgs({this.key, this.arbiterUrl}); - final _i13.Key? key; + final _i14.Key? key; final String? arbiterUrl; @@ -274,33 +291,33 @@ class ServerConnectionRouteArgs { } /// generated route for -/// [_i10.ServerInfoSetupScreen] -class ServerInfoSetupRoute extends _i12.PageRouteInfo { - const ServerInfoSetupRoute({List<_i12.PageRouteInfo>? children}) +/// [_i11.ServerInfoSetupScreen] +class ServerInfoSetupRoute extends _i13.PageRouteInfo { + const ServerInfoSetupRoute({List<_i13.PageRouteInfo>? children}) : super(ServerInfoSetupRoute.name, initialChildren: children); static const String name = 'ServerInfoSetupRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { - return const _i10.ServerInfoSetupScreen(); + return const _i11.ServerInfoSetupScreen(); }, ); } /// generated route for -/// [_i11.VaultSetupScreen] -class VaultSetupRoute extends _i12.PageRouteInfo { - const VaultSetupRoute({List<_i12.PageRouteInfo>? children}) +/// [_i12.VaultSetupScreen] +class VaultSetupRoute extends _i13.PageRouteInfo { + const VaultSetupRoute({List<_i13.PageRouteInfo>? children}) : super(VaultSetupRoute.name, initialChildren: children); static const String name = 'VaultSetupRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { - return const _i11.VaultSetupScreen(); + return const _i12.VaultSetupScreen(); }, ); } diff --git a/useragent/lib/screens/dashboard.dart b/useragent/lib/screens/dashboard.dart index acfb828..55d97c6 100644 --- a/useragent/lib/screens/dashboard.dart +++ b/useragent/lib/screens/dashboard.dart @@ -9,7 +9,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; const breakpoints = MaterialAdaptiveBreakpoints(); -final routes = [const EvmRoute(), const ClientsRoute(), const AboutRoute()]; +final routes = [ + const EvmRoute(), + const ClientsRoute(), + const EvmGrantsRoute(), + const AboutRoute(), +]; @RoutePage() class DashboardRouter extends StatelessWidget { @@ -38,6 +43,11 @@ class DashboardRouter extends StatelessWidget { selectedIcon: Icon(Icons.devices_other), label: "Clients", ), + NavigationDestination( + icon: Icon(Icons.policy_outlined), + selectedIcon: Icon(Icons.policy), + label: "Grants", + ), NavigationDestination( icon: Icon(Icons.info_outline), selectedIcon: Icon(Icons.info), diff --git a/useragent/lib/screens/dashboard/evm/grants/grant_create.dart b/useragent/lib/screens/dashboard/evm/grants/grant_create.dart index 4cb27a4..7a11d5c 100644 --- a/useragent/lib/screens/dashboard/evm/grants/grant_create.dart +++ b/useragent/lib/screens/dashboard/evm/grants/grant_create.dart @@ -1,12 +1,16 @@ import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:arbiter/providers/evm/evm.dart'; import 'package:arbiter/providers/evm/evm_grants.dart'; +import 'package:arbiter/providers/sdk_clients/list.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart'; import 'package:auto_route/auto_route.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/experimental/mutation.dart'; +import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; import 'package:sizer/sizer.dart'; @RoutePage() @@ -15,11 +19,10 @@ class CreateEvmGrantScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final wallets = ref.watch(evmProvider).asData?.value ?? const []; final createMutation = ref.watch(createEvmGrantMutation); - final selectedWalletIndex = useState(wallets.isEmpty ? null : 0); - final clientIdController = useTextEditingController(); + final selectedClientId = useState(null); + final selectedWalletAccessId = useState(null); final chainIdController = useTextEditingController(text: '1'); final gasFeeController = useTextEditingController(); final priorityFeeController = useTextEditingController(); @@ -40,14 +43,13 @@ class CreateEvmGrantScreen extends HookConsumerWidget { ]); Future submit() async { - final selectedWallet = selectedWalletIndex.value; - if (selectedWallet == null) { - _showCreateMessage(context, 'At least one wallet is required.'); + final accessId = selectedWalletAccessId.value; + if (accessId == null) { + _showCreateMessage(context, 'Select a client and wallet access.'); return; } try { - final clientId = int.parse(clientIdController.text.trim()); final chainId = Int64.parseInt(chainIdController.text.trim()); final rateLimit = _buildRateLimit( txCountController.text, @@ -83,16 +85,25 @@ class CreateEvmGrantScreen extends HookConsumerWidget { _ => throw Exception('Unsupported grant type.'), }; + final sharedSettings = SharedSettings( + walletAccessId: accessId, + chainId: chainId, + ); + if (validFrom.value != null) { + sharedSettings.validFrom = _toTimestamp(validFrom.value!); + } + if (validUntil.value != null) { + sharedSettings.validUntil = _toTimestamp(validUntil.value!); + } + final gasBytes = _optionalBigIntBytes(gasFeeController.text); + if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes; + final priorityBytes = _optionalBigIntBytes(priorityFeeController.text); + if (priorityBytes != null) sharedSettings.maxPriorityFeePerGas = priorityBytes; + if (rateLimit != null) sharedSettings.rateLimit = rateLimit; + await executeCreateEvmGrant( ref, - clientId: clientId, - walletId: selectedWallet + 1, - chainId: chainId, - validFrom: validFrom.value, - validUntil: validUntil.value, - maxGasFeePerGas: _optionalBigIntBytes(gasFeeController.text), - maxPriorityFeePerGas: _optionalBigIntBytes(priorityFeeController.text), - rateLimit: rateLimit, + sharedSettings: sharedSettings, specific: specific, ); if (!context.mounted) { @@ -113,22 +124,23 @@ class CreateEvmGrantScreen extends HookConsumerWidget { child: ListView( padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h), children: [ - _CreateIntroCard(walletCount: wallets.length), + const _CreateIntroCard(), SizedBox(height: 1.8.h), _CreateSection( title: 'Shared grant options', children: [ - _WalletPickerField( - wallets: wallets, - selectedIndex: selectedWalletIndex.value, - onChanged: (value) => selectedWalletIndex.value = value, + _ClientPickerField( + selectedClientId: selectedClientId.value, + onChanged: (clientId) { + selectedClientId.value = clientId; + selectedWalletAccessId.value = null; + }, ), - _NumberInputField( - controller: clientIdController, - label: 'Client ID', - hint: '42', - helper: - 'Manual for now. The app does not yet expose a client picker.', + _WalletAccessPickerField( + selectedClientId: selectedClientId.value, + selectedAccessId: selectedWalletAccessId.value, + onChanged: (accessId) => + selectedWalletAccessId.value = accessId, ), _NumberInputField( controller: chainIdController, @@ -204,9 +216,7 @@ class CreateEvmGrantScreen extends HookConsumerWidget { } class _CreateIntroCard extends StatelessWidget { - const _CreateIntroCard({required this.walletCount}); - - final int walletCount; + const _CreateIntroCard(); @override Widget build(BuildContext context) { @@ -222,7 +232,7 @@ class _CreateIntroCard extends StatelessWidget { border: Border.all(color: const Color(0x1A17324A)), ), child: Text( - 'Compose shared constraints once, then switch between Ether and token transfer rules. $walletCount wallet${walletCount == 1 ? '' : 's'} currently available.', + 'Pick a client, then select one of the wallet accesses already granted to it. Compose shared constraints once, then switch between Ether and token transfer rules.', style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5), ), ); @@ -266,37 +276,98 @@ class _CreateSection extends StatelessWidget { } } -class _WalletPickerField extends StatelessWidget { - const _WalletPickerField({ - required this.wallets, - required this.selectedIndex, +class _ClientPickerField extends ConsumerWidget { + const _ClientPickerField({ + required this.selectedClientId, required this.onChanged, }); - final List wallets; - final int? selectedIndex; + final int? selectedClientId; final ValueChanged onChanged; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final clients = + ref.watch(sdkClientsProvider).asData?.value ?? const []; + return DropdownButtonFormField( - initialValue: selectedIndex, + value: clients.any((c) => c.id == selectedClientId) + ? selectedClientId + : null, decoration: const InputDecoration( - labelText: 'Wallet', - helperText: - 'Uses the current wallet order. The API still does not expose stable wallet IDs directly.', + labelText: 'Client', border: OutlineInputBorder(), ), items: [ - for (var i = 0; i < wallets.length; i++) + for (final c in clients) DropdownMenuItem( - value: i, + value: c.id, child: Text( - 'Wallet ${(i + 1).toString().padLeft(2, '0')} · ${_shortAddress(wallets[i].address)}', + c.info.name.isEmpty ? 'Client #${c.id}' : c.info.name, ), ), ], - onChanged: wallets.isEmpty ? null : onChanged, + onChanged: clients.isEmpty ? null : onChanged, + ); + } +} + +class _WalletAccessPickerField extends ConsumerWidget { + const _WalletAccessPickerField({ + required this.selectedClientId, + required this.selectedAccessId, + required this.onChanged, + }); + + final int? selectedClientId; + final int? selectedAccessId; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final allAccesses = + ref.watch(walletAccessListProvider).asData?.value ?? + const []; + final wallets = + ref.watch(evmProvider).asData?.value ?? const []; + + final walletById = { + for (final w in wallets) w.id: w, + }; + + final accesses = selectedClientId == null + ? const [] + : allAccesses + .where((a) => a.access.sdkClientId == selectedClientId) + .toList(); + + final effectiveValue = + accesses.any((a) => a.id == selectedAccessId) ? selectedAccessId : null; + + return DropdownButtonFormField( + value: effectiveValue, + decoration: InputDecoration( + labelText: 'Wallet access', + helperText: selectedClientId == null + ? 'Select a client first' + : accesses.isEmpty + ? 'No wallet accesses for this client' + : null, + border: const OutlineInputBorder(), + ), + items: [ + for (final a in accesses) + DropdownMenuItem( + value: a.id, + child: Text(() { + final wallet = walletById[a.access.walletId]; + return wallet != null + ? _shortAddress(wallet.address) + : 'Wallet #${a.access.walletId}'; + }()), + ), + ], + onChanged: accesses.isEmpty ? null : onChanged, ); } } @@ -735,6 +806,13 @@ class _VolumeLimitValue { } } +Timestamp _toTimestamp(DateTime value) { + final utc = value.toUtc(); + return Timestamp() + ..seconds = Int64(utc.millisecondsSinceEpoch ~/ 1000) + ..nanos = (utc.microsecondsSinceEpoch % 1000000) * 1000; +} + TransactionRateLimit? _buildRateLimit(String countText, String windowText) { if (countText.trim().isEmpty || windowText.trim().isEmpty) { return null; diff --git a/useragent/lib/screens/dashboard/evm/grants/grants.dart b/useragent/lib/screens/dashboard/evm/grants/grants.dart new file mode 100644 index 0000000..eb73efc --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/grants.dart @@ -0,0 +1,231 @@ +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/providers/evm/evm_grants.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart'; +import 'package:arbiter/router.gr.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:arbiter/widgets/page_header.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; + +String _formatError(Object error) { + final message = error.toString(); + if (message.startsWith('Exception: ')) { + return message.substring('Exception: '.length); + } + return message; +} + +// ─── State panel ────────────────────────────────────────────────────────────── + +class _StatePanel extends StatelessWidget { + const _StatePanel({ + required this.icon, + required this.title, + required this.body, + this.actionLabel, + this.onAction, + this.busy = false, + }); + + final IconData icon; + final String title; + final String body; + final String? actionLabel; + final Future Function()? onAction; + final bool busy; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), + ), + child: Padding( + padding: EdgeInsets.all(2.8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (busy) + SizedBox( + width: 2.8.h, + height: 2.8.h, + child: const CircularProgressIndicator(strokeWidth: 2.5), + ) + else + Icon(icon, size: 34, color: Palette.coral), + SizedBox(height: 1.8.h), + Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + color: Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 1.h), + Text( + body, + style: theme.textTheme.bodyLarge?.copyWith( + color: Palette.ink.withValues(alpha: 0.72), + height: 1.5, + ), + ), + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: 2.h), + OutlinedButton.icon( + onPressed: () => onAction!(), + icon: const Icon(Icons.refresh), + label: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} + +// ─── Grant list ─────────────────────────────────────────────────────────────── + +class _GrantList extends StatelessWidget { + const _GrantList({required this.grants}); + + final List grants; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (var i = 0; i < grants.length; i++) + Padding( + padding: EdgeInsets.only( + bottom: i == grants.length - 1 ? 0 : 1.8.h, + ), + child: GrantCard(grant: grants[i]), + ), + ], + ); + } +} + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +@RoutePage() +class EvmGrantsScreen extends ConsumerWidget { + const EvmGrantsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Screen watches only the grant list for top-level state decisions + final grantsAsync = ref.watch(evmGrantsProvider); + + Future refresh() async { + ref.invalidate(walletAccessListProvider); + ref.invalidate(evmGrantsProvider); + } + + void showMessage(String message) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + Future safeRefresh() async { + try { + await refresh(); + } catch (e) { + showMessage(_formatError(e)); + } + } + + final grantsState = grantsAsync.asData?.value; + final grants = grantsState?.grants; + + final content = switch (grantsAsync) { + AsyncLoading() when grantsState == null => const _StatePanel( + icon: Icons.hourglass_top, + title: 'Loading grants', + body: 'Pulling grant registry from Arbiter.', + busy: true, + ), + AsyncError(:final error) => _StatePanel( + icon: Icons.sync_problem, + title: 'Grant registry unavailable', + body: _formatError(error), + actionLabel: 'Retry', + onAction: safeRefresh, + ), + AsyncData(:final value) when value == null => _StatePanel( + icon: Icons.portable_wifi_off, + title: 'No active server connection', + body: 'Reconnect to Arbiter to list EVM grants.', + actionLabel: 'Refresh', + onAction: safeRefresh, + ), + _ when grants != null && grants.isEmpty => _StatePanel( + icon: Icons.policy_outlined, + title: 'No grants yet', + body: 'Create a grant to allow SDK clients to sign transactions.', + actionLabel: 'Create grant', + onAction: () async => context.router.push(const CreateEvmGrantRoute()), + ), + _ => _GrantList(grants: grants ?? const []), + }; + + return Scaffold( + body: SafeArea( + child: RefreshIndicator.adaptive( + color: Palette.ink, + backgroundColor: Colors.white, + onRefresh: safeRefresh, + child: ListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h), + children: [ + PageHeader( + title: 'EVM Grants', + isBusy: grantsAsync.isLoading, + actions: [ + FilledButton.icon( + onPressed: () => + context.router.push(const CreateEvmGrantRoute()), + icon: const Icon(Icons.add_rounded), + label: const Text('Create grant'), + ), + SizedBox(width: 1.w), + OutlinedButton.icon( + onPressed: safeRefresh, + 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), + content, + ], + ), + ), + ), + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart b/useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart new file mode 100644 index 0000000..5e01b1c --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart @@ -0,0 +1,225 @@ +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/evm/evm.dart'; +import 'package:arbiter/providers/evm/evm_grants.dart'; +import 'package:arbiter/providers/sdk_clients/list.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access_list.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'; + +String _shortAddress(List bytes) { + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}'; +} + +String _formatError(Object error) { + final message = error.toString(); + if (message.startsWith('Exception: ')) { + return message.substring('Exception: '.length); + } + return message; +} + +class GrantCard extends ConsumerWidget { + const GrantCard({super.key, required this.grant}); + + final GrantEntry grant; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Enrichment lookups — each watch scopes rebuilds to this card only + final walletAccesses = + ref.watch(walletAccessListProvider).asData?.value ?? const []; + final wallets = ref.watch(evmProvider).asData?.value ?? const []; + final clients = ref.watch(sdkClientsProvider).asData?.value ?? const []; + final revoking = ref.watch(revokeEvmGrantMutation) is MutationPending; + + final isEther = + grant.specific.whichGrant() == SpecificGrant_Grant.etherTransfer; + final accent = isEther ? Palette.coral : Palette.token; + final typeLabel = isEther ? 'Ether' : 'Token'; + final theme = Theme.of(context); + final muted = Palette.ink.withValues(alpha: 0.62); + + // Resolve wallet_access_id → wallet address + client name + final accessById = { + for (final a in walletAccesses) a.id: a, + }; + final walletById = { + for (final w in wallets) w.id: w, + }; + final clientNameById = { + for (final c in clients) c.id: c.info.name, + }; + + final accessId = grant.shared.walletAccessId; + final access = accessById[accessId]; + final wallet = access != null ? walletById[access.access.walletId] : null; + + final walletLabel = wallet != null + ? _shortAddress(wallet.address) + : 'Access #$accessId'; + + final clientLabel = () { + if (access == null) return ''; + final name = clientNameById[access.access.sdkClientId] ?? ''; + return name.isEmpty ? 'Client #${access.access.sdkClientId}' : name; + }(); + + void showError(String message) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + Future revoke() async { + try { + await executeRevokeEvmGrant(ref, grantId: grant.id); + } catch (e) { + showError(_formatError(e)); + } + } + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Accent strip + Container( + width: 0.8.w, + decoration: BoxDecoration( + color: accent, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(24), + ), + ), + ), + // Card body + Expanded( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 1.6.w, + vertical: 1.4.h, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row 1: type badge · chain · spacer · revoke button + Row( + children: [ + Container( + padding: EdgeInsets.symmetric( + horizontal: 1.w, + vertical: 0.4.h, + ), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + typeLabel, + style: theme.textTheme.labelSmall?.copyWith( + color: accent, + fontWeight: FontWeight.w800, + ), + ), + ), + SizedBox(width: 1.w), + Container( + padding: EdgeInsets.symmetric( + horizontal: 1.w, + vertical: 0.4.h, + ), + decoration: BoxDecoration( + color: Palette.ink.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Chain ${grant.shared.chainId}', + style: theme.textTheme.labelSmall?.copyWith( + color: muted, + fontWeight: FontWeight.w700, + ), + ), + ), + const Spacer(), + if (revoking) + SizedBox( + width: 1.8.h, + height: 1.8.h, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Palette.coral, + ), + ) + else + OutlinedButton.icon( + onPressed: revoke, + style: OutlinedButton.styleFrom( + foregroundColor: Palette.coral, + side: BorderSide( + color: Palette.coral.withValues(alpha: 0.4), + ), + padding: EdgeInsets.symmetric( + horizontal: 1.w, + vertical: 0.6.h, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + icon: const Icon(Icons.block_rounded, size: 16), + label: const Text('Revoke'), + ), + ], + ), + SizedBox(height: 0.8.h), + // Row 2: wallet address · client name + Row( + children: [ + Text( + walletLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: Palette.ink, + fontFamily: 'monospace', + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 0.8.w), + child: Text( + '·', + style: theme.textTheme.bodySmall + ?.copyWith(color: muted), + ), + ), + Expanded( + child: Text( + clientLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall + ?.copyWith(color: muted), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/useragent/lib/theme/palette.dart b/useragent/lib/theme/palette.dart index 1b87a9b..a2a5194 100644 --- a/useragent/lib/theme/palette.dart +++ b/useragent/lib/theme/palette.dart @@ -5,4 +5,5 @@ class Palette { static const coral = Color(0xFFE26254); static const cream = Color(0xFFFFFAF4); static const line = Color(0x1A15263C); + static const token = Color(0xFF5C6BC0); }