diff --git a/docs/superpowers/plans/2026-03-28-grant-creation-refactor.md b/docs/superpowers/plans/2026-03-28-grant-creation-refactor.md new file mode 100644 index 0000000..11f1837 --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-grant-creation-refactor.md @@ -0,0 +1,1308 @@ +# Grant Creation Screen Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Decompose the monolithic `create/screen.dart` into a Riverpod-managed provider, composable `flutter_form_builder` field widgets, and per-grant-type handlers with a clean interface. + +**Architecture:** A `GrantCreation` provider manages only the state that cannot live in a form field: `selectedClientId` (drives wallet-access filtering) and `grantType` (SegmentedButton). All other shared settings — including validity dates — are `FormBuilder` fields; the custom `FormBuilderDateTimeField` wraps the native date/time picker dialog. Token volume limits are owned by a `TokenGrantLimits` provider that lives entirely inside `token_transfer_grant.dart`. Each grant type implements `GrantFormHandler`, whose `buildSpecificGrant` receives `formValues` and a `WidgetRef` so handlers can read their own providers. + +**Tech Stack:** Flutter, Riverpod (riverpod_annotation 4.x), flutter_form_builder 10.x, freezed 3.x, flutter_hooks, Protobuf (Dart), fixnum + +--- + +## File Map + +| Path | Action | Responsibility | +|------|--------|----------------| +| `create/utils.dart` | Create | Parsing/conversion helpers (hex addresses, big-int bytes, timestamps, rate limits) | +| `create/provider.dart` | Create | `GrantCreationState` (freezed), `GrantCreation` (@riverpod) — only `selectedClientId` + `grantType` | +| `create/fields/client_picker_field.dart` | Create | `FormBuilderDropdown` for SDK client; syncs selection to `GrantCreation` provider | +| `create/fields/wallet_access_picker_field.dart` | Create | `FormBuilderDropdown` for wallet access; filters by provider's `selectedClientId` | +| `create/fields/chain_id_field.dart` | Create | `FormBuilderTextField` for chain ID | +| `create/fields/date_time_field.dart` | Create | Custom `FormBuilderDateTimeField` — wraps date+time picker dialogs as a `FormBuilderField` | +| `create/fields/validity_window_field.dart` | Create | Row of two `FormBuilderDateTimeField` widgets (names `validFrom`, `validUntil`) | +| `create/fields/gas_fee_options_field.dart` | Create | Two `FormBuilderTextField` fields for gas fee and priority fee | +| `create/fields/transaction_rate_limit_field.dart` | Create | Two `FormBuilderTextField` fields for tx count and window | +| `create/shared_grant_fields.dart` | Create | Composes all shared field widgets | +| `create/grants/grant_form_handler.dart` | Create | Abstract `GrantFormHandler` with `buildSpecificGrant(formValues, WidgetRef)` | +| `create/grants/ether_transfer_grant.dart` | Create | `EtherTransferGrantHandler` + `_EtherTransferForm` | +| `create/grants/token_transfer_grant.dart` | Create | `TokenGrantLimits` provider, `TokenTransferGrantHandler`, `_TokenTransferForm` + volume limit widgets | +| `create/screen.dart` | Modify | Thin orchestration: `FormBuilder` root, section layout, submit logic, dispatch handler | + +--- + +## Task 1: Create `utils.dart` — Parsing helpers + +**Files:** +- Create: `lib/screens/dashboard/evm/grants/create/utils.dart` + +- [ ] **Step 1: Create `utils.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/utils.dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; + +Timestamp toTimestamp(DateTime value) { + final utc = value.toUtc(); + return Timestamp() + ..seconds = Int64(utc.millisecondsSinceEpoch ~/ 1000) + ..nanos = (utc.microsecondsSinceEpoch % 1000000) * 1000; +} + +TransactionRateLimit? buildRateLimit(String countText, String windowText) { + if (countText.trim().isEmpty || windowText.trim().isEmpty) return null; + return TransactionRateLimit( + count: int.parse(countText.trim()), + windowSecs: Int64.parseInt(windowText.trim()), + ); +} + +VolumeRateLimit? buildVolumeLimit(String amountText, String windowText) { + if (amountText.trim().isEmpty || windowText.trim().isEmpty) return null; + return VolumeRateLimit( + maxVolume: parseBigIntBytes(amountText), + windowSecs: Int64.parseInt(windowText.trim()), + ); +} + +List? optionalBigIntBytes(String value) { + if (value.trim().isEmpty) return null; + return parseBigIntBytes(value); +} + +List parseBigIntBytes(String value) { + final number = BigInt.parse(value.trim()); + if (number < BigInt.zero) throw Exception('Numeric values must be positive.'); + if (number == BigInt.zero) return [0]; + var remaining = number; + final bytes = []; + while (remaining > BigInt.zero) { + bytes.insert(0, (remaining & BigInt.from(0xff)).toInt()); + remaining >>= 8; + } + return bytes; +} + +List> parseAddresses(String input) { + final parts = input + .split(RegExp(r'[\n,]')) + .map((p) => p.trim()) + .where((p) => p.isNotEmpty); + return parts.map(parseHexAddress).toList(); +} + +List parseHexAddress(String value) { + final normalized = value.trim().replaceFirst(RegExp(r'^0x'), ''); + if (normalized.length != 40) throw Exception('Expected a 20-byte hex address.'); + return [ + for (var i = 0; i < normalized.length; i += 2) + int.parse(normalized.substring(i, i + 2), radix: 16), + ]; +} + +String shortAddress(List bytes) { + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}'; +} +``` + +- [ ] **Step 2: Verify** + +```sh +cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/utils.dart +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "refactor(grants): extract parsing helpers to utils.dart" +``` + +--- + +## Task 2: Create `provider.dart` — Grant creation state + +**Files:** +- Create: `lib/screens/dashboard/evm/grants/create/provider.dart` +- Create (generated): `lib/screens/dashboard/evm/grants/create/provider.freezed.dart` +- Create (generated): `lib/screens/dashboard/evm/grants/create/provider.g.dart` + +The provider holds only what cannot live in a `FormBuilder` field: +- `selectedClientId` — a helper for filtering the wallet access dropdown; not part of `SharedSettings` proto +- `grantType` — driven by a `SegmentedButton`, not a form input + +- [ ] **Step 1: Create `provider.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/provider.dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'provider.freezed.dart'; +part 'provider.g.dart'; + +@freezed +abstract class GrantCreationState with _$GrantCreationState { + const factory GrantCreationState({ + int? selectedClientId, + @Default(SpecificGrant_Grant.etherTransfer) SpecificGrant_Grant grantType, + }) = _GrantCreationState; +} + +@riverpod +class GrantCreation extends _$GrantCreation { + @override + GrantCreationState build() => const GrantCreationState(); + + void setClientId(int? id) => state = state.copyWith(selectedClientId: id); + void setGrantType(SpecificGrant_Grant type) => + state = state.copyWith(grantType: type); +} +``` + +- [ ] **Step 2: Run code generator** + +```sh +cd useragent && dart run build_runner build --delete-conflicting-outputs +``` + +Expected: generates `provider.freezed.dart` and `provider.g.dart`, no errors. + +- [ ] **Step 3: Verify** + +```sh +cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/provider.dart +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```sh +jj describe -m "feat(grants): add GrantCreation provider (client selection + grant type)" +``` + +--- + +## Task 3: Create field widgets + +**Files:** +- Create: `lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart` +- Create: `lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart` +- Create: `lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart` +- Create: `lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart` +- Create: `lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart` +- Create: `lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart` +- Create: `lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart` + +- [ ] **Step 1: Create `client_picker_field.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/sdk_clients/list.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class ClientPickerField extends ConsumerWidget { + const ClientPickerField({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clients = + ref.watch(sdkClientsProvider).asData?.value ?? const []; + + return FormBuilderDropdown( + name: 'clientId', + decoration: const InputDecoration( + labelText: 'Client', + border: OutlineInputBorder(), + ), + items: [ + for (final c in clients) + DropdownMenuItem( + value: c.id, + child: Text(c.info.name.isEmpty ? 'Client #${c.id}' : c.info.name), + ), + ], + onChanged: clients.isEmpty + ? null + : (value) => + ref.read(grantCreationProvider.notifier).setClientId(value), + ); + } +} +``` + +- [ ] **Step 2: Create `wallet_access_picker_field.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.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/wallet_access_list.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class WalletAccessPickerField extends ConsumerWidget { + const WalletAccessPickerField({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(grantCreationProvider); + final allAccesses = + ref.watch(walletAccessListProvider).asData?.value ?? + const []; + final wallets = + ref.watch(evmProvider).asData?.value ?? const []; + + final walletById = {for (final w in wallets) w.id: w}; + final accesses = state.selectedClientId == null + ? const [] + : allAccesses + .where((a) => a.access.sdkClientId == state.selectedClientId) + .toList(); + + return FormBuilderDropdown( + name: 'walletAccessId', + decoration: InputDecoration( + labelText: 'Wallet access', + helperText: state.selectedClientId == null + ? 'Select a client first' + : accesses.isEmpty + ? 'No wallet accesses for this client' + : null, + border: const OutlineInputBorder(), + ), + items: [ + for (final a in accesses) + DropdownMenuItem( + value: a.id, + child: Text(() { + final wallet = walletById[a.access.walletId]; + return wallet != null + ? shortAddress(wallet.address) + : 'Wallet #${a.access.walletId}'; + }()), + ), + ], + ); + } +} +``` + +- [ ] **Step 3: Create `chain_id_field.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; + +class ChainIdField extends StatelessWidget { + const ChainIdField({super.key}); + + @override + Widget build(BuildContext context) { + return FormBuilderTextField( + name: 'chainId', + initialValue: '1', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Chain ID', + hintText: '1', + border: OutlineInputBorder(), + ), + ); + } +} +``` + +- [ ] **Step 4: Create `date_time_field.dart`** + +`flutter_form_builder` has no built-in date+time picker that matches the existing UX (separate date then time dialog, long-press to clear). Implement one via `FormBuilderField`. + +```dart +// lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:sizer/sizer.dart'; + +/// A [FormBuilderField] that opens a date picker followed by a time picker. +/// Long-press clears the value. +class FormBuilderDateTimeField extends FormBuilderField { + final String label; + + FormBuilderDateTimeField({ + super.key, + required super.name, + required this.label, + super.initialValue, + super.onChanged, + super.validator, + }) : super( + builder: (FormFieldState field) { + final value = field.value; + return OutlinedButton( + onPressed: () async { + final now = DateTime.now(); + final date = await showDatePicker( + context: field.context, + firstDate: DateTime(now.year - 5), + lastDate: DateTime(now.year + 10), + initialDate: value ?? now, + ); + if (date == null) return; + // ignore: use_build_context_synchronously — field.context is + // still valid as long as the widget is in the tree. + if (!field.context.mounted) return; + final time = await showTimePicker( + context: field.context, + initialTime: TimeOfDay.fromDateTime(value ?? now), + ); + if (time == null) return; + field.didChange(DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + )); + }, + onLongPress: value == null ? null : () => field.didChange(null), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 1.8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label), + SizedBox(height: 0.6.h), + Text(value?.toLocal().toString() ?? 'Not set'), + ], + ), + ), + ); + }, + ); +} +``` + +- [ ] **Step 5: Create `validity_window_field.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart +import 'package:arbiter/screens/dashboard/evm/grants/create/fields/date_time_field.dart'; +import 'package:flutter/material.dart'; +import 'package:sizer/sizer.dart'; + +class ValidityWindowField extends StatelessWidget { + const ValidityWindowField({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FormBuilderDateTimeField( + name: 'validFrom', + label: 'Valid from', + ), + ), + SizedBox(width: 1.w), + Expanded( + child: FormBuilderDateTimeField( + name: 'validUntil', + label: 'Valid until', + ), + ), + ], + ); + } +} +``` + +- [ ] **Step 6: Create `gas_fee_options_field.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:sizer/sizer.dart'; + +class GasFeeOptionsField extends StatelessWidget { + const GasFeeOptionsField({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FormBuilderTextField( + name: 'maxGasFeePerGas', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Max gas fee / gas', + hintText: '1000000000', + border: OutlineInputBorder(), + ), + ), + ), + SizedBox(width: 1.w), + Expanded( + child: FormBuilderTextField( + name: 'maxPriorityFeePerGas', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Max priority fee / gas', + hintText: '100000000', + border: OutlineInputBorder(), + ), + ), + ), + ], + ); + } +} +``` + +- [ ] **Step 7: Create `transaction_rate_limit_field.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:sizer/sizer.dart'; + +class TransactionRateLimitField extends StatelessWidget { + const TransactionRateLimitField({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FormBuilderTextField( + name: 'txCount', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Tx count limit', + hintText: '10', + border: OutlineInputBorder(), + ), + ), + ), + SizedBox(width: 1.w), + Expanded( + child: FormBuilderTextField( + name: 'txWindow', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Window (seconds)', + hintText: '3600', + border: OutlineInputBorder(), + ), + ), + ), + ], + ); + } +} +``` + +- [ ] **Step 8: Verify all field widgets** + +```sh +cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/fields/ +``` + +Expected: no errors. + +- [ ] **Step 9: Commit** + +```sh +jj describe -m "feat(grants): add composable FormBuilder field widgets incl. custom DateTimeField" +``` + +--- + +## Task 4: Create `SharedGrantFields` widget + +**Files:** +- Create: `lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart` + +- [ ] **Step 1: Create `shared_grant_fields.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart +import 'package:arbiter/screens/dashboard/evm/grants/create/fields/chain_id_field.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/fields/client_picker_field.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/fields/validity_window_field.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart'; +import 'package:flutter/material.dart'; +import 'package:sizer/sizer.dart'; + +/// All shared grant fields in a single vertical layout. +/// +/// Every [FormBuilderField] descendant auto-registers with the nearest +/// [FormBuilder] ancestor via [BuildContext] — no controllers passed. +class SharedGrantFields extends StatelessWidget { + const SharedGrantFields({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const ClientPickerField(), + SizedBox(height: 1.6.h), + const WalletAccessPickerField(), + SizedBox(height: 1.6.h), + const ChainIdField(), + SizedBox(height: 1.6.h), + const ValidityWindowField(), + SizedBox(height: 1.6.h), + const GasFeeOptionsField(), + SizedBox(height: 1.6.h), + const TransactionRateLimitField(), + ], + ); + } +} +``` + +- [ ] **Step 2: Verify** + +```sh +cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "feat(grants): add SharedGrantFields composite widget" +``` + +--- + +## Task 5: Create grant form handlers + +**Files:** +- Create: `lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart` +- Create: `lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart` +- Create: `lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart` +- Create (generated): `lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.g.dart` + +- [ ] **Step 1: Create `grant_form_handler.dart`** + +`buildSpecificGrant` takes `WidgetRef` so each handler can read its own providers (e.g., token volume limits) without coupling to a shared state object. + +```dart +// lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +abstract class GrantFormHandler { + /// Renders the grant-specific form section. + /// + /// The returned widget must be a descendant of the [FormBuilder] in the + /// screen so its [FormBuilderField] children register automatically. + Widget buildForm(BuildContext context, WidgetRef ref); + + /// Assembles a [SpecificGrant] proto. + /// + /// [formValues] — `formKey.currentState!.value` after `saveAndValidate()`. + /// [ref] — read any provider the handler owns (e.g. token volume limits). + SpecificGrant buildSpecificGrant( + Map formValues, + WidgetRef ref, + ); +} +``` + +- [ ] **Step 2: Create `ether_transfer_grant.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; + +class EtherTransferGrantHandler implements GrantFormHandler { + const EtherTransferGrantHandler(); + + @override + Widget buildForm(BuildContext context, WidgetRef ref) => + const _EtherTransferForm(); + + @override + SpecificGrant buildSpecificGrant( + Map formValues, + WidgetRef ref, + ) { + return SpecificGrant( + etherTransfer: EtherTransferSettings( + targets: parseAddresses(formValues['etherRecipients'] as String? ?? ''), + limit: buildVolumeLimit( + formValues['etherVolume'] as String? ?? '', + formValues['etherVolumeWindow'] as String? ?? '', + ), + ), + ); + } +} + +class _EtherTransferForm extends StatelessWidget { + const _EtherTransferForm(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FormBuilderTextField( + name: 'etherRecipients', + minLines: 3, + maxLines: 6, + decoration: const InputDecoration( + labelText: 'Ether recipients', + hintText: + 'One 0x address per line. Leave empty for unrestricted targets.', + border: OutlineInputBorder(), + ), + ), + SizedBox(height: 1.6.h), + Text( + 'Ether volume limit', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 0.8.h), + Row( + children: [ + Expanded( + child: FormBuilderTextField( + name: 'etherVolume', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Max volume', + hintText: '1000000000000000000', + border: OutlineInputBorder(), + ), + ), + ), + SizedBox(width: 1.w), + Expanded( + child: FormBuilderTextField( + name: 'etherVolumeWindow', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Window (seconds)', + hintText: '86400', + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + ], + ); + } +} +``` + +- [ ] **Step 3: Create `token_transfer_grant.dart`** + +`TokenGrantLimits` is a scoped `@riverpod` provider (auto-dispose) that owns the dynamic volume limit list for the token grant form. `TokenTransferGrantHandler.buildSpecificGrant` reads it via `ref`. + +```dart +// lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sizer/sizer.dart'; + +part 'token_transfer_grant.g.dart'; + +// --------------------------------------------------------------------------- +// Volume limit entry — a single row's data +// --------------------------------------------------------------------------- + +class VolumeLimitEntry { + const VolumeLimitEntry({this.amount = '', this.windowSeconds = ''}); + + final String amount; + final String windowSeconds; + + VolumeLimitEntry copyWith({String? amount, String? windowSeconds}) => + VolumeLimitEntry( + amount: amount ?? this.amount, + windowSeconds: windowSeconds ?? this.windowSeconds, + ); +} + +// --------------------------------------------------------------------------- +// Provider — owns token volume limits; auto-disposed when screen pops +// --------------------------------------------------------------------------- + +@riverpod +class TokenGrantLimits extends _$TokenGrantLimits { + @override + List build() => const [VolumeLimitEntry()]; + + void add() => state = [...state, const VolumeLimitEntry()]; + + void update(int index, VolumeLimitEntry entry) { + final updated = [...state]; + updated[index] = entry; + state = updated; + } + + void remove(int index) => state = [...state]..removeAt(index); +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +class TokenTransferGrantHandler implements GrantFormHandler { + const TokenTransferGrantHandler(); + + @override + Widget buildForm(BuildContext context, WidgetRef ref) => + const _TokenTransferForm(); + + @override + SpecificGrant buildSpecificGrant( + Map formValues, + WidgetRef ref, + ) { + final limits = ref.read(tokenGrantLimitsProvider); + final targetText = formValues['tokenTarget'] as String? ?? ''; + + return SpecificGrant( + tokenTransfer: TokenTransferSettings( + tokenContract: + parseHexAddress(formValues['tokenContract'] as String? ?? ''), + target: targetText.trim().isEmpty ? null : parseHexAddress(targetText), + volumeLimits: limits + .where((e) => e.amount.trim().isNotEmpty) + .map( + (e) => VolumeRateLimit( + maxVolume: parseBigIntBytes(e.amount), + windowSecs: Int64.parseInt(e.windowSeconds), + ), + ) + .toList(), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Form widget +// --------------------------------------------------------------------------- + +class _TokenTransferForm extends ConsumerWidget { + const _TokenTransferForm(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final limits = ref.watch(tokenGrantLimitsProvider); + final notifier = ref.read(tokenGrantLimitsProvider.notifier); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FormBuilderTextField( + name: 'tokenContract', + decoration: const InputDecoration( + labelText: 'Token contract', + hintText: '0x...', + border: OutlineInputBorder(), + ), + ), + SizedBox(height: 1.6.h), + FormBuilderTextField( + name: 'tokenTarget', + decoration: const InputDecoration( + labelText: 'Token recipient', + hintText: '0x... or leave empty for any recipient', + border: OutlineInputBorder(), + ), + ), + SizedBox(height: 1.6.h), + _TokenVolumeLimitsField( + values: limits, + onAdd: notifier.add, + onUpdate: notifier.update, + onRemove: notifier.remove, + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Volume limits list widget +// --------------------------------------------------------------------------- + +class _TokenVolumeLimitsField extends StatelessWidget { + const _TokenVolumeLimitsField({ + required this.values, + required this.onAdd, + required this.onUpdate, + required this.onRemove, + }); + + final List values; + final VoidCallback onAdd; + final void Function(int index, VolumeLimitEntry entry) onUpdate; + final void Function(int index) onRemove; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Token volume limits', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + TextButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add_rounded), + label: const Text('Add'), + ), + ], + ), + SizedBox(height: 0.8.h), + for (var i = 0; i < values.length; i++) + Padding( + padding: EdgeInsets.only(bottom: 1.h), + child: _TokenVolumeLimitRow( + key: ValueKey(i), + value: values[i], + onChanged: (entry) => onUpdate(i, entry), + onRemove: values.length == 1 ? null : () => onRemove(i), + ), + ), + ], + ); + } +} + +class _TokenVolumeLimitRow extends HookWidget { + const _TokenVolumeLimitRow({ + super.key, + required this.value, + required this.onChanged, + required this.onRemove, + }); + + final VolumeLimitEntry value; + final ValueChanged onChanged; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + final amountController = useTextEditingController(text: value.amount); + final windowController = useTextEditingController(text: value.windowSeconds); + + return Row( + children: [ + Expanded( + child: TextField( + controller: amountController, + onChanged: (next) => onChanged(value.copyWith(amount: next)), + decoration: const InputDecoration( + labelText: 'Max volume', + border: OutlineInputBorder(), + ), + ), + ), + SizedBox(width: 1.w), + Expanded( + child: TextField( + controller: windowController, + onChanged: (next) => + onChanged(value.copyWith(windowSeconds: next)), + decoration: const InputDecoration( + labelText: 'Window (seconds)', + border: OutlineInputBorder(), + ), + ), + ), + SizedBox(width: 0.4.w), + IconButton( + onPressed: onRemove, + icon: const Icon(Icons.remove_circle_outline_rounded), + ), + ], + ); + } +} +``` + +- [ ] **Step 4: Run code generator for token_transfer_grant.g.dart** + +```sh +cd useragent && dart run build_runner build --delete-conflicting-outputs +``` + +Expected: generates `token_transfer_grant.g.dart`, no errors. + +- [ ] **Step 5: Verify** + +```sh +cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/grants/ +``` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```sh +jj describe -m "feat(grants): add GrantFormHandler interface and per-type implementations" +``` + +--- + +## Task 6: Rewrite `screen.dart` + +**Files:** +- Modify: `lib/screens/dashboard/evm/grants/create/screen.dart` + +The screen owns `FormBuilder`, dispatches to the active handler, and assembles `SharedSettings` on submit. `validFrom`/`validUntil` are now read from `formValues['validFrom']` etc. — no more provider reads for dates. + +- [ ] **Step 1: Replace `screen.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/screen.dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/providers/evm/evm_grants.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/shared_grant_fields.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; +import 'package:sizer/sizer.dart'; + +const _etherHandler = EtherTransferGrantHandler(); +const _tokenHandler = TokenTransferGrantHandler(); + +GrantFormHandler _handlerFor(SpecificGrant_Grant type) => switch (type) { + SpecificGrant_Grant.etherTransfer => _etherHandler, + SpecificGrant_Grant.tokenTransfer => _tokenHandler, + _ => throw ArgumentError('Unsupported grant type: $type'), + }; + +@RoutePage() +class CreateEvmGrantScreen extends HookConsumerWidget { + const CreateEvmGrantScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + final createMutation = ref.watch(createEvmGrantMutation); + final state = ref.watch(grantCreationProvider); + final notifier = ref.read(grantCreationProvider.notifier); + final handler = _handlerFor(state.grantType); + + Future submit() async { + if (!(formKey.currentState?.saveAndValidate() ?? false)) return; + final formValues = formKey.currentState!.value; + + final accessId = formValues['walletAccessId'] as int?; + if (accessId == null) { + _showSnackBar(context, 'Select a client and wallet access.'); + return; + } + + try { + final specific = handler.buildSpecificGrant(formValues, ref); + final sharedSettings = SharedSettings( + walletAccessId: accessId, + chainId: Int64.parseInt( + (formValues['chainId'] as String? ?? '').trim(), + ), + ); + final validFrom = formValues['validFrom'] as DateTime?; + final validUntil = formValues['validUntil'] as DateTime?; + if (validFrom != null) sharedSettings.validFrom = toTimestamp(validFrom); + if (validUntil != null) { + sharedSettings.validUntil = toTimestamp(validUntil); + } + final gasBytes = + optionalBigIntBytes(formValues['maxGasFeePerGas'] as String? ?? ''); + if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes; + final priorityBytes = optionalBigIntBytes( + formValues['maxPriorityFeePerGas'] as String? ?? '', + ); + if (priorityBytes != null) { + sharedSettings.maxPriorityFeePerGas = priorityBytes; + } + final rateLimit = buildRateLimit( + formValues['txCount'] as String? ?? '', + formValues['txWindow'] as String? ?? '', + ); + if (rateLimit != null) sharedSettings.rateLimit = rateLimit; + + await executeCreateEvmGrant( + ref, + sharedSettings: sharedSettings, + specific: specific, + ); + if (!context.mounted) return; + context.router.pop(); + } catch (error) { + if (!context.mounted) return; + _showSnackBar(context, _formatError(error)); + } + } + + return Scaffold( + appBar: AppBar(title: const Text('Create EVM Grant')), + body: SafeArea( + child: FormBuilder( + key: formKey, + child: ListView( + padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h), + children: [ + const _IntroCard(), + SizedBox(height: 1.8.h), + const _Section( + title: 'Shared grant options', + child: SharedGrantFields(), + ), + SizedBox(height: 1.8.h), + _GrantTypeSelector( + value: state.grantType, + onChanged: notifier.setGrantType, + ), + SizedBox(height: 1.8.h), + _Section( + title: 'Grant-specific options', + child: handler.buildForm(context, ref), + ), + SizedBox(height: 2.2.h), + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: + createMutation is MutationPending ? null : submit, + icon: createMutation is MutationPending + ? SizedBox( + width: 1.8.h, + height: 1.8.h, + child: const CircularProgressIndicator( + strokeWidth: 2.2, + ), + ) + : const Icon(Icons.check_rounded), + label: Text( + createMutation is MutationPending + ? 'Creating...' + : 'Create grant', + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Layout helpers +// --------------------------------------------------------------------------- + +class _IntroCard extends StatelessWidget { + const _IntroCard(); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(2.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: const LinearGradient( + colors: [Color(0xFFF7F9FC), Color(0xFFFDF5EA)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: Border.all(color: const Color(0x1A17324A)), + ), + child: Text( + 'Pick a client, then select one of the wallet accesses already granted ' + 'to it. Compose shared constraints once, then switch between Ether and ' + 'token transfer rules.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5), + ), + ); + } +} + +class _Section extends StatelessWidget { + const _Section({required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(2.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Colors.white, + border: Border.all(color: const Color(0x1A17324A)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 1.4.h), + child, + ], + ), + ); + } +} + +class _GrantTypeSelector extends StatelessWidget { + const _GrantTypeSelector({required this.value, required this.onChanged}); + + final SpecificGrant_Grant value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: const [ + ButtonSegment( + value: SpecificGrant_Grant.etherTransfer, + label: Text('Ether'), + icon: Icon(Icons.bolt_rounded), + ), + ButtonSegment( + value: SpecificGrant_Grant.tokenTransfer, + label: Text('Token'), + icon: Icon(Icons.token_rounded), + ), + ], + selected: {value}, + onSelectionChanged: (selection) => onChanged(selection.first), + ); + } +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +void _showSnackBar(BuildContext context, String message) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); +} + +String _formatError(Object error) { + final text = error.toString(); + return text.startsWith('Exception: ') + ? text.substring('Exception: '.length) + : text; +} +``` + +- [ ] **Step 2: Verify the full create/ directory** + +```sh +cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/ +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "refactor(grants): decompose CreateEvmGrantScreen into provider + field widgets + grant handlers" +``` + +--- + +## Self-Review + +### Spec coverage + +| Requirement | Task | +|-------------|------| +| Riverpod provider for grant creation state | Task 2 (`GrantCreation`) | +| flutter_form_builder in composable manner | Tasks 3–4 | +| `fields/` folder for shared fields | Task 3 | +| Custom `FormBuilderDateTimeField` (no built-in) | Task 3, Step 4 | +| `validFrom`/`validUntil` as form fields | Task 3 (`ValidityWindowField` uses `FormBuilderDateTimeField`) | +| `tokenVolumeLimits` in grant-specific implementation | Task 5 (`TokenGrantLimits` in `token_transfer_grant.dart`) | +| Grant handler with `buildForm` + `buildSpecificGrant` | Task 5 | +| `SharedGrantFields` widget | Task 4 | +| Thin main screen | Task 6 | +| Class renamed to `GrantCreation` (provider: `grantCreationProvider`) | Task 2 | + +### Placeholder scan + +No TODOs, TBDs, or "similar to task N" references found. + +### Type consistency + +- `VolumeLimitEntry` defined in `token_transfer_grant.dart`, used only within that file ✓ +- `GrantFormHandler` interface: `buildSpecificGrant(Map, WidgetRef)` matches both implementations ✓ +- `grantCreationProvider` (from `@riverpod class GrantCreation`) used in `client_picker_field.dart`, `wallet_access_picker_field.dart`, `screen.dart` ✓ +- `tokenGrantLimitsProvider` (from `@riverpod class TokenGrantLimits`) used in `_TokenTransferForm.build` and `TokenTransferGrantHandler.buildSpecificGrant` ✓ +- FormBuilder field names in `screen.dart` (`validFrom`, `validUntil`, `walletAccessId`, `chainId`, `maxGasFeePerGas`, `maxPriorityFeePerGas`, `txCount`, `txWindow`) match the `name:` params in field widgets ✓ diff --git a/docs/superpowers/plans/2026-03-28-grant-grid-view.md b/docs/superpowers/plans/2026-03-28-grant-grid-view.md new file mode 100644 index 0000000..859971a --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-grant-grid-view.md @@ -0,0 +1,821 @@ +# Grant Grid View Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an "EVM Grants" dashboard tab that displays all grants as enriched cards (type, chain, wallet address, client name) with per-card revoke support. + +**Architecture:** A new `walletAccessListProvider` fetches wallet accesses with their DB row IDs. The screen (`grants.dart`) watches only `evmGrantsProvider` for top-level state. Each `GrantCard` widget (its own file) watches enrichment providers (`walletAccessListProvider`, `evmProvider`, `sdkClientsProvider`) and the revoke mutation directly — keeping rebuilds scoped to the card. The screen is registered as a dashboard tab in `AdaptiveScaffold`. + +**Tech Stack:** Flutter, Riverpod (`riverpod_annotation` + `build_runner` codegen), `sizer` (adaptive sizing), `auto_route`, Protocol Buffers (Dart), `Palette` design tokens. + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `useragent/lib/theme/palette.dart` | Modify | Add `Palette.token` (indigo accent for token-transfer cards) | +| `useragent/lib/features/connection/evm/wallet_access.dart` | Modify | Add `listAllWalletAccesses()` function | +| `useragent/lib/providers/sdk_clients/wallet_access_list.dart` | Create | `WalletAccessListProvider` — fetches full wallet access list with IDs | +| `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` | Create | `GrantCard` widget — watches enrichment providers + revoke mutation; one card per grant | +| `useragent/lib/screens/dashboard/evm/grants/grants.dart` | Create | `EvmGrantsScreen` — watches `evmGrantsProvider`; handles loading/error/empty/data states; renders `GrantCard` list | +| `useragent/lib/router.dart` | Modify | Register `EvmGrantsRoute` in dashboard children | +| `useragent/lib/screens/dashboard.dart` | Modify | Add Grants entry to `routes` list and `NavigationDestination` list | + +--- + +## Task 1: Add `Palette.token` + +**Files:** +- Modify: `useragent/lib/theme/palette.dart` + +- [ ] **Step 1: Add the color** + +Replace the contents of `useragent/lib/theme/palette.dart` with: + +```dart +import 'package:flutter/material.dart'; + +class Palette { + static const ink = Color(0xFF15263C); + static const coral = Color(0xFFE26254); + static const cream = Color(0xFFFFFAF4); + static const line = Color(0x1A15263C); + static const token = Color(0xFF5C6BC0); +} +``` + +- [ ] **Step 2: Verify** + +```sh +cd useragent && flutter analyze lib/theme/palette.dart +``` + +Expected: no issues. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "feat(theme): add Palette.token for token-transfer grant cards" +jj new +``` + +--- + +## Task 2: Add `listAllWalletAccesses` feature function + +**Files:** +- Modify: `useragent/lib/features/connection/evm/wallet_access.dart` + +`readClientWalletAccess` (existing) filters the list to one client's wallet IDs and returns `Set`. This new function returns the complete unfiltered list with row IDs so the grant cards can resolve wallet_access_id → wallet + client. + +- [ ] **Step 1: Append function** + +Add at the bottom of `useragent/lib/features/connection/evm/wallet_access.dart`: + +```dart +Future> listAllWalletAccesses( + Connection connection, +) async { + final response = await connection.ask( + UserAgentRequest(listWalletAccess: Empty()), + ); + if (!response.hasListWalletAccessResponse()) { + throw Exception( + 'Expected list wallet access response, got ${response.whichPayload()}', + ); + } + return response.listWalletAccessResponse.accesses.toList(growable: false); +} +``` + +Each returned `SdkClientWalletAccess` has: +- `.id` — the `evm_wallet_access` row ID (same value as `wallet_access_id` in a `GrantEntry`) +- `.access.walletId` — the EVM wallet DB ID +- `.access.sdkClientId` — the SDK client DB ID + +- [ ] **Step 2: Verify** + +```sh +cd useragent && flutter analyze lib/features/connection/evm/wallet_access.dart +``` + +Expected: no issues. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "feat(evm): add listAllWalletAccesses feature function" +jj new +``` + +--- + +## Task 3: Create `WalletAccessListProvider` + +**Files:** +- Create: `useragent/lib/providers/sdk_clients/wallet_access_list.dart` +- Generated: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart` + +Mirrors the structure of `EvmGrants` in `providers/evm/evm_grants.dart` — class-based `@riverpod` with a `refresh()` method. + +- [ ] **Step 1: Write the provider** + +Create `useragent/lib/providers/sdk_clients/wallet_access_list.dart`: + +```dart +import 'package:arbiter/features/connection/evm/wallet_access.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:mtcore/markettakers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'wallet_access_list.g.dart'; + +@riverpod +class WalletAccessList extends _$WalletAccessList { + @override + Future?> build() async { + final connection = await ref.watch(connectionManagerProvider.future); + if (connection == null) { + return null; + } + + try { + return await listAllWalletAccesses(connection); + } catch (e, st) { + talker.handle(e, st); + rethrow; + } + } + + Future refresh() async { + final connection = await ref.read(connectionManagerProvider.future); + if (connection == null) { + state = const AsyncData(null); + return; + } + + state = const AsyncLoading(); + state = await AsyncValue.guard(() => listAllWalletAccesses(connection)); + } +} +``` + +- [ ] **Step 2: Run code generation** + +```sh +cd useragent && dart run build_runner build --delete-conflicting-outputs +``` + +Expected: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors. + +- [ ] **Step 3: Verify** + +```sh +cd useragent && flutter analyze lib/providers/sdk_clients/ +``` + +Expected: no issues. + +- [ ] **Step 4: Commit** + +```sh +jj describe -m "feat(providers): add WalletAccessListProvider" +jj new +``` + +--- + +## Task 4: Create `GrantCard` widget + +**Files:** +- Create: `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` + +This widget owns all per-card logic: enrichment lookups, revoke action, and rebuild scope. The screen only passes it a `GrantEntry` — the card fetches everything else itself. + +**Key types:** +- `GrantEntry` (from `proto/evm.pb.dart`): `.id`, `.shared.walletAccessId`, `.shared.chainId`, `.specific.whichGrant()` +- `SpecificGrant_Grant.etherTransfer` / `.tokenTransfer` — enum values for the oneof +- `SdkClientWalletAccess` (from `proto/user_agent.pb.dart`): `.id`, `.access.walletId`, `.access.sdkClientId` +- `WalletEntry` (from `proto/evm.pb.dart`): `.id`, `.address` (List) +- `SdkClientEntry` (from `proto/user_agent.pb.dart`): `.id`, `.info.name` +- `revokeEvmGrantMutation` — `Mutation` (global; all revoke buttons disable together while any revoke is in flight) +- `executeRevokeEvmGrant(ref, grantId: int)` — `Future` + +- [ ] **Step 1: Write the widget** + +Create `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`: + +```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/evm/evm_grants.dart'; +import 'package:arbiter/providers/sdk_clients/list.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; + +String _shortAddress(List bytes) { + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}'; +} + +String _formatError(Object error) { + final message = error.toString(); + if (message.startsWith('Exception: ')) { + return message.substring('Exception: '.length); + } + return message; +} + +class GrantCard extends ConsumerWidget { + const GrantCard({super.key, required this.grant}); + + final GrantEntry grant; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Enrichment lookups — each watch scopes rebuilds to this card only + final walletAccesses = + ref.watch(walletAccessListProvider).asData?.value ?? const []; + final wallets = ref.watch(evmProvider).asData?.value ?? const []; + final clients = ref.watch(sdkClientsProvider).asData?.value ?? const []; + final revoking = ref.watch(revokeEvmGrantMutation) is MutationPending; + + final isEther = + grant.specific.whichGrant() == SpecificGrant_Grant.etherTransfer; + final accent = isEther ? Palette.coral : Palette.token; + final typeLabel = isEther ? 'Ether' : 'Token'; + final theme = Theme.of(context); + final muted = Palette.ink.withValues(alpha: 0.62); + + // Resolve wallet_access_id → wallet address + client name + final accessById = { + for (final a in walletAccesses) a.id: a, + }; + final walletById = { + for (final w in wallets) w.id: w, + }; + final clientNameById = { + for (final c in clients) c.id: c.info.name, + }; + + final accessId = grant.shared.walletAccessId; + final access = accessById[accessId]; + final wallet = access != null ? walletById[access.access.walletId] : null; + + final walletLabel = wallet != null + ? _shortAddress(wallet.address) + : 'Access #$accessId'; + + final clientLabel = () { + if (access == null) return ''; + final name = clientNameById[access.access.sdkClientId] ?? ''; + return name.isEmpty ? 'Client #${access.access.sdkClientId}' : name; + }(); + + void showError(String message) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + Future revoke() async { + try { + await executeRevokeEvmGrant(ref, grantId: grant.id); + } catch (e) { + showError(_formatError(e)); + } + } + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Accent strip + Container( + width: 0.8.w, + decoration: BoxDecoration( + color: accent, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(24), + ), + ), + ), + // Card body + Expanded( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 1.6.w, + vertical: 1.4.h, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row 1: type badge · chain · spacer · revoke button + Row( + children: [ + Container( + padding: EdgeInsets.symmetric( + horizontal: 1.w, + vertical: 0.4.h, + ), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + typeLabel, + style: theme.textTheme.labelSmall?.copyWith( + color: accent, + fontWeight: FontWeight.w800, + ), + ), + ), + SizedBox(width: 1.w), + Container( + padding: EdgeInsets.symmetric( + horizontal: 1.w, + vertical: 0.4.h, + ), + decoration: BoxDecoration( + color: Palette.ink.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Chain ${grant.shared.chainId}', + style: theme.textTheme.labelSmall?.copyWith( + color: muted, + fontWeight: FontWeight.w700, + ), + ), + ), + const Spacer(), + if (revoking) + SizedBox( + width: 1.8.h, + height: 1.8.h, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Palette.coral, + ), + ) + else + OutlinedButton.icon( + onPressed: revoke, + style: OutlinedButton.styleFrom( + foregroundColor: Palette.coral, + side: BorderSide( + color: Palette.coral.withValues(alpha: 0.4), + ), + padding: EdgeInsets.symmetric( + horizontal: 1.w, + vertical: 0.6.h, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + icon: const Icon(Icons.block_rounded, size: 16), + label: const Text('Revoke'), + ), + ], + ), + SizedBox(height: 0.8.h), + // Row 2: wallet address · client name + Row( + children: [ + Text( + walletLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: Palette.ink, + fontFamily: 'monospace', + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 0.8.w), + child: Text( + '·', + style: theme.textTheme.bodySmall + ?.copyWith(color: muted), + ), + ), + Expanded( + child: Text( + clientLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall + ?.copyWith(color: muted), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 2: Verify** + +```sh +cd useragent && flutter analyze lib/screens/dashboard/evm/grants/widgets/grant_card.dart +``` + +Expected: no issues. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "feat(grants): add GrantCard widget with self-contained enrichment" +jj new +``` + +--- + +## Task 5: Create `EvmGrantsScreen` + +**Files:** +- Create: `useragent/lib/screens/dashboard/evm/grants/grants.dart` + +The screen watches only `evmGrantsProvider` for top-level state (loading / error / no connection / empty / data). When there is data it renders a list of `GrantCard` widgets — each card manages its own enrichment subscriptions. + +- [ ] **Step 1: Write the screen** + +Create `useragent/lib/screens/dashboard/evm/grants/grants.dart`: + +```dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/providers/evm/evm_grants.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart'; +import 'package:arbiter/router.gr.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:arbiter/widgets/page_header.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; + +String _formatError(Object error) { + final message = error.toString(); + if (message.startsWith('Exception: ')) { + return message.substring('Exception: '.length); + } + return message; +} + +// ─── State panel ────────────────────────────────────────────────────────────── + +class _StatePanel extends StatelessWidget { + const _StatePanel({ + required this.icon, + required this.title, + required this.body, + this.actionLabel, + this.onAction, + this.busy = false, + }); + + final IconData icon; + final String title; + final String body; + final String? actionLabel; + final Future Function()? onAction; + final bool busy; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), + ), + child: Padding( + padding: EdgeInsets.all(2.8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (busy) + SizedBox( + width: 2.8.h, + height: 2.8.h, + child: const CircularProgressIndicator(strokeWidth: 2.5), + ) + else + Icon(icon, size: 34, color: Palette.coral), + SizedBox(height: 1.8.h), + Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + color: Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 1.h), + Text( + body, + style: theme.textTheme.bodyLarge?.copyWith( + color: Palette.ink.withValues(alpha: 0.72), + height: 1.5, + ), + ), + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: 2.h), + OutlinedButton.icon( + onPressed: () => onAction!(), + icon: const Icon(Icons.refresh), + label: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} + +// ─── Grant list ─────────────────────────────────────────────────────────────── + +class _GrantList extends StatelessWidget { + const _GrantList({required this.grants}); + + final List grants; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (var i = 0; i < grants.length; i++) + Padding( + padding: EdgeInsets.only( + bottom: i == grants.length - 1 ? 0 : 1.8.h, + ), + child: GrantCard(grant: grants[i]), + ), + ], + ); + } +} + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +@RoutePage() +class EvmGrantsScreen extends ConsumerWidget { + const EvmGrantsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Screen watches only the grant list for top-level state decisions + final grantsAsync = ref.watch(evmGrantsProvider); + + Future refresh() async { + await Future.wait([ + ref.read(evmGrantsProvider.notifier).refresh(), + ref.read(walletAccessListProvider.notifier).refresh(), + ]); + } + + void showMessage(String message) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + Future safeRefresh() async { + try { + await refresh(); + } catch (e) { + showMessage(_formatError(e)); + } + } + + final grantsState = grantsAsync.asData?.value; + final grants = grantsState?.grants; + + final content = switch (grantsAsync) { + AsyncLoading() when grantsState == null => const _StatePanel( + icon: Icons.hourglass_top, + title: 'Loading grants', + body: 'Pulling grant registry from Arbiter.', + busy: true, + ), + AsyncError(:final error) => _StatePanel( + icon: Icons.sync_problem, + title: 'Grant registry unavailable', + body: _formatError(error), + actionLabel: 'Retry', + onAction: safeRefresh, + ), + AsyncData(:final value) when value == null => _StatePanel( + icon: Icons.portable_wifi_off, + title: 'No active server connection', + body: 'Reconnect to Arbiter to list EVM grants.', + actionLabel: 'Refresh', + onAction: safeRefresh, + ), + _ when grants != null && grants.isEmpty => _StatePanel( + icon: Icons.policy_outlined, + title: 'No grants yet', + body: 'Create a grant to allow SDK clients to sign transactions.', + actionLabel: 'Create grant', + onAction: () => context.router.push(const CreateEvmGrantRoute()), + ), + _ => _GrantList(grants: grants ?? const []), + }; + + return Scaffold( + body: SafeArea( + child: RefreshIndicator.adaptive( + color: Palette.ink, + backgroundColor: Colors.white, + onRefresh: safeRefresh, + child: ListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h), + children: [ + PageHeader( + title: 'EVM Grants', + isBusy: grantsAsync.isLoading, + actions: [ + FilledButton.icon( + onPressed: () => + context.router.push(const CreateEvmGrantRoute()), + icon: const Icon(Icons.add_rounded), + label: const Text('Create grant'), + ), + SizedBox(width: 1.w), + OutlinedButton.icon( + onPressed: safeRefresh, + style: OutlinedButton.styleFrom( + foregroundColor: Palette.ink, + side: BorderSide(color: Palette.line), + padding: EdgeInsets.symmetric( + horizontal: 1.4.w, + vertical: 1.2.h, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Refresh'), + ), + ], + ), + SizedBox(height: 1.8.h), + content, + ], + ), + ), + ), + ); + } +} +``` + +- [ ] **Step 2: Verify** + +```sh +cd useragent && flutter analyze lib/screens/dashboard/evm/grants/ +``` + +Expected: no issues. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "feat(grants): add EvmGrantsScreen" +jj new +``` + +--- + +## Task 6: Wire router and dashboard tab + +**Files:** +- Modify: `useragent/lib/router.dart` +- Modify: `useragent/lib/screens/dashboard.dart` +- Regenerated: `useragent/lib/router.gr.dart` + +- [ ] **Step 1: Add route to `router.dart`** + +Replace the contents of `useragent/lib/router.dart` with: + +```dart +import 'package:auto_route/auto_route.dart'; + +import 'router.gr.dart'; + +@AutoRouterConfig(generateForDir: ['lib/screens']) +class Router extends RootStackRouter { + @override + List get routes => [ + AutoRoute(page: Bootstrap.page, path: '/bootstrap', initial: true), + 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( + page: DashboardRouter.page, + path: '/dashboard', + children: [ + AutoRoute(page: EvmRoute.page, path: 'evm'), + AutoRoute(page: ClientsRoute.page, path: 'clients'), + AutoRoute(page: EvmGrantsRoute.page, path: 'grants'), + AutoRoute(page: AboutRoute.page, path: 'about'), + ], + ), + ]; +} +``` + +- [ ] **Step 2: Update `dashboard.dart`** + +In `useragent/lib/screens/dashboard.dart`, replace the `routes` constant: + +```dart +final routes = [ + const EvmRoute(), + const ClientsRoute(), + const EvmGrantsRoute(), + const AboutRoute(), +]; +``` + +And replace the `destinations` list inside `AdaptiveScaffold`: + +```dart +destinations: const [ + NavigationDestination( + icon: Icon(Icons.account_balance_wallet_outlined), + selectedIcon: Icon(Icons.account_balance_wallet), + label: 'Wallets', + ), + NavigationDestination( + icon: Icon(Icons.devices_other_outlined), + selectedIcon: Icon(Icons.devices_other), + label: 'Clients', + ), + NavigationDestination( + icon: Icon(Icons.policy_outlined), + selectedIcon: Icon(Icons.policy), + label: 'Grants', + ), + NavigationDestination( + icon: Icon(Icons.info_outline), + selectedIcon: Icon(Icons.info), + label: 'About', + ), +], +``` + +- [ ] **Step 3: Regenerate router** + +```sh +cd useragent && dart run build_runner build --delete-conflicting-outputs +``` + +Expected: `lib/router.gr.dart` updated, `EvmGrantsRoute` now available, no errors. + +- [ ] **Step 4: Full project verify** + +```sh +cd useragent && flutter analyze +``` + +Expected: no issues. + +- [ ] **Step 5: Commit** + +```sh +jj describe -m "feat(nav): add Grants dashboard tab" +jj new +``` diff --git a/docs/superpowers/specs/2026-03-28-grant-grid-view-design.md b/docs/superpowers/specs/2026-03-28-grant-grid-view-design.md new file mode 100644 index 0000000..f7094c2 --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-grant-grid-view-design.md @@ -0,0 +1,170 @@ +# Grant Grid View — Design Spec + +**Date:** 2026-03-28 + +## Overview + +Add a "Grants" dashboard tab to the Flutter user-agent app that displays all EVM grants as a card-based grid. Each card shows a compact summary (type, chain, wallet address, client name) with a revoke action. The tab integrates into the existing `AdaptiveScaffold` navigation alongside Wallets, Clients, and About. + +## Scope + +- New `walletAccessListProvider` for fetching wallet access entries with their DB row IDs +- New `EvmGrantsScreen` as a dashboard tab +- Grant card widget with enriched display (type, chain, wallet, client) +- Revoke action wired to existing `executeRevokeEvmGrant` mutation +- Dashboard tab bar and router updated +- New token-transfer accent color added to `Palette` + +**Out of scope:** Fixing grant creation (separate task). + +--- + +## Data Layer + +### `walletAccessListProvider` + +**File:** `useragent/lib/providers/sdk_clients/wallet_access_list.dart` + +- `@riverpod` class, watches `connectionManagerProvider.future` +- Returns `List?` (null when not connected) +- Each entry: `.id` (wallet_access_id), `.access.walletId`, `.access.sdkClientId` +- Exposes a `refresh()` method following the same pattern as `EvmGrants.refresh()` + +### Enrichment at render time (Approach A) + +The `EvmGrantsScreen` watches four providers: +1. `evmGrantsProvider` — the grant list +2. `walletAccessListProvider` — to resolve wallet_access_id → (wallet_id, sdk_client_id) +3. `evmProvider` — to resolve wallet_id → wallet address +4. `sdkClientsProvider` — to resolve sdk_client_id → client name + +All lookups are in-memory Maps built inside the build method; no extra model class needed. + +Fallbacks: +- Wallet address not found → `"Access #N"` where N is the wallet_access_id +- Client name not found → `"Client #N"` where N is the sdk_client_id + +--- + +## Route Structure + +``` +/dashboard + /evm ← existing (Wallets tab) + /clients ← existing (Clients tab) + /grants ← NEW (Grants tab) + /about ← existing + +/evm-grants/create ← existing push route (unchanged) +``` + +### Changes to `router.dart` + +Add inside dashboard children: +```dart +AutoRoute(page: EvmGrantsRoute.page, path: 'grants'), +``` + +### Changes to `dashboard.dart` + +Add to `routes` list: +```dart +const EvmGrantsRoute() +``` + +Add `NavigationDestination`: +```dart +NavigationDestination( + icon: Icon(Icons.policy_outlined), + selectedIcon: Icon(Icons.policy), + label: 'Grants', +), +``` + +--- + +## Screen: `EvmGrantsScreen` + +**File:** `useragent/lib/screens/dashboard/evm/grants/grants.dart` + +``` +Scaffold +└─ SafeArea + └─ RefreshIndicator.adaptive (refreshes evmGrantsProvider + walletAccessListProvider) + └─ ListView (BouncingScrollPhysics + AlwaysScrollableScrollPhysics) + ├─ PageHeader + │ title: 'EVM Grants' + │ isBusy: evmGrantsProvider.isLoading + │ actions: [CreateGrantButton, RefreshButton] + ├─ SizedBox(height: 1.8.h) + └─ +``` + +### State handling + +Matches the pattern from `EvmScreen` and `ClientsScreen`: + +| State | Display | +|---|---| +| Loading (no data yet) | `_StatePanel` with spinner, "Loading grants" | +| Error | `_StatePanel` with coral icon, error message, Retry button | +| No connection | `_StatePanel`, "No active server connection" | +| Empty list | `_StatePanel`, "No grants yet", with Create Grant shortcut | +| Data | Column of `_GrantCard` widgets | + +### Header actions + +**CreateGrantButton:** `FilledButton.icon` with `Icons.add_rounded`, pushes `CreateEvmGrantRoute()` via `context.router.push(...)`. + +**RefreshButton:** `OutlinedButton.icon` with `Icons.refresh`, calls `ref.read(evmGrantsProvider.notifier).refresh()`. + +--- + +## Grant Card: `_GrantCard` + +**Layout:** + +``` +Container (rounded 24, Palette.cream bg, Palette.line border) +└─ IntrinsicHeight > Row + ├─ Accent strip (0.8.w wide, full height, rounded left) + └─ Padding > Column + ├─ Row 1: TypeBadge + ChainChip + Spacer + RevokeButton + └─ Row 2: WalletText + "·" + ClientText +``` + +**Accent color by grant type:** +- Ether transfer → `Palette.coral` +- Token transfer → `Palette.token` (new entry in `Palette` — indigo, e.g. `Color(0xFF5C6BC0)`) + +**TypeBadge:** Small pill container with accent color background at 15% opacity, accent-colored text. Label: `'Ether'` or `'Token'`. + +**ChainChip:** Small container: `'Chain ${grant.shared.chainId}'`, muted ink color. + +**WalletText:** Short hex address (`0xabc...def`) from wallet lookup, `bodySmall`, monospace font family. + +**ClientText:** Client name from `sdkClientsProvider` lookup, or fallback string. `bodySmall`, muted ink. + +**RevokeButton:** +- `OutlinedButton` with `Icons.block_rounded` icon, label `'Revoke'` +- `foregroundColor: Palette.coral`, `side: BorderSide(color: Palette.coral.withValues(alpha: 0.4))` +- Disabled (replaced with `CircularProgressIndicator`) while `revokeEvmGrantMutation` is pending — note: this is a single global mutation, so all revoke buttons disable while any revoke is in flight +- On press: calls `executeRevokeEvmGrant(ref, grantId: grant.id)`; shows `SnackBar` on error + +--- + +## Adaptive Sizing + +All sizing uses `sizer` units (`1.h`, `1.w`, etc.). No hardcoded pixel values. + +--- + +## Files to Create / Modify + +| File | Action | +|---|---| +| `lib/theme/palette.dart` | Modify — add `Palette.token` color | +| `lib/providers/sdk_clients/wallet_access_list.dart` | Create | +| `lib/screens/dashboard/evm/grants/grants.dart` | Create | +| `lib/router.dart` | Modify — add grants route to dashboard children | +| `lib/screens/dashboard.dart` | Modify — add tab to routes list and NavigationDestinations | 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>(), + ); + }); +}