refactor(useragent::evm::grants): split into more files & flutter_form_builder usage

This commit is contained in:
hdbg
2026-03-28 19:35:58 +01:00
parent 643f251419
commit 59c7091cba
23 changed files with 1656 additions and 930 deletions

View File

@@ -0,0 +1,21 @@
// lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
class ChainIdField extends StatelessWidget {
const ChainIdField({super.key});
@override
Widget build(BuildContext context) {
return FormBuilderTextField(
name: 'chainId',
initialValue: '1',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Chain ID',
hintText: '1',
border: OutlineInputBorder(),
),
);
}
}

View File

@@ -0,0 +1,38 @@
// lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ClientPickerField extends ConsumerWidget {
const ClientPickerField({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final clients =
ref.watch(sdkClientsProvider).asData?.value ?? const <SdkClientEntry>[];
return FormBuilderDropdown<int>(
name: 'clientId',
decoration: const InputDecoration(
labelText: 'Client',
border: OutlineInputBorder(),
),
items: [
for (final c in clients)
DropdownMenuItem(
value: c.id,
child: Text(c.info.name.isEmpty ? 'Client #${c.id}' : c.info.name),
),
],
onChanged: clients.isEmpty
? null
: (value) {
ref.read(grantCreationProvider.notifier).setClientId(value);
FormBuilder.of(context)?.fields['walletAccessId']?.didChange(null);
},
);
}
}

View File

@@ -0,0 +1,61 @@
// lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:sizer/sizer.dart';
/// A [FormBuilderField] that opens a date picker followed by a time picker.
/// Long-press clears the value.
class FormBuilderDateTimeField extends FormBuilderField<DateTime?> {
final String label;
FormBuilderDateTimeField({
super.key,
required super.name,
required this.label,
super.initialValue,
super.onChanged,
super.validator,
}) : super(
builder: (FormFieldState<DateTime?> field) {
final value = field.value;
return OutlinedButton(
onPressed: () async {
final ctx = field.context;
final now = DateTime.now();
final date = await showDatePicker(
context: ctx,
firstDate: DateTime(now.year - 5),
lastDate: DateTime(now.year + 10),
initialDate: value ?? now,
);
if (date == null) return;
if (!ctx.mounted) return;
final time = await showTimePicker(
context: ctx,
initialTime: TimeOfDay.fromDateTime(value ?? now),
);
if (time == null) return;
field.didChange(DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
));
},
onLongPress: value == null ? null : () => field.didChange(null),
child: Padding(
padding: EdgeInsets.symmetric(vertical: 1.8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
SizedBox(height: 0.6.h),
Text(value?.toLocal().toString() ?? 'Not set'),
],
),
),
);
},
);
}

View File

@@ -0,0 +1,39 @@
// lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:sizer/sizer.dart';
class GasFeeOptionsField extends StatelessWidget {
const GasFeeOptionsField({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: FormBuilderTextField(
name: 'maxGasFeePerGas',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Max gas fee / gas',
hintText: '1000000000',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 1.w),
Expanded(
child: FormBuilderTextField(
name: 'maxPriorityFeePerGas',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Max priority fee / gas',
hintText: '100000000',
border: OutlineInputBorder(),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,39 @@
// lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:sizer/sizer.dart';
class TransactionRateLimitField extends StatelessWidget {
const TransactionRateLimitField({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: FormBuilderTextField(
name: 'txCount',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Tx count limit',
hintText: '10',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 1.w),
Expanded(
child: FormBuilderTextField(
name: 'txWindow',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Window (seconds)',
hintText: '3600',
border: OutlineInputBorder(),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,29 @@
// lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/date_time_field.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
class ValidityWindowField extends StatelessWidget {
const ValidityWindowField({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: FormBuilderDateTimeField(
name: 'validFrom',
label: 'Valid from',
),
),
SizedBox(width: 1.w),
Expanded(
child: FormBuilderDateTimeField(
name: 'validUntil',
label: 'Valid until',
),
),
],
);
}
}

View File

@@ -0,0 +1,57 @@
// lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class WalletAccessPickerField extends ConsumerWidget {
const WalletAccessPickerField({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(grantCreationProvider);
final allAccesses =
ref.watch(walletAccessListProvider).asData?.value ??
const <SdkClientWalletAccess>[];
final wallets =
ref.watch(evmProvider).asData?.value ?? const <WalletEntry>[];
final walletById = <int, WalletEntry>{for (final w in wallets) w.id: w};
final accesses = state.selectedClientId == null
? const <SdkClientWalletAccess>[]
: allAccesses
.where((a) => a.access.sdkClientId == state.selectedClientId)
.toList();
return FormBuilderDropdown<int>(
name: 'walletAccessId',
enabled: accesses.isNotEmpty,
decoration: InputDecoration(
labelText: 'Wallet access',
helperText: state.selectedClientId == null
? 'Select a client first'
: accesses.isEmpty
? 'No wallet accesses for this client'
: null,
border: const OutlineInputBorder(),
),
items: [
for (final a in accesses)
DropdownMenuItem(
value: a.id,
child: Text(() {
final wallet = walletById[a.access.walletId];
return wallet != null
? shortAddress(wallet.address)
: 'Wallet #${a.access.walletId}';
}()),
),
],
);
}
}

View File

@@ -0,0 +1,225 @@
// lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sizer/sizer.dart';
part 'ether_transfer_grant.g.dart';
class EtherTargetEntry {
EtherTargetEntry({required this.id, this.address = ''});
final int id;
final String address;
EtherTargetEntry copyWith({String? address}) =>
EtherTargetEntry(id: id, address: address ?? this.address);
}
@riverpod
class EtherGrantTargets extends _$EtherGrantTargets {
int _nextId = 0;
int _newId() => _nextId++;
@override
List<EtherTargetEntry> build() => [EtherTargetEntry(id: _newId())];
void add() => state = [...state, EtherTargetEntry(id: _newId())];
void update(int index, EtherTargetEntry entry) {
final updated = [...state];
updated[index] = entry;
state = updated;
}
void remove(int index) => state = [...state]..removeAt(index);
}
class EtherTransferGrantHandler implements GrantFormHandler {
const EtherTransferGrantHandler();
@override
Widget buildForm(BuildContext context, WidgetRef ref) =>
const _EtherTransferForm();
@override
SpecificGrant buildSpecificGrant(
Map<String, dynamic> formValues,
WidgetRef ref,
) {
final targets = ref.read(etherGrantTargetsProvider);
return SpecificGrant(
etherTransfer: EtherTransferSettings(
targets: targets
.where((e) => e.address.trim().isNotEmpty)
.map((e) => parseHexAddress(e.address))
.toList(),
limit: buildVolumeLimit(
formValues['etherVolume'] as String? ?? '',
formValues['etherVolumeWindow'] as String? ?? '',
),
),
);
}
}
// ---------------------------------------------------------------------------
// Form widget
// ---------------------------------------------------------------------------
class _EtherTransferForm extends ConsumerWidget {
const _EtherTransferForm();
@override
Widget build(BuildContext context, WidgetRef ref) {
final targets = ref.watch(etherGrantTargetsProvider);
final notifier = ref.read(etherGrantTargetsProvider.notifier);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_EtherTargetsField(
values: targets,
onAdd: notifier.add,
onUpdate: notifier.update,
onRemove: notifier.remove,
),
SizedBox(height: 1.6.h),
Text(
'Ether volume limit',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 0.8.h),
Row(
children: [
Expanded(
child: FormBuilderTextField(
name: 'etherVolume',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Max volume',
hintText: '1000000000000000000',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 1.w),
Expanded(
child: FormBuilderTextField(
name: 'etherVolumeWindow',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Window (seconds)',
hintText: '86400',
border: OutlineInputBorder(),
),
),
),
],
),
],
);
}
}
// ---------------------------------------------------------------------------
// Targets list widget
// ---------------------------------------------------------------------------
class _EtherTargetsField extends StatelessWidget {
const _EtherTargetsField({
required this.values,
required this.onAdd,
required this.onUpdate,
required this.onRemove,
});
final List<EtherTargetEntry> values;
final VoidCallback onAdd;
final void Function(int index, EtherTargetEntry entry) onUpdate;
final void Function(int index) onRemove;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Ether targets',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
TextButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add_rounded),
label: const Text('Add'),
),
],
),
SizedBox(height: 0.8.h),
for (var i = 0; i < values.length; i++)
Padding(
padding: EdgeInsets.only(bottom: 1.h),
child: _EtherTargetRow(
key: ValueKey(values[i].id),
value: values[i],
onChanged: (entry) => onUpdate(i, entry),
onRemove: values.length == 1 ? null : () => onRemove(i),
),
),
],
);
}
}
class _EtherTargetRow extends HookWidget {
const _EtherTargetRow({
super.key,
required this.value,
required this.onChanged,
required this.onRemove,
});
final EtherTargetEntry value;
final ValueChanged<EtherTargetEntry> onChanged;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
final addressController = useTextEditingController(text: value.address);
return Row(
children: [
Expanded(
child: TextField(
controller: addressController,
onChanged: (next) => onChanged(value.copyWith(address: next)),
decoration: const InputDecoration(
labelText: 'Address',
hintText: '0x...',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 0.4.w),
IconButton(
onPressed: onRemove,
icon: const Icon(Icons.remove_circle_outline_rounded),
),
],
);
}
}

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ether_transfer_grant.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(EtherGrantTargets)
final etherGrantTargetsProvider = EtherGrantTargetsProvider._();
final class EtherGrantTargetsProvider
extends $NotifierProvider<EtherGrantTargets, List<EtherTargetEntry>> {
EtherGrantTargetsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'etherGrantTargetsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$etherGrantTargetsHash();
@$internal
@override
EtherGrantTargets create() => EtherGrantTargets();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<EtherTargetEntry> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<EtherTargetEntry>>(value),
);
}
}
String _$etherGrantTargetsHash() => r'063aa3180d5e5bbc1525702272686f1fd8ca751d';
abstract class _$EtherGrantTargets extends $Notifier<List<EtherTargetEntry>> {
List<EtherTargetEntry> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<List<EtherTargetEntry>, List<EtherTargetEntry>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<EtherTargetEntry>, List<EtherTargetEntry>>,
List<EtherTargetEntry>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,26 @@
// lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
abstract class GrantFormHandler {
/// Renders the grant-specific form section.
///
/// The returned widget must be a descendant of the [FormBuilder] in the
/// screen so its [FormBuilderField] children register automatically.
///
/// **Field name contract:** All `name:` values used by this handler must be
/// unique across ALL [GrantFormHandler] implementations. [FormBuilder]
/// retains field state across handler switches, so name collisions cause
/// silent data corruption.
Widget buildForm(BuildContext context, WidgetRef ref);
/// Assembles a [SpecificGrant] proto.
///
/// [formValues] — `formKey.currentState!.value` after `saveAndValidate()`.
/// [ref] — read any provider the handler owns (e.g. token volume limits).
SpecificGrant buildSpecificGrant(
Map<String, dynamic> formValues,
WidgetRef ref,
);
}

View File

@@ -0,0 +1,233 @@
// lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sizer/sizer.dart';
part 'token_transfer_grant.g.dart';
class VolumeLimitEntry {
VolumeLimitEntry({required this.id, this.amount = '', this.windowSeconds = ''});
final int id;
final String amount;
final String windowSeconds;
VolumeLimitEntry copyWith({String? amount, String? windowSeconds}) =>
VolumeLimitEntry(
id: id,
amount: amount ?? this.amount,
windowSeconds: windowSeconds ?? this.windowSeconds,
);
}
@riverpod
class TokenGrantLimits extends _$TokenGrantLimits {
int _nextId = 0;
int _newId() => _nextId++;
@override
List<VolumeLimitEntry> build() => [VolumeLimitEntry(id: _newId())];
void add() => state = [...state, VolumeLimitEntry(id: _newId())];
void update(int index, VolumeLimitEntry entry) {
final updated = [...state];
updated[index] = entry;
state = updated;
}
void remove(int index) => state = [...state]..removeAt(index);
}
class TokenTransferGrantHandler implements GrantFormHandler {
const TokenTransferGrantHandler();
@override
Widget buildForm(BuildContext context, WidgetRef ref) =>
const _TokenTransferForm();
@override
SpecificGrant buildSpecificGrant(
Map<String, dynamic> formValues,
WidgetRef ref,
) {
final limits = ref.read(tokenGrantLimitsProvider);
final targetText = formValues['tokenTarget'] as String? ?? '';
return SpecificGrant(
tokenTransfer: TokenTransferSettings(
tokenContract:
parseHexAddress(formValues['tokenContract'] as String? ?? ''),
target: targetText.trim().isEmpty ? null : parseHexAddress(targetText),
volumeLimits: limits
.where((e) => e.amount.trim().isNotEmpty && e.windowSeconds.trim().isNotEmpty)
.map(
(e) => VolumeRateLimit(
maxVolume: parseBigIntBytes(e.amount),
windowSecs: Int64.parseInt(e.windowSeconds),
),
)
.toList(),
),
);
}
}
// ---------------------------------------------------------------------------
// Form widget
// ---------------------------------------------------------------------------
class _TokenTransferForm extends ConsumerWidget {
const _TokenTransferForm();
@override
Widget build(BuildContext context, WidgetRef ref) {
final limits = ref.watch(tokenGrantLimitsProvider);
final notifier = ref.read(tokenGrantLimitsProvider.notifier);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FormBuilderTextField(
name: 'tokenContract',
decoration: const InputDecoration(
labelText: 'Token contract',
hintText: '0x...',
border: OutlineInputBorder(),
),
),
SizedBox(height: 1.6.h),
FormBuilderTextField(
name: 'tokenTarget',
decoration: const InputDecoration(
labelText: 'Token recipient',
hintText: '0x... or leave empty for any recipient',
border: OutlineInputBorder(),
),
),
SizedBox(height: 1.6.h),
_TokenVolumeLimitsField(
values: limits,
onAdd: notifier.add,
onUpdate: notifier.update,
onRemove: notifier.remove,
),
],
);
}
}
// ---------------------------------------------------------------------------
// Volume limits list widget
// ---------------------------------------------------------------------------
class _TokenVolumeLimitsField extends StatelessWidget {
const _TokenVolumeLimitsField({
required this.values,
required this.onAdd,
required this.onUpdate,
required this.onRemove,
});
final List<VolumeLimitEntry> values;
final VoidCallback onAdd;
final void Function(int index, VolumeLimitEntry entry) onUpdate;
final void Function(int index) onRemove;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Token volume limits',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
TextButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add_rounded),
label: const Text('Add'),
),
],
),
SizedBox(height: 0.8.h),
for (var i = 0; i < values.length; i++)
Padding(
padding: EdgeInsets.only(bottom: 1.h),
child: _TokenVolumeLimitRow(
key: ValueKey(values[i].id),
value: values[i],
onChanged: (entry) => onUpdate(i, entry),
onRemove: values.length == 1 ? null : () => onRemove(i),
),
),
],
);
}
}
class _TokenVolumeLimitRow extends HookWidget {
const _TokenVolumeLimitRow({
super.key,
required this.value,
required this.onChanged,
required this.onRemove,
});
final VolumeLimitEntry value;
final ValueChanged<VolumeLimitEntry> onChanged;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
final amountController = useTextEditingController(text: value.amount);
final windowController = useTextEditingController(text: value.windowSeconds);
return Row(
children: [
Expanded(
child: TextField(
controller: amountController,
onChanged: (next) => onChanged(value.copyWith(amount: next)),
decoration: const InputDecoration(
labelText: 'Max volume',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 1.w),
Expanded(
child: TextField(
controller: windowController,
onChanged: (next) =>
onChanged(value.copyWith(windowSeconds: next)),
decoration: const InputDecoration(
labelText: 'Window (seconds)',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 0.4.w),
IconButton(
onPressed: onRemove,
icon: const Icon(Icons.remove_circle_outline_rounded),
),
],
);
}
}

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'token_transfer_grant.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(TokenGrantLimits)
final tokenGrantLimitsProvider = TokenGrantLimitsProvider._();
final class TokenGrantLimitsProvider
extends $NotifierProvider<TokenGrantLimits, List<VolumeLimitEntry>> {
TokenGrantLimitsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tokenGrantLimitsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tokenGrantLimitsHash();
@$internal
@override
TokenGrantLimits create() => TokenGrantLimits();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<VolumeLimitEntry> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<VolumeLimitEntry>>(value),
);
}
}
String _$tokenGrantLimitsHash() => r'84db377f24940d215af82052e27863ab40c02b24';
abstract class _$TokenGrantLimits extends $Notifier<List<VolumeLimitEntry>> {
List<VolumeLimitEntry> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<List<VolumeLimitEntry>, List<VolumeLimitEntry>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<VolumeLimitEntry>, List<VolumeLimitEntry>>,
List<VolumeLimitEntry>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:arbiter/proto/evm.pb.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'provider.freezed.dart';
part 'provider.g.dart';
@freezed
abstract class GrantCreationState with _$GrantCreationState {
const factory GrantCreationState({
int? selectedClientId,
@Default(SpecificGrant_Grant.etherTransfer) SpecificGrant_Grant grantType,
}) = _GrantCreationState;
}
@riverpod
class GrantCreation extends _$GrantCreation {
@override
GrantCreationState build() => const GrantCreationState();
void setClientId(int? id) => state = state.copyWith(selectedClientId: id);
void setGrantType(SpecificGrant_Grant type) =>
state = state.copyWith(grantType: type);
}

View File

@@ -0,0 +1,274 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'provider.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$GrantCreationState {
int? get selectedClientId; SpecificGrant_Grant get grantType;
/// Create a copy of GrantCreationState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$GrantCreationStateCopyWith<GrantCreationState> get copyWith => _$GrantCreationStateCopyWithImpl<GrantCreationState>(this as GrantCreationState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is GrantCreationState&&(identical(other.selectedClientId, selectedClientId) || other.selectedClientId == selectedClientId)&&(identical(other.grantType, grantType) || other.grantType == grantType));
}
@override
int get hashCode => Object.hash(runtimeType,selectedClientId,grantType);
@override
String toString() {
return 'GrantCreationState(selectedClientId: $selectedClientId, grantType: $grantType)';
}
}
/// @nodoc
abstract mixin class $GrantCreationStateCopyWith<$Res> {
factory $GrantCreationStateCopyWith(GrantCreationState value, $Res Function(GrantCreationState) _then) = _$GrantCreationStateCopyWithImpl;
@useResult
$Res call({
int? selectedClientId, SpecificGrant_Grant grantType
});
}
/// @nodoc
class _$GrantCreationStateCopyWithImpl<$Res>
implements $GrantCreationStateCopyWith<$Res> {
_$GrantCreationStateCopyWithImpl(this._self, this._then);
final GrantCreationState _self;
final $Res Function(GrantCreationState) _then;
/// Create a copy of GrantCreationState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? selectedClientId = freezed,Object? grantType = null,}) {
return _then(_self.copyWith(
selectedClientId: freezed == selectedClientId ? _self.selectedClientId : selectedClientId // ignore: cast_nullable_to_non_nullable
as int?,grantType: null == grantType ? _self.grantType : grantType // ignore: cast_nullable_to_non_nullable
as SpecificGrant_Grant,
));
}
}
/// Adds pattern-matching-related methods to [GrantCreationState].
extension GrantCreationStatePatterns on GrantCreationState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GrantCreationState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _GrantCreationState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GrantCreationState value) $default,){
final _that = this;
switch (_that) {
case _GrantCreationState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GrantCreationState value)? $default,){
final _that = this;
switch (_that) {
case _GrantCreationState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? selectedClientId, SpecificGrant_Grant grantType)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _GrantCreationState() when $default != null:
return $default(_that.selectedClientId,_that.grantType);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? selectedClientId, SpecificGrant_Grant grantType) $default,) {final _that = this;
switch (_that) {
case _GrantCreationState():
return $default(_that.selectedClientId,_that.grantType);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? selectedClientId, SpecificGrant_Grant grantType)? $default,) {final _that = this;
switch (_that) {
case _GrantCreationState() when $default != null:
return $default(_that.selectedClientId,_that.grantType);case _:
return null;
}
}
}
/// @nodoc
class _GrantCreationState implements GrantCreationState {
const _GrantCreationState({this.selectedClientId, this.grantType = SpecificGrant_Grant.etherTransfer});
@override final int? selectedClientId;
@override@JsonKey() final SpecificGrant_Grant grantType;
/// Create a copy of GrantCreationState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$GrantCreationStateCopyWith<_GrantCreationState> get copyWith => __$GrantCreationStateCopyWithImpl<_GrantCreationState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GrantCreationState&&(identical(other.selectedClientId, selectedClientId) || other.selectedClientId == selectedClientId)&&(identical(other.grantType, grantType) || other.grantType == grantType));
}
@override
int get hashCode => Object.hash(runtimeType,selectedClientId,grantType);
@override
String toString() {
return 'GrantCreationState(selectedClientId: $selectedClientId, grantType: $grantType)';
}
}
/// @nodoc
abstract mixin class _$GrantCreationStateCopyWith<$Res> implements $GrantCreationStateCopyWith<$Res> {
factory _$GrantCreationStateCopyWith(_GrantCreationState value, $Res Function(_GrantCreationState) _then) = __$GrantCreationStateCopyWithImpl;
@override @useResult
$Res call({
int? selectedClientId, SpecificGrant_Grant grantType
});
}
/// @nodoc
class __$GrantCreationStateCopyWithImpl<$Res>
implements _$GrantCreationStateCopyWith<$Res> {
__$GrantCreationStateCopyWithImpl(this._self, this._then);
final _GrantCreationState _self;
final $Res Function(_GrantCreationState) _then;
/// Create a copy of GrantCreationState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? selectedClientId = freezed,Object? grantType = null,}) {
return _then(_GrantCreationState(
selectedClientId: freezed == selectedClientId ? _self.selectedClientId : selectedClientId // ignore: cast_nullable_to_non_nullable
as int?,grantType: null == grantType ? _self.grantType : grantType // ignore: cast_nullable_to_non_nullable
as SpecificGrant_Grant,
));
}
}
// dart format on

View File

@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(GrantCreation)
final grantCreationProvider = GrantCreationProvider._();
final class GrantCreationProvider
extends $NotifierProvider<GrantCreation, GrantCreationState> {
GrantCreationProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'grantCreationProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$grantCreationHash();
@$internal
@override
GrantCreation create() => GrantCreation();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GrantCreationState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GrantCreationState>(value),
);
}
}
String _$grantCreationHash() => r'3733d45da30990ef8ecbee946d2eae81bc7f5fc9';
abstract class _$GrantCreation extends $Notifier<GrantCreationState> {
GrantCreationState build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<GrantCreationState, GrantCreationState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<GrantCreationState, GrantCreationState>,
GrantCreationState,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,252 @@
// lib/screens/dashboard/evm/grants/create/screen.dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/shared_grant_fields.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:auto_route/auto_route.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:sizer/sizer.dart';
const _etherHandler = EtherTransferGrantHandler();
const _tokenHandler = TokenTransferGrantHandler();
GrantFormHandler _handlerFor(SpecificGrant_Grant type) => switch (type) {
SpecificGrant_Grant.etherTransfer => _etherHandler,
SpecificGrant_Grant.tokenTransfer => _tokenHandler,
_ => throw ArgumentError('Unsupported grant type: $type'),
};
@RoutePage()
class CreateEvmGrantScreen extends HookConsumerWidget {
const CreateEvmGrantScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormBuilderState>());
final createMutation = ref.watch(createEvmGrantMutation);
final state = ref.watch(grantCreationProvider);
final notifier = ref.read(grantCreationProvider.notifier);
final handler = _handlerFor(state.grantType);
Future<void> submit() async {
if (!(formKey.currentState?.saveAndValidate() ?? false)) return;
final formValues = formKey.currentState!.value;
final accessId = formValues['walletAccessId'] as int?;
if (accessId == null) {
_showSnackBar(context, 'Select a client and wallet access.');
return;
}
try {
final specific = handler.buildSpecificGrant(formValues, ref);
final sharedSettings = SharedSettings(
walletAccessId: accessId,
chainId: Int64.parseInt(
(formValues['chainId'] as String? ?? '').trim(),
),
);
final validFrom = formValues['validFrom'] as DateTime?;
final validUntil = formValues['validUntil'] as DateTime?;
if (validFrom != null) sharedSettings.validFrom = toTimestamp(validFrom);
if (validUntil != null) {
sharedSettings.validUntil = toTimestamp(validUntil);
}
final gasBytes =
optionalBigIntBytes(formValues['maxGasFeePerGas'] as String? ?? '');
if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes;
final priorityBytes = optionalBigIntBytes(
formValues['maxPriorityFeePerGas'] as String? ?? '',
);
if (priorityBytes != null) {
sharedSettings.maxPriorityFeePerGas = priorityBytes;
}
final rateLimit = buildRateLimit(
formValues['txCount'] as String? ?? '',
formValues['txWindow'] as String? ?? '',
);
if (rateLimit != null) sharedSettings.rateLimit = rateLimit;
await executeCreateEvmGrant(
ref,
sharedSettings: sharedSettings,
specific: specific,
);
if (!context.mounted) return;
context.router.pop();
} catch (error) {
if (!context.mounted) return;
_showSnackBar(context, _formatError(error));
}
}
return Scaffold(
appBar: AppBar(title: const Text('Create EVM Grant')),
body: SafeArea(
child: FormBuilder(
key: formKey,
child: ListView(
padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h),
children: [
const _IntroCard(),
SizedBox(height: 1.8.h),
const _Section(
title: 'Shared grant options',
child: SharedGrantFields(),
),
SizedBox(height: 1.8.h),
_GrantTypeSelector(
value: state.grantType,
onChanged: notifier.setGrantType,
),
SizedBox(height: 1.8.h),
_Section(
title: 'Grant-specific options',
child: handler.buildForm(context, ref),
),
SizedBox(height: 2.2.h),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed:
createMutation is MutationPending ? null : submit,
icon: createMutation is MutationPending
? SizedBox(
width: 1.8.h,
height: 1.8.h,
child: const CircularProgressIndicator(
strokeWidth: 2.2,
),
)
: const Icon(Icons.check_rounded),
label: Text(
createMutation is MutationPending
? 'Creating...'
: 'Create grant',
),
),
),
],
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Layout helpers
// ---------------------------------------------------------------------------
class _IntroCard extends StatelessWidget {
const _IntroCard();
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: const LinearGradient(
colors: [Palette.introGradientStart, Palette.introGradientEnd],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
border: Border.all(color: Palette.cardBorder),
),
child: Text(
'Pick a client, then select one of the wallet accesses already granted '
'to it. Compose shared constraints once, then switch between Ether and '
'token transfer rules.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5),
),
);
}
}
class _Section extends StatelessWidget {
const _Section({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Colors.white,
border: Border.all(color: Palette.cardBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 1.4.h),
child,
],
),
);
}
}
class _GrantTypeSelector extends StatelessWidget {
const _GrantTypeSelector({required this.value, required this.onChanged});
final SpecificGrant_Grant value;
final ValueChanged<SpecificGrant_Grant> onChanged;
@override
Widget build(BuildContext context) {
return SegmentedButton<SpecificGrant_Grant>(
segments: const [
ButtonSegment(
value: SpecificGrant_Grant.etherTransfer,
label: Text('Ether'),
icon: Icon(Icons.bolt_rounded),
),
ButtonSegment(
value: SpecificGrant_Grant.tokenTransfer,
label: Text('Token'),
icon: Icon(Icons.token_rounded),
),
],
selected: {value},
onSelectionChanged: (selection) => onChanged(selection.first),
);
}
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
void _showSnackBar(BuildContext context, String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
String _formatError(Object error) {
final text = error.toString();
return text.startsWith('Exception: ')
? text.substring('Exception: '.length)
: text;
}

View File

@@ -0,0 +1,37 @@
// lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/chain_id_field.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/client_picker_field.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/validity_window_field.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
/// All shared grant fields in a single vertical layout.
///
/// Every [FormBuilderField] descendant auto-registers with the nearest
/// [FormBuilder] ancestor via [BuildContext] — no controllers passed.
class SharedGrantFields extends StatelessWidget {
const SharedGrantFields({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const ClientPickerField(),
SizedBox(height: 1.6.h),
const WalletAccessPickerField(),
SizedBox(height: 1.6.h),
const ChainIdField(),
SizedBox(height: 1.6.h),
const ValidityWindowField(),
SizedBox(height: 1.6.h),
const GasFeeOptionsField(),
SizedBox(height: 1.6.h),
const TransactionRateLimitField(),
],
);
}
}

View File

@@ -0,0 +1,73 @@
import 'package:arbiter/proto/evm.pb.dart';
import 'package:fixnum/fixnum.dart';
import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart';
Timestamp toTimestamp(DateTime value) {
final utc = value.toUtc();
return Timestamp()
..seconds = Int64(utc.millisecondsSinceEpoch ~/ 1000)
..nanos = (utc.microsecondsSinceEpoch % 1000000) * 1000;
}
TransactionRateLimit? buildRateLimit(String countText, String windowText) {
if (countText.trim().isEmpty || windowText.trim().isEmpty) {
return null;
}
return TransactionRateLimit(
count: int.parse(countText.trim()),
windowSecs: Int64.parseInt(windowText.trim()),
);
}
VolumeRateLimit? buildVolumeLimit(String amountText, String windowText) {
if (amountText.trim().isEmpty || windowText.trim().isEmpty) {
return null;
}
return VolumeRateLimit(
maxVolume: parseBigIntBytes(amountText),
windowSecs: Int64.parseInt(windowText.trim()),
);
}
List<int>? optionalBigIntBytes(String value) {
if (value.trim().isEmpty) {
return null;
}
return parseBigIntBytes(value);
}
List<int> parseBigIntBytes(String value) {
final number = BigInt.parse(value.trim());
if (number < BigInt.zero) {
throw Exception('Numeric values must be positive.');
}
if (number == BigInt.zero) {
return [0];
}
var remaining = number;
final bytes = <int>[];
while (remaining > BigInt.zero) {
bytes.insert(0, (remaining & BigInt.from(0xff)).toInt());
remaining >>= 8;
}
return bytes;
}
List<int> parseHexAddress(String value) {
final normalized = value.trim().replaceFirst(RegExp(r'^0x'), '');
if (normalized.length != 40) {
throw Exception('Expected a 20-byte hex address.');
}
return [
for (var i = 0; i < normalized.length; i += 2)
int.parse(normalized.substring(i, i + 2), radix: 16),
];
}
String shortAddress(List<int> bytes) {
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
}