feat(useragent): vibe-coded access list

This commit is contained in:
hdbg
2026-03-25 11:52:10 +01:00
parent bbf8a8019c
commit 7f8b9cc63e
20 changed files with 1462 additions and 101 deletions

View File

@@ -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<Set<int>> 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<void> writeClientWalletAccess(
Connection connection, {
required int clientId,
required Set<int> 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),
],
),
),
);
}
}

View File

@@ -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<SdkClientEntry?> 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;
}

View File

@@ -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?>,
SdkClientEntry?,
FutureOr<SdkClientEntry?>
>
with $FutureModifier<SdkClientEntry?>, $FutureProvider<SdkClientEntry?> {
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<SdkClientEntry?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SdkClientEntry?> 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<FutureOr<SdkClientEntry?>, 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';
}

View File

@@ -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<List<SdkClientWalletAccess>?> 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<int> originalWalletIds;
final Set<int> selectedWalletIds;
bool get hasChanges => !setEquals(originalWalletIds, selectedWalletIds);
ClientWalletAccessState copyWith({
String? searchQuery,
Set<int>? originalWalletIds,
Set<int>? selectedWalletIds,
}) {
return ClientWalletAccessState(
searchQuery: searchQuery ?? this.searchQuery,
originalWalletIds: originalWalletIds ?? this.originalWalletIds,
selectedWalletIds: selectedWalletIds ?? this.selectedWalletIds,
);
}
}
final saveClientWalletAccessMutation = Mutation<void>();
abstract class ClientWalletAccessRepository {
Future<Set<int>> fetchSelectedWalletIds(int clientId);
Future<void> saveSelectedWalletIds(int clientId, Set<int> walletIds);
}
class ServerClientWalletAccessRepository
implements ClientWalletAccessRepository {
ServerClientWalletAccessRepository(this.ref);
final Ref ref;
@override
Future<Set<int>> 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<void> saveSelectedWalletIds(int clientId, Set<int> 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<List<ClientWalletOption>> clientWalletOptions(Ref ref) async {
final wallets = await ref.watch(evmProvider.future) ?? const <WalletEntry>[];
return [
for (var index = 0; index < wallets.length; index++)
ClientWalletOption(
walletId: index + 1,
address: formatWalletAddress(wallets[index].address),
),
];
}
@riverpod
Future<Set<int>> 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<Set<int>> value) {
value.when(data: hydrate, error: (_, _) {}, loading: () {});
}
ref.listen<AsyncValue<Set<int>>>(
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<int> 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<int>.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<void> 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<int> bytes) {
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '0x$hex';
}

View File

@@ -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<SdkClientWalletAccess>?>,
List<SdkClientWalletAccess>?,
FutureOr<List<SdkClientWalletAccess>?>
ClientWalletAccessRepository,
ClientWalletAccessRepository,
ClientWalletAccessRepository
>
with
$FutureModifier<List<SdkClientWalletAccess>?>,
$FutureProvider<List<SdkClientWalletAccess>?> {
WalletAccessProvider._()
with $Provider<ClientWalletAccessRepository> {
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<List<SdkClientWalletAccess>?> $createElement(
$ProviderElement<ClientWalletAccessRepository> $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<ClientWalletAccessRepository>(value),
);
}
}
String _$clientWalletAccessRepositoryHash() =>
r'bbc332284bc36a8b5d807bd5c45362b6b12b19e7';
@ProviderFor(clientWalletOptions)
final clientWalletOptionsProvider = ClientWalletOptionsProvider._();
final class ClientWalletOptionsProvider
extends
$FunctionalProvider<
AsyncValue<List<ClientWalletOption>>,
List<ClientWalletOption>,
FutureOr<List<ClientWalletOption>>
>
with
$FutureModifier<List<ClientWalletOption>>,
$FutureProvider<List<ClientWalletOption>> {
ClientWalletOptionsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'clientWalletOptionsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$clientWalletOptionsHash();
@$internal
@override
$FutureProviderElement<List<ClientWalletOption>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SdkClientWalletAccess>?> create(Ref ref) {
return walletAccess(ref);
FutureOr<List<ClientWalletOption>> create(Ref ref) {
return clientWalletOptions(ref);
}
}
String _$walletAccessHash() => r'954aae12d2d18999efaa97d01be983bf849f2296';
String _$clientWalletOptionsHash() =>
r'32183c2b281e2a41400de07f2381132a706815ab';
@ProviderFor(clientWalletAccessSelection)
final clientWalletAccessSelectionProvider =
ClientWalletAccessSelectionFamily._();
final class ClientWalletAccessSelectionProvider
extends
$FunctionalProvider<AsyncValue<Set<int>>, Set<int>, FutureOr<Set<int>>>
with $FutureModifier<Set<int>>, $FutureProvider<Set<int>> {
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<Set<int>> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<Set<int>> 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<FutureOr<Set<int>>, 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<ClientWalletAccessState>(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<ClientWalletAccessState> {
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<ClientWalletAccessState, ClientWalletAccessState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ClientWalletAccessState, ClientWalletAccessState>,
ClientWalletAccessState,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}

View File

@@ -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(

View File

@@ -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<void> {
const AboutRoute({List<_i11.PageRouteInfo>? children})
class AboutRoute extends _i12.PageRouteInfo<void> {
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<void> {
/// generated route for
/// [_i2.Bootstrap]
class Bootstrap extends _i11.PageRouteInfo<void> {
const Bootstrap({List<_i11.PageRouteInfo>? children})
class Bootstrap extends _i12.PageRouteInfo<void> {
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<void> {
/// generated route for
/// [_i3.ClientDetails]
class ClientDetails extends _i11.PageRouteInfo<ClientDetailsArgs> {
class ClientDetails extends _i12.PageRouteInfo<ClientDetailsArgs> {
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<ClientDetailsArgs> {
static const String name = 'ClientDetails';
static _i11.PageInfo page = _i11.PageInfo(
static _i12.PageInfo page = _i12.PageInfo(
name,
builder: (data) {
final args = data.argsAs<ClientDetailsArgs>();
@@ -82,9 +84,9 @@ class ClientDetails extends _i11.PageRouteInfo<ClientDetailsArgs> {
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<void> {
const ClientsRoute({List<_i11.PageRouteInfo>? children})
/// [_i4.ClientDetailsScreen]
class ClientDetailsRoute extends _i12.PageRouteInfo<ClientDetailsRouteArgs> {
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<ClientDetailsRouteArgs>(
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<void> {
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<void> {
const CreateEvmGrantRoute({List<_i11.PageRouteInfo>? children})
/// [_i6.CreateEvmGrantScreen]
class CreateEvmGrantRoute extends _i12.PageRouteInfo<void> {
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<void> {
const DashboardRouter({List<_i11.PageRouteInfo>? children})
/// [_i7.DashboardRouter]
class DashboardRouter extends _i12.PageRouteInfo<void> {
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<void> {
const EvmRoute({List<_i11.PageRouteInfo>? children})
/// [_i8.EvmScreen]
class EvmRoute extends _i12.PageRouteInfo<void> {
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<ServerConnectionRouteArgs> {
extends _i12.PageRouteInfo<ServerConnectionRouteArgs> {
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<ServerConnectionRouteArgs>(
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<void> {
const ServerInfoSetupRoute({List<_i11.PageRouteInfo>? children})
/// [_i10.ServerInfoSetupScreen]
class ServerInfoSetupRoute extends _i12.PageRouteInfo<void> {
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<void> {
const VaultSetupRoute({List<_i11.PageRouteInfo>? children})
/// [_i11.VaultSetupScreen]
class VaultSetupRoute extends _i12.PageRouteInfo<void> {
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();
},
);
}

View File

@@ -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!);
}
}

View File

@@ -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),
),
],
);
}
}

View File

@@ -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,
),
),
),
],
);
}
}

View File

@@ -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),
],
),
),
),
),
);
}
}

View File

@@ -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<int> 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)}';
}

View File

@@ -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<ClientWalletOption> options;
final Set<int> selectedWalletIds;
final bool enabled;
final ValueChanged<int> 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),
),
],
);
}
}

View File

@@ -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<void> saveMutation;
final VoidCallback onDiscard;
final Future<void> 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'),
),
],
),
],
),
),
);
}
}

View File

@@ -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<String> onChanged;
@override
Widget build(BuildContext context) {
return TextFormField(
initialValue: searchQuery,
decoration: const InputDecoration(
labelText: 'Search wallets',
prefixIcon: Icon(Icons.search),
),
onChanged: onChanged,
);
}
}

View File

@@ -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<Set<int>> accessSelectionAsync;
final bool isSavePending;
final ValueChanged<String> onSearchChanged;
final ValueChanged<int> 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<Set<int>> accessSelectionAsync;
final bool isSavePending;
final AsyncValue<List<ClientWalletOption>> optionsAsync;
final ValueChanged<String> onSearchChanged;
final ValueChanged<int> 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<ClientWalletOption> options;
final ValueChanged<String> onSearchChanged;
final ValueChanged<int> 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<ClientWalletOption> _filterOptions(
List<ClientWalletOption> options,
String query,
) {
if (query.isEmpty) {
return options;
}
final normalized = query.toLowerCase();
return options
.where((option) => option.address.toLowerCase().contains(normalized))
.toList(growable: false);
}

View File

@@ -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),
);
}
}

View File

@@ -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'),
),
],
),
],

View File

@@ -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<WalletEntry> wallets;
@override
Future<List<WalletEntry>?> build() async => wallets;
}
class _FakeWalletAccessRepository implements ClientWalletAccessRepository {
@override
Future<Set<int>> fetchSelectedWalletIds(int clientId) async => {1};
@override
Future<void> saveSelectedWalletIds(int clientId, Set<int> 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);
});
}

View File

@@ -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<int>? savedWalletIds;
@override
Future<Set<int>> fetchSelectedWalletIds(int clientId) async => {1};
@override
Future<void> saveSelectedWalletIds(int clientId, Set<int> walletIds) async {
savedWalletIds = walletIds;
}
}
class _FailureRepository implements ClientWalletAccessRepository {
@override
Future<Set<int>> fetchSelectedWalletIds(int clientId) async => const {};
@override
Future<void> saveSelectedWalletIds(int clientId, Set<int> 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<MutationError<void>>(),
);
});
}