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