feat(useragent): vibe-coded access list

This commit is contained in:
hdbg
2026-03-25 11:52:10 +01:00
parent bbf8a8019c
commit 700545be17
22 changed files with 1826 additions and 101 deletions

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