diff --git a/useragent/lib/features/connection/evm/wallet_access.dart b/useragent/lib/features/connection/evm/wallet_access.dart new file mode 100644 index 0000000..1876fbd --- /dev/null +++ b/useragent/lib/features/connection/evm/wallet_access.dart @@ -0,0 +1,58 @@ +import 'package:arbiter/features/connection/connection.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; + +Future> readClientWalletAccess( + Connection connection, { + required int clientId, +}) async { + final response = await connection.ask( + UserAgentRequest(listWalletAccess: Empty()), + ); + if (!response.hasListWalletAccessResponse()) { + throw Exception( + 'Expected list wallet access response, got ${response.whichPayload()}', + ); + } + return { + for (final access in response.listWalletAccessResponse.accesses) + if (access.clientId == clientId) access.walletId, + }; +} + +Future writeClientWalletAccess( + Connection connection, { + required int clientId, + required Set walletIds, +}) async { + final current = await readClientWalletAccess(connection, clientId: clientId); + + final toGrant = walletIds.difference(current); + final toRevoke = current.difference(walletIds); + + if (toGrant.isNotEmpty) { + await connection.tell( + UserAgentRequest( + grantWalletAccess: SdkClientGrantWalletAccess( + accesses: [ + for (final walletId in toGrant) + SdkClientWalletAccess(clientId: clientId, walletId: walletId), + ], + ), + ), + ); + } + + if (toRevoke.isNotEmpty) { + await connection.tell( + UserAgentRequest( + revokeWalletAccess: SdkClientRevokeWalletAccess( + accesses: [ + for (final walletId in toRevoke) + SdkClientWalletAccess(clientId: clientId, walletId: walletId), + ], + ), + ), + ); + } +} diff --git a/useragent/lib/providers/sdk_clients/details.dart b/useragent/lib/providers/sdk_clients/details.dart new file mode 100644 index 0000000..1e1fb2b --- /dev/null +++ b/useragent/lib/providers/sdk_clients/details.dart @@ -0,0 +1,19 @@ +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/sdk_clients/list.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'details.g.dart'; + +@riverpod +Future clientDetails(Ref ref, int clientId) async { + final clients = await ref.watch(sdkClientsProvider.future); + if (clients == null) { + return null; + } + for (final client in clients) { + if (client.id == clientId) { + return client; + } + } + return null; +} diff --git a/useragent/lib/providers/sdk_clients/details.g.dart b/useragent/lib/providers/sdk_clients/details.g.dart new file mode 100644 index 0000000..4f59df2 --- /dev/null +++ b/useragent/lib/providers/sdk_clients/details.g.dart @@ -0,0 +1,85 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'details.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(clientDetails) +final clientDetailsProvider = ClientDetailsFamily._(); + +final class ClientDetailsProvider + extends + $FunctionalProvider< + AsyncValue, + SdkClientEntry?, + FutureOr + > + with $FutureModifier, $FutureProvider { + ClientDetailsProvider._({ + required ClientDetailsFamily super.from, + required int super.argument, + }) : super( + retry: null, + name: r'clientDetailsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$clientDetailsHash(); + + @override + String toString() { + return r'clientDetailsProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as int; + return clientDetails(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is ClientDetailsProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$clientDetailsHash() => r'21449a1a2cc4fa4e65ce761e6342e97c1d957a7a'; + +final class ClientDetailsFamily extends $Family + with $FunctionalFamilyOverride, int> { + ClientDetailsFamily._() + : super( + retry: null, + name: r'clientDetailsProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + ClientDetailsProvider call(int clientId) => + ClientDetailsProvider._(argument: clientId, from: this); + + @override + String toString() => r'clientDetailsProvider'; +} diff --git a/useragent/lib/providers/sdk_clients/wallet_access.dart b/useragent/lib/providers/sdk_clients/wallet_access.dart index 1e0e1bc..faf4f95 100644 --- a/useragent/lib/providers/sdk_clients/wallet_access.dart +++ b/useragent/lib/providers/sdk_clients/wallet_access.dart @@ -1,25 +1,174 @@ - -import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/features/connection/evm/wallet_access.dart'; +import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; -import 'package:mtcore/markettakers.dart'; -import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; +import 'package:arbiter/providers/evm/evm.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'wallet_access.g.dart'; -@riverpod -Future?> walletAccess(Ref ref) async { - final connection = await ref.watch(connectionManagerProvider.future); - if (connection == null) { - return null; - } +class ClientWalletOption { + const ClientWalletOption({required this.walletId, required this.address}); - final accesses = await connection.ask(UserAgentRequest(listWalletAccess: Empty())); + final int walletId; + final String address; +} - if (accesses.hasListWalletAccessResponse()) { - return accesses.listWalletAccessResponse.accesses.toList(); - } else { - talker.warning('Received unexpected response for listWalletAccess: $accesses'); - return null; +class ClientWalletAccessState { + const ClientWalletAccessState({ + this.searchQuery = '', + this.originalWalletIds = const {}, + this.selectedWalletIds = const {}, + }); + + final String searchQuery; + final Set originalWalletIds; + final Set selectedWalletIds; + + bool get hasChanges => !setEquals(originalWalletIds, selectedWalletIds); + + ClientWalletAccessState copyWith({ + String? searchQuery, + Set? originalWalletIds, + Set? selectedWalletIds, + }) { + return ClientWalletAccessState( + searchQuery: searchQuery ?? this.searchQuery, + originalWalletIds: originalWalletIds ?? this.originalWalletIds, + selectedWalletIds: selectedWalletIds ?? this.selectedWalletIds, + ); } } + +final saveClientWalletAccessMutation = Mutation(); + +abstract class ClientWalletAccessRepository { + Future> fetchSelectedWalletIds(int clientId); + Future saveSelectedWalletIds(int clientId, Set walletIds); +} + +class ServerClientWalletAccessRepository + implements ClientWalletAccessRepository { + ServerClientWalletAccessRepository(this.ref); + + final Ref ref; + + @override + Future> fetchSelectedWalletIds(int clientId) async { + final connection = await ref.read(connectionManagerProvider.future); + if (connection == null) { + throw Exception('Not connected to the server.'); + } + return readClientWalletAccess(connection, clientId: clientId); + } + + @override + Future saveSelectedWalletIds(int clientId, Set walletIds) async { + final connection = await ref.read(connectionManagerProvider.future); + if (connection == null) { + throw Exception('Not connected to the server.'); + } + await writeClientWalletAccess( + connection, + clientId: clientId, + walletIds: walletIds, + ); + } +} + +@riverpod +ClientWalletAccessRepository clientWalletAccessRepository(Ref ref) { + return ServerClientWalletAccessRepository(ref); +} + +@riverpod +Future> clientWalletOptions(Ref ref) async { + final wallets = await ref.watch(evmProvider.future) ?? const []; + return [ + for (var index = 0; index < wallets.length; index++) + ClientWalletOption( + walletId: index + 1, + address: formatWalletAddress(wallets[index].address), + ), + ]; +} + +@riverpod +Future> clientWalletAccessSelection(Ref ref, int clientId) async { + final repository = ref.watch(clientWalletAccessRepositoryProvider); + return repository.fetchSelectedWalletIds(clientId); +} + +@riverpod +class ClientWalletAccessController extends _$ClientWalletAccessController { + @override + ClientWalletAccessState build(int clientId) { + final selection = ref.read(clientWalletAccessSelectionProvider(clientId)); + + void sync(AsyncValue> value) { + value.when(data: hydrate, error: (_, _) {}, loading: () {}); + } + + ref.listen>>( + clientWalletAccessSelectionProvider(clientId), + (_, next) => sync(next), + ); + return selection.when( + data: (walletIds) => ClientWalletAccessState( + originalWalletIds: Set.of(walletIds), + selectedWalletIds: Set.of(walletIds), + ), + error: (error, _) => const ClientWalletAccessState(), + loading: () => const ClientWalletAccessState(), + ); + } + + void hydrate(Set selectedWalletIds) { + state = state.copyWith( + originalWalletIds: Set.of(selectedWalletIds), + selectedWalletIds: Set.of(selectedWalletIds), + ); + } + + void setSearchQuery(String value) { + state = state.copyWith(searchQuery: value); + } + + void toggleWallet(int walletId) { + final next = Set.of(state.selectedWalletIds); + if (!next.add(walletId)) { + next.remove(walletId); + } + state = state.copyWith(selectedWalletIds: next); + } + + void discardChanges() { + state = state.copyWith(selectedWalletIds: Set.of(state.originalWalletIds)); + } +} + +Future executeSaveClientWalletAccess( + MutationTarget ref, { + required int clientId, +}) { + final mutation = saveClientWalletAccessMutation(clientId); + return mutation.run(ref, (tsx) async { + final repository = tsx.get(clientWalletAccessRepositoryProvider); + final controller = tsx.get( + clientWalletAccessControllerProvider(clientId).notifier, + ); + final selectedWalletIds = tsx + .get(clientWalletAccessControllerProvider(clientId)) + .selectedWalletIds; + await repository.saveSelectedWalletIds(clientId, selectedWalletIds); + controller.hydrate(selectedWalletIds); + }); +} + +String formatWalletAddress(List bytes) { + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '0x$hex'; +} diff --git a/useragent/lib/providers/sdk_clients/wallet_access.g.dart b/useragent/lib/providers/sdk_clients/wallet_access.g.dart index cb61d63..413ff16 100644 --- a/useragent/lib/providers/sdk_clients/wallet_access.g.dart +++ b/useragent/lib/providers/sdk_clients/wallet_access.g.dart @@ -9,43 +9,272 @@ part of 'wallet_access.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning -@ProviderFor(walletAccess) -final walletAccessProvider = WalletAccessProvider._(); +@ProviderFor(clientWalletAccessRepository) +final clientWalletAccessRepositoryProvider = + ClientWalletAccessRepositoryProvider._(); -final class WalletAccessProvider +final class ClientWalletAccessRepositoryProvider extends $FunctionalProvider< - AsyncValue?>, - List?, - FutureOr?> + ClientWalletAccessRepository, + ClientWalletAccessRepository, + ClientWalletAccessRepository > - with - $FutureModifier?>, - $FutureProvider?> { - WalletAccessProvider._() + with $Provider { + ClientWalletAccessRepositoryProvider._() : super( from: null, argument: null, retry: null, - name: r'walletAccessProvider', + name: r'clientWalletAccessRepositoryProvider', isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$walletAccessHash(); + String debugGetCreateSourceHash() => _$clientWalletAccessRepositoryHash(); @$internal @override - $FutureProviderElement?> $createElement( + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + ClientWalletAccessRepository create(Ref ref) { + return clientWalletAccessRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ClientWalletAccessRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$clientWalletAccessRepositoryHash() => + r'bbc332284bc36a8b5d807bd5c45362b6b12b19e7'; + +@ProviderFor(clientWalletOptions) +final clientWalletOptionsProvider = ClientWalletOptionsProvider._(); + +final class ClientWalletOptionsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + ClientWalletOptionsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'clientWalletOptionsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$clientWalletOptionsHash(); + + @$internal + @override + $FutureProviderElement> $createElement( $ProviderPointer pointer, ) => $FutureProviderElement(pointer); @override - FutureOr?> create(Ref ref) { - return walletAccess(ref); + FutureOr> create(Ref ref) { + return clientWalletOptions(ref); } } -String _$walletAccessHash() => r'954aae12d2d18999efaa97d01be983bf849f2296'; +String _$clientWalletOptionsHash() => + r'32183c2b281e2a41400de07f2381132a706815ab'; + +@ProviderFor(clientWalletAccessSelection) +final clientWalletAccessSelectionProvider = + ClientWalletAccessSelectionFamily._(); + +final class ClientWalletAccessSelectionProvider + extends + $FunctionalProvider>, Set, FutureOr>> + with $FutureModifier>, $FutureProvider> { + ClientWalletAccessSelectionProvider._({ + required ClientWalletAccessSelectionFamily super.from, + required int super.argument, + }) : super( + retry: null, + name: r'clientWalletAccessSelectionProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$clientWalletAccessSelectionHash(); + + @override + String toString() { + return r'clientWalletAccessSelectionProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement> $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + final argument = this.argument as int; + return clientWalletAccessSelection(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is ClientWalletAccessSelectionProvider && + other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$clientWalletAccessSelectionHash() => + r'f33705ee7201cd9b899cc058d6642de85a22b03e'; + +final class ClientWalletAccessSelectionFamily extends $Family + with $FunctionalFamilyOverride>, int> { + ClientWalletAccessSelectionFamily._() + : super( + retry: null, + name: r'clientWalletAccessSelectionProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + ClientWalletAccessSelectionProvider call(int clientId) => + ClientWalletAccessSelectionProvider._(argument: clientId, from: this); + + @override + String toString() => r'clientWalletAccessSelectionProvider'; +} + +@ProviderFor(ClientWalletAccessController) +final clientWalletAccessControllerProvider = + ClientWalletAccessControllerFamily._(); + +final class ClientWalletAccessControllerProvider + extends + $NotifierProvider< + ClientWalletAccessController, + ClientWalletAccessState + > { + ClientWalletAccessControllerProvider._({ + required ClientWalletAccessControllerFamily super.from, + required int super.argument, + }) : super( + retry: null, + name: r'clientWalletAccessControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$clientWalletAccessControllerHash(); + + @override + String toString() { + return r'clientWalletAccessControllerProvider' + '' + '($argument)'; + } + + @$internal + @override + ClientWalletAccessController create() => ClientWalletAccessController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ClientWalletAccessState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is ClientWalletAccessControllerProvider && + other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$clientWalletAccessControllerHash() => + r'45bff81382fec3e8610190167b55667a7dfc1111'; + +final class ClientWalletAccessControllerFamily extends $Family + with + $ClassFamilyOverride< + ClientWalletAccessController, + ClientWalletAccessState, + ClientWalletAccessState, + ClientWalletAccessState, + int + > { + ClientWalletAccessControllerFamily._() + : super( + retry: null, + name: r'clientWalletAccessControllerProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + ClientWalletAccessControllerProvider call(int clientId) => + ClientWalletAccessControllerProvider._(argument: clientId, from: this); + + @override + String toString() => r'clientWalletAccessControllerProvider'; +} + +abstract class _$ClientWalletAccessController + extends $Notifier { + late final _$args = ref.$arg as int; + int get clientId => _$args; + + ClientWalletAccessState build(int clientId); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ClientWalletAccessState, + Object?, + Object? + >; + element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/useragent/lib/router.dart b/useragent/lib/router.dart index c5a17f2..5342ff5 100644 --- a/useragent/lib/router.dart +++ b/useragent/lib/router.dart @@ -10,6 +10,7 @@ class Router extends RootStackRouter { AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'), AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'), AutoRoute(page: VaultSetupRoute.page, path: '/vault'), + AutoRoute(page: ClientDetailsRoute.page, path: '/clients/:clientId'), AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'), AutoRoute( diff --git a/useragent/lib/router.gr.dart b/useragent/lib/router.gr.dart index dbab355..b661a9d 100644 --- a/useragent/lib/router.gr.dart +++ b/useragent/lib/router.gr.dart @@ -9,29 +9,31 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:arbiter/proto/user_agent.pb.dart' as _i13; +import 'package:arbiter/proto/user_agent.pb.dart' as _i14; import 'package:arbiter/screens/bootstrap.dart' as _i2; -import 'package:arbiter/screens/dashboard.dart' as _i6; +import 'package:arbiter/screens/dashboard.dart' as _i7; import 'package:arbiter/screens/dashboard/about.dart' as _i1; import 'package:arbiter/screens/dashboard/clients/details.dart' as _i3; -import 'package:arbiter/screens/dashboard/clients/table.dart' as _i4; -import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i7; -import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i5; -import 'package:arbiter/screens/server_connection.dart' as _i8; -import 'package:arbiter/screens/server_info_setup.dart' as _i9; -import 'package:arbiter/screens/vault_setup.dart' as _i10; -import 'package:auto_route/auto_route.dart' as _i11; -import 'package:flutter/material.dart' as _i12; +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/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; /// generated route for /// [_i1.AboutScreen] -class AboutRoute extends _i11.PageRouteInfo { - const AboutRoute({List<_i11.PageRouteInfo>? children}) +class AboutRoute extends _i12.PageRouteInfo { + const AboutRoute({List<_i12.PageRouteInfo>? children}) : super(AboutRoute.name, initialChildren: children); static const String name = 'AboutRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { return const _i1.AboutScreen(); @@ -41,13 +43,13 @@ class AboutRoute extends _i11.PageRouteInfo { /// generated route for /// [_i2.Bootstrap] -class Bootstrap extends _i11.PageRouteInfo { - const Bootstrap({List<_i11.PageRouteInfo>? children}) +class Bootstrap extends _i12.PageRouteInfo { + const Bootstrap({List<_i12.PageRouteInfo>? children}) : super(Bootstrap.name, initialChildren: children); static const String name = 'Bootstrap'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { return const _i2.Bootstrap(); @@ -57,11 +59,11 @@ class Bootstrap extends _i11.PageRouteInfo { /// generated route for /// [_i3.ClientDetails] -class ClientDetails extends _i11.PageRouteInfo { +class ClientDetails extends _i12.PageRouteInfo { ClientDetails({ - _i12.Key? key, - required _i13.SdkClientEntry client, - List<_i11.PageRouteInfo>? children, + _i13.Key? key, + required _i14.SdkClientEntry client, + List<_i12.PageRouteInfo>? children, }) : super( ClientDetails.name, args: ClientDetailsArgs(key: key, client: client), @@ -70,7 +72,7 @@ class ClientDetails extends _i11.PageRouteInfo { static const String name = 'ClientDetails'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -82,9 +84,9 @@ class ClientDetails extends _i11.PageRouteInfo { class ClientDetailsArgs { const ClientDetailsArgs({this.key, required this.client}); - final _i12.Key? key; + final _i13.Key? key; - final _i13.SdkClientEntry client; + final _i14.SdkClientEntry client; @override String toString() { @@ -103,77 +105,129 @@ class ClientDetailsArgs { } /// generated route for -/// [_i4.ClientsScreen] -class ClientsRoute extends _i11.PageRouteInfo { - const ClientsRoute({List<_i11.PageRouteInfo>? children}) +/// [_i4.ClientDetailsScreen] +class ClientDetailsRoute extends _i12.PageRouteInfo { + ClientDetailsRoute({ + _i13.Key? key, + required int clientId, + List<_i12.PageRouteInfo>? children, + }) : super( + ClientDetailsRoute.name, + args: ClientDetailsRouteArgs(key: key, clientId: clientId), + rawPathParams: {'clientId': clientId}, + initialChildren: children, + ); + + static const String name = 'ClientDetailsRoute'; + + static _i12.PageInfo page = _i12.PageInfo( + name, + builder: (data) { + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => + ClientDetailsRouteArgs(clientId: pathParams.getInt('clientId')), + ); + return _i4.ClientDetailsScreen(key: args.key, clientId: args.clientId); + }, + ); +} + +class ClientDetailsRouteArgs { + const ClientDetailsRouteArgs({this.key, required this.clientId}); + + final _i13.Key? key; + + final int clientId; + + @override + String toString() { + return 'ClientDetailsRouteArgs{key: $key, clientId: $clientId}'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! ClientDetailsRouteArgs) return false; + return key == other.key && clientId == other.clientId; + } + + @override + int get hashCode => key.hashCode ^ clientId.hashCode; +} + +/// generated route for +/// [_i5.ClientsScreen] +class ClientsRoute extends _i12.PageRouteInfo { + const ClientsRoute({List<_i12.PageRouteInfo>? children}) : super(ClientsRoute.name, initialChildren: children); static const String name = 'ClientsRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { - return const _i4.ClientsScreen(); + return const _i5.ClientsScreen(); }, ); } /// generated route for -/// [_i5.CreateEvmGrantScreen] -class CreateEvmGrantRoute extends _i11.PageRouteInfo { - const CreateEvmGrantRoute({List<_i11.PageRouteInfo>? children}) +/// [_i6.CreateEvmGrantScreen] +class CreateEvmGrantRoute extends _i12.PageRouteInfo { + const CreateEvmGrantRoute({List<_i12.PageRouteInfo>? children}) : super(CreateEvmGrantRoute.name, initialChildren: children); static const String name = 'CreateEvmGrantRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { - return const _i5.CreateEvmGrantScreen(); + return const _i6.CreateEvmGrantScreen(); }, ); } /// generated route for -/// [_i6.DashboardRouter] -class DashboardRouter extends _i11.PageRouteInfo { - const DashboardRouter({List<_i11.PageRouteInfo>? children}) +/// [_i7.DashboardRouter] +class DashboardRouter extends _i12.PageRouteInfo { + const DashboardRouter({List<_i12.PageRouteInfo>? children}) : super(DashboardRouter.name, initialChildren: children); static const String name = 'DashboardRouter'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { - return const _i6.DashboardRouter(); + return const _i7.DashboardRouter(); }, ); } /// generated route for -/// [_i7.EvmScreen] -class EvmRoute extends _i11.PageRouteInfo { - const EvmRoute({List<_i11.PageRouteInfo>? children}) +/// [_i8.EvmScreen] +class EvmRoute extends _i12.PageRouteInfo { + const EvmRoute({List<_i12.PageRouteInfo>? children}) : super(EvmRoute.name, initialChildren: children); static const String name = 'EvmRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { - return const _i7.EvmScreen(); + return const _i8.EvmScreen(); }, ); } /// generated route for -/// [_i8.ServerConnectionScreen] +/// [_i9.ServerConnectionScreen] class ServerConnectionRoute - extends _i11.PageRouteInfo { + extends _i12.PageRouteInfo { ServerConnectionRoute({ - _i12.Key? key, + _i13.Key? key, String? arbiterUrl, - List<_i11.PageRouteInfo>? children, + List<_i12.PageRouteInfo>? children, }) : super( ServerConnectionRoute.name, args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl), @@ -182,13 +236,13 @@ class ServerConnectionRoute static const String name = 'ServerConnectionRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { final args = data.argsAs( orElse: () => const ServerConnectionRouteArgs(), ); - return _i8.ServerConnectionScreen( + return _i9.ServerConnectionScreen( key: args.key, arbiterUrl: args.arbiterUrl, ); @@ -199,7 +253,7 @@ class ServerConnectionRoute class ServerConnectionRouteArgs { const ServerConnectionRouteArgs({this.key, this.arbiterUrl}); - final _i12.Key? key; + final _i13.Key? key; final String? arbiterUrl; @@ -220,33 +274,33 @@ class ServerConnectionRouteArgs { } /// generated route for -/// [_i9.ServerInfoSetupScreen] -class ServerInfoSetupRoute extends _i11.PageRouteInfo { - const ServerInfoSetupRoute({List<_i11.PageRouteInfo>? children}) +/// [_i10.ServerInfoSetupScreen] +class ServerInfoSetupRoute extends _i12.PageRouteInfo { + const ServerInfoSetupRoute({List<_i12.PageRouteInfo>? children}) : super(ServerInfoSetupRoute.name, initialChildren: children); static const String name = 'ServerInfoSetupRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { - return const _i9.ServerInfoSetupScreen(); + return const _i10.ServerInfoSetupScreen(); }, ); } /// generated route for -/// [_i10.VaultSetupScreen] -class VaultSetupRoute extends _i11.PageRouteInfo { - const VaultSetupRoute({List<_i11.PageRouteInfo>? children}) +/// [_i11.VaultSetupScreen] +class VaultSetupRoute extends _i12.PageRouteInfo { + const VaultSetupRoute({List<_i12.PageRouteInfo>? children}) : super(VaultSetupRoute.name, initialChildren: children); static const String name = 'VaultSetupRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { - return const _i10.VaultSetupScreen(); + return const _i11.VaultSetupScreen(); }, ); } diff --git a/useragent/lib/screens/dashboard/clients/details/client_details.dart b/useragent/lib/screens/dashboard/clients/details/client_details.dart new file mode 100644 index 0000000..854c5d9 --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/client_details.dart @@ -0,0 +1,56 @@ +import 'package:arbiter/providers/sdk_clients/details.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_content.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +@RoutePage() +class ClientDetailsScreen extends ConsumerWidget { + const ClientDetailsScreen({super.key, @pathParam required this.clientId}); + + final int clientId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientAsync = ref.watch(clientDetailsProvider(clientId)); + return Scaffold( + body: SafeArea( + child: clientAsync.when( + data: (client) => + _ClientDetailsState(clientId: clientId, client: client), + error: (error, _) => ClientDetailsStatePanel( + title: 'Client unavailable', + body: error.toString(), + icon: Icons.sync_problem, + ), + loading: () => const ClientDetailsStatePanel( + title: 'Loading client', + body: 'Pulling client details from Arbiter.', + icon: Icons.hourglass_top, + ), + ), + ), + ); + } +} + +class _ClientDetailsState extends StatelessWidget { + const _ClientDetailsState({required this.clientId, required this.client}); + + final int clientId; + final SdkClientEntry? client; + + @override + Widget build(BuildContext context) { + if (client == null) { + return const ClientDetailsStatePanel( + title: 'Client not found', + body: 'The selected SDK client is no longer available.', + icon: Icons.person_off_outlined, + ); + } + return ClientDetailsContent(clientId: clientId, client: client!); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/client_details_content.dart b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_content.dart new file mode 100644 index 0000000..cf2693f --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_content.dart @@ -0,0 +1,55 @@ +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_header.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/client_summary_card.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_section.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class ClientDetailsContent extends ConsumerWidget { + const ClientDetailsContent({ + super.key, + required this.clientId, + required this.client, + }); + + final int clientId; + final SdkClientEntry client; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(clientWalletAccessControllerProvider(clientId)); + final notifier = ref.read( + clientWalletAccessControllerProvider(clientId).notifier, + ); + final saveMutation = ref.watch(saveClientWalletAccessMutation(clientId)); + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const ClientDetailsHeader(), + const SizedBox(height: 16), + ClientSummaryCard(client: client), + const SizedBox(height: 16), + WalletAccessSection( + clientId: clientId, + state: state, + accessSelectionAsync: ref.watch( + clientWalletAccessSelectionProvider(clientId), + ), + isSavePending: saveMutation is MutationPending, + onSearchChanged: notifier.setSearchQuery, + onToggleWallet: notifier.toggleWallet, + ), + const SizedBox(height: 16), + WalletAccessSaveBar( + state: state, + saveMutation: saveMutation, + onDiscard: notifier.discardChanges, + onSave: () => executeSaveClientWalletAccess(ref, clientId: clientId), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/client_details_header.dart b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_header.dart new file mode 100644 index 0000000..f93562a --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_header.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class ClientDetailsHeader extends StatelessWidget { + const ClientDetailsHeader({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + BackButton(onPressed: () => Navigator.of(context).maybePop()), + Expanded( + child: Text( + 'Client Details', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart new file mode 100644 index 0000000..f9c40d5 --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart @@ -0,0 +1,45 @@ +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; + +class ClientDetailsStatePanel extends StatelessWidget { + const ClientDetailsStatePanel({ + super.key, + required this.title, + required this.body, + required this.icon, + }); + + final String title; + final String body; + final IconData icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: DecoratedBox( + decoration: BoxDecoration( + color: Palette.cream, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Palette.line), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Palette.coral), + const SizedBox(height: 12), + Text(title, style: theme.textTheme.titleLarge), + const SizedBox(height: 8), + Text(body, textAlign: TextAlign.center), + ], + ), + ), + ), + ), + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/client_summary_card.dart b/useragent/lib/screens/dashboard/clients/details/widgets/client_summary_card.dart new file mode 100644 index 0000000..7fa081c --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/client_summary_card.dart @@ -0,0 +1,82 @@ +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; + +class ClientSummaryCard extends StatelessWidget { + const ClientSummaryCard({super.key, required this.client}); + + final SdkClientEntry client; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Palette.cream, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Palette.line), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + client.info.name, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text(client.info.description), + const SizedBox(height: 16), + Wrap( + runSpacing: 8, + spacing: 16, + children: [ + _Fact(label: 'Client ID', value: '${client.id}'), + _Fact(label: 'Version', value: client.info.version), + _Fact( + label: 'Registered', + value: _formatDate(client.createdAt), + ), + _Fact(label: 'Pubkey', value: _shortPubkey(client.pubkey)), + ], + ), + ], + ), + ), + ); + } +} + +class _Fact extends StatelessWidget { + const _Fact({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.labelMedium), + Text(value.isEmpty ? '—' : value, style: theme.textTheme.bodyMedium), + ], + ); + } +} + +String _formatDate(int unixSecs) { + final dt = DateTime.fromMillisecondsSinceEpoch(unixSecs * 1000).toLocal(); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; +} + +String _shortPubkey(List bytes) { + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + if (hex.length < 12) { + return '0x$hex'; + } + return '0x${hex.substring(0, 8)}...${hex.substring(hex.length - 4)}'; +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_list.dart b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_list.dart new file mode 100644 index 0000000..a59a909 --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_list.dart @@ -0,0 +1,33 @@ +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_tile.dart'; +import 'package:flutter/material.dart'; + +class WalletAccessList extends StatelessWidget { + const WalletAccessList({ + super.key, + required this.options, + required this.selectedWalletIds, + required this.enabled, + required this.onToggleWallet, + }); + + final List options; + final Set selectedWalletIds; + final bool enabled; + final ValueChanged onToggleWallet; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (final option in options) + WalletAccessTile( + option: option, + value: selectedWalletIds.contains(option.walletId), + enabled: enabled, + onChanged: () => onToggleWallet(option.walletId), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart new file mode 100644 index 0000000..52e820d --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart @@ -0,0 +1,60 @@ +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; + +class WalletAccessSaveBar extends StatelessWidget { + const WalletAccessSaveBar({ + super.key, + required this.state, + required this.saveMutation, + required this.onDiscard, + required this.onSave, + }); + + final ClientWalletAccessState state; + final MutationState saveMutation; + final VoidCallback onDiscard; + final Future Function() onSave; + + @override + Widget build(BuildContext context) { + final isPending = saveMutation is MutationPending; + final errorText = switch (saveMutation) { + MutationError(:final error) => error.toString(), + _ => null, + }; + return DecoratedBox( + decoration: BoxDecoration( + color: Palette.cream, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Palette.line), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (errorText != null) ...[ + Text(errorText, style: TextStyle(color: Palette.coral)), + const SizedBox(height: 12), + ], + Row( + children: [ + TextButton( + onPressed: state.hasChanges && !isPending ? onDiscard : null, + child: const Text('Reset'), + ), + const Spacer(), + FilledButton( + onPressed: state.hasChanges && !isPending ? onSave : null, + child: Text(isPending ? 'Saving...' : 'Save changes'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart new file mode 100644 index 0000000..62196c7 --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class WalletAccessSearchField extends StatelessWidget { + const WalletAccessSearchField({ + super.key, + required this.searchQuery, + required this.onChanged, + }); + + final String searchQuery; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return TextFormField( + initialValue: searchQuery, + decoration: const InputDecoration( + labelText: 'Search wallets', + prefixIcon: Icon(Icons.search), + ), + onChanged: onChanged, + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart new file mode 100644 index 0000000..e5b40f2 --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart @@ -0,0 +1,176 @@ +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_list.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class WalletAccessSection extends ConsumerWidget { + const WalletAccessSection({ + super.key, + required this.clientId, + required this.state, + required this.accessSelectionAsync, + required this.isSavePending, + required this.onSearchChanged, + required this.onToggleWallet, + }); + + final int clientId; + final ClientWalletAccessState state; + final AsyncValue> accessSelectionAsync; + final bool isSavePending; + final ValueChanged onSearchChanged; + final ValueChanged onToggleWallet; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final optionsAsync = ref.watch(clientWalletOptionsProvider); + return DecoratedBox( + decoration: BoxDecoration( + color: Palette.cream, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Palette.line), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Wallet access', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text('Choose which managed wallets this client can see.'), + const SizedBox(height: 16), + _WalletAccessBody( + clientId: clientId, + state: state, + accessSelectionAsync: accessSelectionAsync, + isSavePending: isSavePending, + optionsAsync: optionsAsync, + onSearchChanged: onSearchChanged, + onToggleWallet: onToggleWallet, + ), + ], + ), + ), + ); + } +} + +class _WalletAccessBody extends StatelessWidget { + const _WalletAccessBody({ + required this.clientId, + required this.state, + required this.accessSelectionAsync, + required this.isSavePending, + required this.optionsAsync, + required this.onSearchChanged, + required this.onToggleWallet, + }); + + final int clientId; + final ClientWalletAccessState state; + final AsyncValue> accessSelectionAsync; + final bool isSavePending; + final AsyncValue> optionsAsync; + final ValueChanged onSearchChanged; + final ValueChanged onToggleWallet; + + @override + Widget build(BuildContext context) { + final selectionState = accessSelectionAsync; + if (selectionState.isLoading) { + return const ClientDetailsStatePanel( + title: 'Loading wallet access', + body: 'Pulling the current wallet permissions for this client.', + icon: Icons.hourglass_top, + ); + } + if (selectionState.hasError) { + return ClientDetailsStatePanel( + title: 'Wallet access unavailable', + body: selectionState.error.toString(), + icon: Icons.lock_outline, + ); + } + return optionsAsync.when( + data: (options) => _WalletAccessLoaded( + state: state, + isSavePending: isSavePending, + options: options, + onSearchChanged: onSearchChanged, + onToggleWallet: onToggleWallet, + ), + error: (error, _) => ClientDetailsStatePanel( + title: 'Wallet list unavailable', + body: error.toString(), + icon: Icons.sync_problem, + ), + loading: () => const ClientDetailsStatePanel( + title: 'Loading wallets', + body: 'Pulling the managed wallet inventory.', + icon: Icons.hourglass_top, + ), + ); + } +} + +class _WalletAccessLoaded extends StatelessWidget { + const _WalletAccessLoaded({ + required this.state, + required this.isSavePending, + required this.options, + required this.onSearchChanged, + required this.onToggleWallet, + }); + + final ClientWalletAccessState state; + final bool isSavePending; + final List options; + final ValueChanged onSearchChanged; + final ValueChanged onToggleWallet; + + @override + Widget build(BuildContext context) { + if (options.isEmpty) { + return const ClientDetailsStatePanel( + title: 'No wallets yet', + body: 'Create a managed wallet before assigning client access.', + icon: Icons.account_balance_wallet_outlined, + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + WalletAccessSearchField( + searchQuery: state.searchQuery, + onChanged: onSearchChanged, + ), + const SizedBox(height: 16), + WalletAccessList( + options: _filterOptions(options, state.searchQuery), + selectedWalletIds: state.selectedWalletIds, + enabled: !isSavePending, + onToggleWallet: onToggleWallet, + ), + ], + ); + } +} + +List _filterOptions( + List options, + String query, +) { + if (query.isEmpty) { + return options; + } + final normalized = query.toLowerCase(); + return options + .where((option) => option.address.toLowerCase().contains(normalized)) + .toList(growable: false); +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_tile.dart b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_tile.dart new file mode 100644 index 0000000..066c9fb --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_tile.dart @@ -0,0 +1,28 @@ +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:flutter/material.dart'; + +class WalletAccessTile extends StatelessWidget { + const WalletAccessTile({ + super.key, + required this.option, + required this.value, + required this.enabled, + required this.onChanged, + }); + + final ClientWalletOption option; + final bool value; + final bool enabled; + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + return CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: value, + onChanged: enabled ? (_) => onChanged() : null, + title: Text('Wallet ${option.walletId}'), + subtitle: Text(option.address), + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/table.dart b/useragent/lib/screens/dashboard/clients/table.dart index 8bdb88d..a84cfe9 100644 --- a/useragent/lib/screens/dashboard/clients/table.dart +++ b/useragent/lib/screens/dashboard/clients/table.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:arbiter/router.gr.dart'; import 'package:arbiter/providers/sdk_clients/list.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; @@ -176,10 +177,7 @@ class _Header extends StatelessWidget { style: OutlinedButton.styleFrom( foregroundColor: Palette.ink, side: BorderSide(color: Palette.line), - padding: EdgeInsets.symmetric( - horizontal: 1.4.w, - vertical: 1.2.h, - ), + padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), @@ -215,9 +213,15 @@ class _ClientTableHeader extends StatelessWidget { child: Row( children: [ SizedBox(width: _accentStripWidth + _cellHPad), - SizedBox(width: _idColWidth, child: Text('ID', style: style)), + SizedBox( + width: _idColWidth, + child: Text('ID', style: style), + ), SizedBox(width: _colGap), - SizedBox(width: _nameColWidth, child: Text('Name', style: style)), + SizedBox( + width: _nameColWidth, + child: Text('Name', style: style), + ), SizedBox(width: _colGap), SizedBox( width: _versionColWidth, @@ -397,9 +401,7 @@ class _ClientTableRow extends HookWidget { color: muted, onPressed: () async { await Clipboard.setData( - ClipboardData( - text: _fullPubkey(client.pubkey), - ), + ClipboardData(text: _fullPubkey(client.pubkey)), ); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -410,6 +412,14 @@ class _ClientTableRow extends HookWidget { ); }, ), + FilledButton.tonal( + onPressed: () { + context.router.push( + ClientDetailsRoute(clientId: client.id), + ); + }, + child: const Text('Manage access'), + ), ], ), ], diff --git a/useragent/test/screens/dashboard/clients/details/client_details_screen_test.dart b/useragent/test/screens/dashboard/clients/details/client_details_screen_test.dart new file mode 100644 index 0000000..5e4e1b4 --- /dev/null +++ b/useragent/test/screens/dashboard/clients/details/client_details_screen_test.dart @@ -0,0 +1,69 @@ +import 'package:arbiter/proto/client.pb.dart'; +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/sdk_clients/list.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:arbiter/screens/dashboard/clients/details/client_details.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _FakeEvm extends Evm { + _FakeEvm(this.wallets); + + final List wallets; + + @override + Future?> build() async => wallets; +} + +class _FakeWalletAccessRepository implements ClientWalletAccessRepository { + @override + Future> fetchSelectedWalletIds(int clientId) async => {1}; + + @override + Future saveSelectedWalletIds(int clientId, Set walletIds) async {} +} + +void main() { + testWidgets('renders client summary and wallet access controls', ( + tester, + ) async { + final client = SdkClientEntry( + id: 42, + createdAt: 1, + info: ClientInfo( + name: 'Safe Wallet SDK', + version: '1.3.0', + description: 'Primary signing client', + ), + pubkey: List.filled(32, 17), + ); + + final wallets = [ + WalletEntry(address: List.filled(20, 1)), + WalletEntry(address: List.filled(20, 2)), + ]; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + sdkClientsProvider.overrideWith((ref) async => [client]), + evmProvider.overrideWith(() => _FakeEvm(wallets)), + clientWalletAccessRepositoryProvider.overrideWithValue( + _FakeWalletAccessRepository(), + ), + ], + child: const MaterialApp(home: ClientDetailsScreen(clientId: 42)), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Safe Wallet SDK'), findsOneWidget); + expect(find.text('Wallet access'), findsOneWidget); + expect(find.textContaining('0x0101'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Save changes'), findsOneWidget); + }); +} diff --git a/useragent/test/screens/dashboard/clients/details/client_wallet_access_controller_test.dart b/useragent/test/screens/dashboard/clients/details/client_wallet_access_controller_test.dart new file mode 100644 index 0000000..d916eab --- /dev/null +++ b/useragent/test/screens/dashboard/clients/details/client_wallet_access_controller_test.dart @@ -0,0 +1,105 @@ +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _SuccessRepository implements ClientWalletAccessRepository { + Set? savedWalletIds; + + @override + Future> fetchSelectedWalletIds(int clientId) async => {1}; + + @override + Future saveSelectedWalletIds(int clientId, Set walletIds) async { + savedWalletIds = walletIds; + } +} + +class _FailureRepository implements ClientWalletAccessRepository { + @override + Future> fetchSelectedWalletIds(int clientId) async => const {}; + + @override + Future saveSelectedWalletIds(int clientId, Set walletIds) async { + throw UnsupportedError('Not supported yet: $walletIds'); + } +} + +void main() { + test('save updates the original selection after toggles', () async { + final repository = _SuccessRepository(); + final container = ProviderContainer( + overrides: [ + clientWalletAccessRepositoryProvider.overrideWithValue(repository), + ], + ); + addTearDown(container.dispose); + + final controller = container.read( + clientWalletAccessControllerProvider(42).notifier, + ); + await container.read(clientWalletAccessSelectionProvider(42).future); + controller.toggleWallet(2); + + expect( + container + .read(clientWalletAccessControllerProvider(42)) + .selectedWalletIds, + {1, 2}, + ); + expect( + container.read(clientWalletAccessControllerProvider(42)).hasChanges, + isTrue, + ); + + await executeSaveClientWalletAccess(container, clientId: 42); + + expect(repository.savedWalletIds, {1, 2}); + expect( + container + .read(clientWalletAccessControllerProvider(42)) + .originalWalletIds, + {1, 2}, + ); + expect( + container.read(clientWalletAccessControllerProvider(42)).hasChanges, + isFalse, + ); + }); + + test('save failure preserves edits and exposes a mutation error', () async { + final container = ProviderContainer( + overrides: [ + clientWalletAccessRepositoryProvider.overrideWithValue( + _FailureRepository(), + ), + ], + ); + addTearDown(container.dispose); + + final controller = container.read( + clientWalletAccessControllerProvider(42).notifier, + ); + await container.read(clientWalletAccessSelectionProvider(42).future); + controller.toggleWallet(3); + await expectLater( + executeSaveClientWalletAccess(container, clientId: 42), + throwsUnsupportedError, + ); + + expect( + container + .read(clientWalletAccessControllerProvider(42)) + .selectedWalletIds, + {3}, + ); + expect( + container.read(clientWalletAccessControllerProvider(42)).hasChanges, + isTrue, + ); + expect( + container.read(saveClientWalletAccessMutation(42)), + isA>(), + ); + }); +}