diff --git a/useragent/lib/router.gr.dart b/useragent/lib/router.gr.dart index e4d05bb..f20b537 100644 --- a/useragent/lib/router.gr.dart +++ b/useragent/lib/router.gr.dart @@ -18,7 +18,7 @@ import 'package:arbiter/screens/dashboard/clients/details/client_details.dart' as _i4; import 'package:arbiter/screens/dashboard/clients/table.dart' as _i5; import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i9; -import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i6; +import 'package:arbiter/screens/dashboard/evm/grants/create/screen.dart' as _i6; import 'package:arbiter/screens/dashboard/evm/grants/grants.dart' as _i8; import 'package:arbiter/screens/server_connection.dart' as _i10; import 'package:arbiter/screens/server_info_setup.dart' as _i11; diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart new file mode 100644 index 0000000..8d2d318 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart @@ -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(), + ), + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart new file mode 100644 index 0000000..8369083 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart @@ -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 []; + + return FormBuilderDropdown( + name: 'clientId', + decoration: const InputDecoration( + labelText: 'Client', + border: OutlineInputBorder(), + ), + items: [ + for (final c in clients) + DropdownMenuItem( + value: c.id, + child: Text(c.info.name.isEmpty ? 'Client #${c.id}' : c.info.name), + ), + ], + onChanged: clients.isEmpty + ? null + : (value) { + ref.read(grantCreationProvider.notifier).setClientId(value); + FormBuilder.of(context)?.fields['walletAccessId']?.didChange(null); + }, + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart new file mode 100644 index 0000000..166359c --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart @@ -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 { + final String label; + + FormBuilderDateTimeField({ + super.key, + required super.name, + required this.label, + super.initialValue, + super.onChanged, + super.validator, + }) : super( + builder: (FormFieldState field) { + final value = field.value; + return OutlinedButton( + onPressed: () async { + final 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'), + ], + ), + ), + ); + }, + ); +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart new file mode 100644 index 0000000..86e3dd0 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart @@ -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(), + ), + ), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart new file mode 100644 index 0000000..f63a1d8 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart @@ -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(), + ), + ), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart new file mode 100644 index 0000000..e86afda --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart @@ -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', + ), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart new file mode 100644 index 0000000..b220e6f --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart @@ -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 []; + final wallets = + ref.watch(evmProvider).asData?.value ?? const []; + + final walletById = {for (final w in wallets) w.id: w}; + final accesses = state.selectedClientId == null + ? const [] + : allAccesses + .where((a) => a.access.sdkClientId == state.selectedClientId) + .toList(); + + return FormBuilderDropdown( + name: 'walletAccessId', + 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}'; + }()), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart b/useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart new file mode 100644 index 0000000..547e247 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart @@ -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 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 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 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 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), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.g.dart b/useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.g.dart new file mode 100644 index 0000000..420340a --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.g.dart @@ -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> { + 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 value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$etherGrantTargetsHash() => r'063aa3180d5e5bbc1525702272686f1fd8ca751d'; + +abstract class _$EtherGrantTargets extends $Notifier> { + List build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, List>, + List, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart b/useragent/lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart new file mode 100644 index 0000000..542f2b3 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart @@ -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 formValues, + WidgetRef ref, + ); +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart b/useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart new file mode 100644 index 0000000..9352b94 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart @@ -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 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 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 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 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), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.g.dart b/useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.g.dart new file mode 100644 index 0000000..e3e7bd9 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.g.dart @@ -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> { + 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 value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$tokenGrantLimitsHash() => r'84db377f24940d215af82052e27863ab40c02b24'; + +abstract class _$TokenGrantLimits extends $Notifier> { + List build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, List>, + List, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/provider.dart b/useragent/lib/screens/dashboard/evm/grants/create/provider.dart new file mode 100644 index 0000000..89aefeb --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/provider.dart @@ -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); +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/provider.freezed.dart b/useragent/lib/screens/dashboard/evm/grants/create/provider.freezed.dart new file mode 100644 index 0000000..e16d346 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/provider.freezed.dart @@ -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 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 get copyWith => _$GrantCreationStateCopyWithImpl(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 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 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? 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 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 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? 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 diff --git a/useragent/lib/screens/dashboard/evm/grants/create/provider.g.dart b/useragent/lib/screens/dashboard/evm/grants/create/provider.g.dart new file mode 100644 index 0000000..9ec5c71 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/provider.g.dart @@ -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 { + 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(value), + ); + } +} + +String _$grantCreationHash() => r'3733d45da30990ef8ecbee946d2eae81bc7f5fc9'; + +abstract class _$GrantCreation extends $Notifier { + GrantCreationState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + GrantCreationState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/screen.dart b/useragent/lib/screens/dashboard/evm/grants/create/screen.dart new file mode 100644 index 0000000..351a2f0 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/screen.dart @@ -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()); + final createMutation = ref.watch(createEvmGrantMutation); + final state = ref.watch(grantCreationProvider); + final notifier = ref.read(grantCreationProvider.notifier); + final handler = _handlerFor(state.grantType); + + Future submit() async { + if (!(formKey.currentState?.saveAndValidate() ?? false)) return; + final formValues = formKey.currentState!.value; + + final accessId = formValues['walletAccessId'] as int?; + if (accessId == null) { + _showSnackBar(context, 'Select a client and wallet access.'); + return; + } + + try { + final specific = handler.buildSpecificGrant(formValues, ref); + final sharedSettings = SharedSettings( + walletAccessId: accessId, + chainId: Int64.parseInt( + (formValues['chainId'] as String? ?? '').trim(), + ), + ); + final validFrom = formValues['validFrom'] as DateTime?; + final validUntil = formValues['validUntil'] as DateTime?; + if (validFrom != null) sharedSettings.validFrom = toTimestamp(validFrom); + if (validUntil != null) { + sharedSettings.validUntil = toTimestamp(validUntil); + } + final gasBytes = + optionalBigIntBytes(formValues['maxGasFeePerGas'] as String? ?? ''); + if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes; + final priorityBytes = optionalBigIntBytes( + formValues['maxPriorityFeePerGas'] as String? ?? '', + ); + if (priorityBytes != null) { + sharedSettings.maxPriorityFeePerGas = priorityBytes; + } + final rateLimit = buildRateLimit( + formValues['txCount'] as String? ?? '', + formValues['txWindow'] as String? ?? '', + ); + if (rateLimit != null) sharedSettings.rateLimit = rateLimit; + + await executeCreateEvmGrant( + ref, + sharedSettings: sharedSettings, + specific: specific, + ); + if (!context.mounted) return; + context.router.pop(); + } catch (error) { + if (!context.mounted) return; + _showSnackBar(context, _formatError(error)); + } + } + + return Scaffold( + appBar: AppBar(title: const Text('Create EVM Grant')), + body: SafeArea( + child: FormBuilder( + key: formKey, + child: ListView( + padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h), + children: [ + const _IntroCard(), + SizedBox(height: 1.8.h), + const _Section( + title: 'Shared grant options', + child: SharedGrantFields(), + ), + SizedBox(height: 1.8.h), + _GrantTypeSelector( + value: state.grantType, + onChanged: notifier.setGrantType, + ), + SizedBox(height: 1.8.h), + _Section( + title: 'Grant-specific options', + child: handler.buildForm(context, ref), + ), + SizedBox(height: 2.2.h), + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: + createMutation is MutationPending ? null : submit, + icon: createMutation is MutationPending + ? SizedBox( + width: 1.8.h, + height: 1.8.h, + child: const CircularProgressIndicator( + strokeWidth: 2.2, + ), + ) + : const Icon(Icons.check_rounded), + label: Text( + createMutation is MutationPending + ? 'Creating...' + : 'Create grant', + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Layout helpers +// --------------------------------------------------------------------------- + +class _IntroCard extends StatelessWidget { + const _IntroCard(); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(2.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: const LinearGradient( + colors: [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 onChanged; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: const [ + ButtonSegment( + value: SpecificGrant_Grant.etherTransfer, + label: Text('Ether'), + icon: Icon(Icons.bolt_rounded), + ), + ButtonSegment( + value: SpecificGrant_Grant.tokenTransfer, + label: Text('Token'), + icon: Icon(Icons.token_rounded), + ), + ], + selected: {value}, + onSelectionChanged: (selection) => onChanged(selection.first), + ); + } +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +void _showSnackBar(BuildContext context, String message) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); +} + +String _formatError(Object error) { + final text = error.toString(); + return text.startsWith('Exception: ') + ? text.substring('Exception: '.length) + : text; +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart b/useragent/lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart new file mode 100644 index 0000000..e2b88a5 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart @@ -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(), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/utils.dart b/useragent/lib/screens/dashboard/evm/grants/create/utils.dart new file mode 100644 index 0000000..08dad7c --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/utils.dart @@ -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? optionalBigIntBytes(String value) { + if (value.trim().isEmpty) { + return null; + } + return parseBigIntBytes(value); +} + +List parseBigIntBytes(String value) { + final number = BigInt.parse(value.trim()); + if (number < BigInt.zero) { + throw Exception('Numeric values must be positive.'); + } + if (number == BigInt.zero) { + return [0]; + } + + var remaining = number; + final bytes = []; + while (remaining > BigInt.zero) { + bytes.insert(0, (remaining & BigInt.from(0xff)).toInt()); + remaining >>= 8; + } + return bytes; +} + +List parseHexAddress(String value) { + final normalized = value.trim().replaceFirst(RegExp(r'^0x'), ''); + if (normalized.length != 40) { + throw Exception('Expected a 20-byte hex address.'); + } + return [ + for (var i = 0; i < normalized.length; i += 2) + int.parse(normalized.substring(i, i + 2), radix: 16), + ]; +} + +String shortAddress(List bytes) { + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}'; +} diff --git a/useragent/lib/screens/dashboard/evm/grants/grant_create.dart b/useragent/lib/screens/dashboard/evm/grants/grant_create.dart deleted file mode 100644 index 7a11d5c..0000000 --- a/useragent/lib/screens/dashboard/evm/grants/grant_create.dart +++ /dev/null @@ -1,902 +0,0 @@ -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:auto_route/auto_route.dart'; -import 'package:fixnum/fixnum.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:hooks_riverpod/experimental/mutation.dart'; -import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; -import 'package:sizer/sizer.dart'; - -@RoutePage() -class CreateEvmGrantScreen extends HookConsumerWidget { - const CreateEvmGrantScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final createMutation = ref.watch(createEvmGrantMutation); - - final selectedClientId = useState(null); - final selectedWalletAccessId = useState(null); - final chainIdController = useTextEditingController(text: '1'); - final gasFeeController = useTextEditingController(); - final priorityFeeController = useTextEditingController(); - final txCountController = useTextEditingController(); - final txWindowController = useTextEditingController(); - final recipientsController = useTextEditingController(); - final etherVolumeController = useTextEditingController(); - final etherVolumeWindowController = useTextEditingController(); - final tokenContractController = useTextEditingController(); - final tokenTargetController = useTextEditingController(); - final validFrom = useState(null); - final validUntil = useState(null); - final grantType = useState( - SpecificGrant_Grant.etherTransfer, - ); - final tokenVolumeLimits = useState>([ - const _VolumeLimitValue(), - ]); - - Future submit() async { - final accessId = selectedWalletAccessId.value; - if (accessId == null) { - _showCreateMessage(context, 'Select a client and wallet access.'); - return; - } - - try { - final chainId = Int64.parseInt(chainIdController.text.trim()); - final rateLimit = _buildRateLimit( - txCountController.text, - txWindowController.text, - ); - final specific = switch (grantType.value) { - SpecificGrant_Grant.etherTransfer => SpecificGrant( - etherTransfer: EtherTransferSettings( - targets: _parseAddresses(recipientsController.text), - limit: _buildVolumeLimit( - etherVolumeController.text, - etherVolumeWindowController.text, - ), - ), - ), - SpecificGrant_Grant.tokenTransfer => SpecificGrant( - tokenTransfer: TokenTransferSettings( - tokenContract: _parseHexAddress(tokenContractController.text), - target: tokenTargetController.text.trim().isEmpty - ? null - : _parseHexAddress(tokenTargetController.text), - volumeLimits: tokenVolumeLimits.value - .where((item) => item.amount.trim().isNotEmpty) - .map( - (item) => VolumeRateLimit( - maxVolume: _parseBigIntBytes(item.amount), - windowSecs: Int64.parseInt(item.windowSeconds), - ), - ) - .toList(), - ), - ), - _ => throw Exception('Unsupported grant type.'), - }; - - final sharedSettings = SharedSettings( - walletAccessId: accessId, - chainId: chainId, - ); - if (validFrom.value != null) { - sharedSettings.validFrom = _toTimestamp(validFrom.value!); - } - if (validUntil.value != null) { - sharedSettings.validUntil = _toTimestamp(validUntil.value!); - } - final gasBytes = _optionalBigIntBytes(gasFeeController.text); - if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes; - final priorityBytes = _optionalBigIntBytes(priorityFeeController.text); - if (priorityBytes != null) sharedSettings.maxPriorityFeePerGas = priorityBytes; - 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; - } - _showCreateMessage(context, _formatCreateError(error)); - } - } - - return Scaffold( - appBar: AppBar(title: const Text('Create EVM Grant')), - body: SafeArea( - child: ListView( - padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h), - children: [ - const _CreateIntroCard(), - SizedBox(height: 1.8.h), - _CreateSection( - title: 'Shared grant options', - children: [ - _ClientPickerField( - selectedClientId: selectedClientId.value, - onChanged: (clientId) { - selectedClientId.value = clientId; - selectedWalletAccessId.value = null; - }, - ), - _WalletAccessPickerField( - selectedClientId: selectedClientId.value, - selectedAccessId: selectedWalletAccessId.value, - onChanged: (accessId) => - selectedWalletAccessId.value = accessId, - ), - _NumberInputField( - controller: chainIdController, - label: 'Chain ID', - hint: '1', - ), - _ValidityWindowField( - validFrom: validFrom.value, - validUntil: validUntil.value, - onValidFromChanged: (value) => validFrom.value = value, - onValidUntilChanged: (value) => validUntil.value = value, - ), - _GasFeeOptionsField( - gasFeeController: gasFeeController, - priorityFeeController: priorityFeeController, - ), - _TransactionRateLimitField( - txCountController: txCountController, - txWindowController: txWindowController, - ), - ], - ), - SizedBox(height: 1.8.h), - _GrantTypeSelector( - value: grantType.value, - onChanged: (value) => grantType.value = value, - ), - SizedBox(height: 1.8.h), - _CreateSection( - title: 'Grant-specific options', - children: [ - if (grantType.value == SpecificGrant_Grant.etherTransfer) ...[ - _EtherTargetsField(controller: recipientsController), - _VolumeLimitField( - amountController: etherVolumeController, - windowController: etherVolumeWindowController, - title: 'Ether volume limit', - ), - ] else ...[ - _TokenContractField(controller: tokenContractController), - _TokenRecipientField(controller: tokenTargetController), - _TokenVolumeLimitsField( - values: tokenVolumeLimits.value, - onChanged: (values) => tokenVolumeLimits.value = values, - ), - ], - ], - ), - 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', - ), - ), - ), - ], - ), - ), - ); - } -} - -class _CreateIntroCard extends StatelessWidget { - const _CreateIntroCard(); - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.all(2.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - gradient: const LinearGradient( - colors: [Color(0xFFF7F9FC), Color(0xFFFDF5EA)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - border: Border.all(color: const Color(0x1A17324A)), - ), - child: Text( - 'Pick a client, then select one of the wallet accesses already granted to it. Compose shared constraints once, then switch between Ether and token transfer rules.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5), - ), - ); - } -} - -class _CreateSection extends StatelessWidget { - const _CreateSection({required this.title, required this.children}); - - final String title; - final List children; - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.all(2.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - color: Colors.white, - border: Border.all(color: const Color(0x1A17324A)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 1.4.h), - ...children.map( - (child) => Padding( - padding: EdgeInsets.only(bottom: 1.6.h), - child: child, - ), - ), - ], - ), - ); - } -} - -class _ClientPickerField extends ConsumerWidget { - const _ClientPickerField({ - required this.selectedClientId, - required this.onChanged, - }); - - final int? selectedClientId; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final clients = - ref.watch(sdkClientsProvider).asData?.value ?? const []; - - return DropdownButtonFormField( - value: clients.any((c) => c.id == selectedClientId) - ? selectedClientId - : null, - 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 : onChanged, - ); - } -} - -class _WalletAccessPickerField extends ConsumerWidget { - const _WalletAccessPickerField({ - required this.selectedClientId, - required this.selectedAccessId, - required this.onChanged, - }); - - final int? selectedClientId; - final int? selectedAccessId; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final allAccesses = - ref.watch(walletAccessListProvider).asData?.value ?? - const []; - final wallets = - ref.watch(evmProvider).asData?.value ?? const []; - - final walletById = { - for (final w in wallets) w.id: w, - }; - - final accesses = selectedClientId == null - ? const [] - : allAccesses - .where((a) => a.access.sdkClientId == selectedClientId) - .toList(); - - final effectiveValue = - accesses.any((a) => a.id == selectedAccessId) ? selectedAccessId : null; - - return DropdownButtonFormField( - value: effectiveValue, - decoration: InputDecoration( - labelText: 'Wallet access', - helperText: 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}'; - }()), - ), - ], - onChanged: accesses.isEmpty ? null : onChanged, - ); - } -} - -class _NumberInputField extends StatelessWidget { - const _NumberInputField({ - required this.controller, - required this.label, - required this.hint, - this.helper, - }); - - final TextEditingController controller; - final String label; - final String hint; - final String? helper; - - @override - Widget build(BuildContext context) { - return TextField( - controller: controller, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: label, - hintText: hint, - helperText: helper, - border: const OutlineInputBorder(), - ), - ); - } -} - -class _ValidityWindowField extends StatelessWidget { - const _ValidityWindowField({ - required this.validFrom, - required this.validUntil, - required this.onValidFromChanged, - required this.onValidUntilChanged, - }); - - final DateTime? validFrom; - final DateTime? validUntil; - final ValueChanged onValidFromChanged; - final ValueChanged onValidUntilChanged; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: _DateButtonField( - label: 'Valid from', - value: validFrom, - onChanged: onValidFromChanged, - ), - ), - SizedBox(width: 1.w), - Expanded( - child: _DateButtonField( - label: 'Valid until', - value: validUntil, - onChanged: onValidUntilChanged, - ), - ), - ], - ); - } -} - -class _DateButtonField extends StatelessWidget { - const _DateButtonField({ - required this.label, - required this.value, - required this.onChanged, - }); - - final String label; - final DateTime? value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return OutlinedButton( - onPressed: () async { - final now = DateTime.now(); - final date = await showDatePicker( - context: context, - firstDate: DateTime(now.year - 5), - lastDate: DateTime(now.year + 10), - initialDate: value ?? now, - ); - if (date == null || !context.mounted) { - return; - } - final time = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(value ?? now), - ); - if (time == null) { - return; - } - onChanged( - DateTime(date.year, date.month, date.day, time.hour, time.minute), - ); - }, - onLongPress: value == null ? null : () => onChanged(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'), - ], - ), - ), - ); - } -} - -class _GasFeeOptionsField extends StatelessWidget { - const _GasFeeOptionsField({ - required this.gasFeeController, - required this.priorityFeeController, - }); - - final TextEditingController gasFeeController; - final TextEditingController priorityFeeController; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: _NumberInputField( - controller: gasFeeController, - label: 'Max gas fee / gas', - hint: '1000000000', - ), - ), - SizedBox(width: 1.w), - Expanded( - child: _NumberInputField( - controller: priorityFeeController, - label: 'Max priority fee / gas', - hint: '100000000', - ), - ), - ], - ); - } -} - -class _TransactionRateLimitField extends StatelessWidget { - const _TransactionRateLimitField({ - required this.txCountController, - required this.txWindowController, - }); - - final TextEditingController txCountController; - final TextEditingController txWindowController; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: _NumberInputField( - controller: txCountController, - label: 'Tx count limit', - hint: '10', - ), - ), - SizedBox(width: 1.w), - Expanded( - child: _NumberInputField( - controller: txWindowController, - label: 'Window (seconds)', - hint: '3600', - ), - ), - ], - ); - } -} - -class _GrantTypeSelector extends StatelessWidget { - const _GrantTypeSelector({required this.value, required this.onChanged}); - - final SpecificGrant_Grant value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return SegmentedButton( - segments: const [ - ButtonSegment( - value: SpecificGrant_Grant.etherTransfer, - label: Text('Ether'), - icon: Icon(Icons.bolt_rounded), - ), - ButtonSegment( - value: SpecificGrant_Grant.tokenTransfer, - label: Text('Token'), - icon: Icon(Icons.token_rounded), - ), - ], - selected: {value}, - onSelectionChanged: (selection) => onChanged(selection.first), - ); - } -} - -class _EtherTargetsField extends StatelessWidget { - const _EtherTargetsField({required this.controller}); - - final TextEditingController controller; - - @override - Widget build(BuildContext context) { - return TextField( - controller: controller, - minLines: 3, - maxLines: 6, - decoration: const InputDecoration( - labelText: 'Ether recipients', - hintText: 'One 0x address per line. Leave empty for unrestricted targets.', - border: OutlineInputBorder(), - ), - ); - } -} - -class _VolumeLimitField extends StatelessWidget { - const _VolumeLimitField({ - required this.amountController, - required this.windowController, - required this.title, - }); - - final TextEditingController amountController; - final TextEditingController windowController; - final String title; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 0.8.h), - Row( - children: [ - Expanded( - child: _NumberInputField( - controller: amountController, - label: 'Max volume', - hint: '1000000000000000000', - ), - ), - SizedBox(width: 1.w), - Expanded( - child: _NumberInputField( - controller: windowController, - label: 'Window (seconds)', - hint: '86400', - ), - ), - ], - ), - ], - ); - } -} - -class _TokenContractField extends StatelessWidget { - const _TokenContractField({required this.controller}); - - final TextEditingController controller; - - @override - Widget build(BuildContext context) { - return TextField( - controller: controller, - decoration: const InputDecoration( - labelText: 'Token contract', - hintText: '0x...', - border: OutlineInputBorder(), - ), - ); - } -} - -class _TokenRecipientField extends StatelessWidget { - const _TokenRecipientField({required this.controller}); - - final TextEditingController controller; - - @override - Widget build(BuildContext context) { - return TextField( - controller: controller, - decoration: const InputDecoration( - labelText: 'Token recipient', - hintText: '0x... or leave empty for any recipient', - border: OutlineInputBorder(), - ), - ); - } -} - -class _TokenVolumeLimitsField extends StatelessWidget { - const _TokenVolumeLimitsField({ - required this.values, - required this.onChanged, - }); - - final List<_VolumeLimitValue> values; - final ValueChanged> onChanged; - - @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: () => - onChanged([...values, const _VolumeLimitValue()]), - 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( - value: values[i], - onChanged: (next) { - final updated = [...values]; - updated[i] = next; - onChanged(updated); - }, - onRemove: values.length == 1 - ? null - : () { - final updated = [...values]..removeAt(i); - onChanged(updated); - }, - ), - ), - ], - ); - } -} - -class _TokenVolumeLimitRow extends StatelessWidget { - const _TokenVolumeLimitRow({ - required this.value, - required this.onChanged, - required this.onRemove, - }); - - final _VolumeLimitValue value; - final ValueChanged<_VolumeLimitValue> onChanged; - final VoidCallback? onRemove; - - @override - Widget build(BuildContext context) { - final amountController = TextEditingController(text: value.amount); - final windowController = TextEditingController(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), - ), - ], - ); - } -} - -class _VolumeLimitValue { - const _VolumeLimitValue({this.amount = '', this.windowSeconds = ''}); - - final String amount; - final String windowSeconds; - - _VolumeLimitValue copyWith({String? amount, String? windowSeconds}) { - return _VolumeLimitValue( - amount: amount ?? this.amount, - windowSeconds: windowSeconds ?? this.windowSeconds, - ); - } -} - -Timestamp _toTimestamp(DateTime value) { - final utc = value.toUtc(); - return Timestamp() - ..seconds = Int64(utc.millisecondsSinceEpoch ~/ 1000) - ..nanos = (utc.microsecondsSinceEpoch % 1000000) * 1000; -} - -TransactionRateLimit? _buildRateLimit(String countText, String windowText) { - if (countText.trim().isEmpty || windowText.trim().isEmpty) { - return null; - } - return TransactionRateLimit( - count: int.parse(countText.trim()), - windowSecs: Int64.parseInt(windowText.trim()), - ); -} - -VolumeRateLimit? _buildVolumeLimit(String amountText, String windowText) { - if (amountText.trim().isEmpty || windowText.trim().isEmpty) { - return null; - } - return VolumeRateLimit( - maxVolume: _parseBigIntBytes(amountText), - windowSecs: Int64.parseInt(windowText.trim()), - ); -} - -List? _optionalBigIntBytes(String value) { - if (value.trim().isEmpty) { - return null; - } - return _parseBigIntBytes(value); -} - -List _parseBigIntBytes(String value) { - final number = BigInt.parse(value.trim()); - if (number < BigInt.zero) { - throw Exception('Numeric values must be positive.'); - } - if (number == BigInt.zero) { - return [0]; - } - - var remaining = number; - final bytes = []; - while (remaining > BigInt.zero) { - bytes.insert(0, (remaining & BigInt.from(0xff)).toInt()); - remaining >>= 8; - } - return bytes; -} - -List> _parseAddresses(String input) { - final parts = input - .split(RegExp(r'[\n,]')) - .map((part) => part.trim()) - .where((part) => part.isNotEmpty); - return parts.map(_parseHexAddress).toList(); -} - -List _parseHexAddress(String value) { - final normalized = value.trim().replaceFirst(RegExp(r'^0x'), ''); - if (normalized.length != 40) { - throw Exception('Expected a 20-byte hex address.'); - } - return [ - for (var i = 0; i < normalized.length; i += 2) - int.parse(normalized.substring(i, i + 2), radix: 16), - ]; -} - -String _shortAddress(List bytes) { - final hex = bytes - .map((byte) => byte.toRadixString(16).padLeft(2, '0')) - .join(); - return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}'; -} - -void _showCreateMessage(BuildContext context, String message) { - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), - ); -} - -String _formatCreateError(Object error) { - final text = error.toString(); - if (text.startsWith('Exception: ')) { - return text.substring('Exception: '.length); - } - return text; -} diff --git a/useragent/lib/theme/palette.dart b/useragent/lib/theme/palette.dart index a2a5194..be95981 100644 --- a/useragent/lib/theme/palette.dart +++ b/useragent/lib/theme/palette.dart @@ -6,4 +6,7 @@ class Palette { static const cream = Color(0xFFFFFAF4); static const line = Color(0x1A15263C); static const token = Color(0xFF5C6BC0); + static const cardBorder = Color(0x1A17324A); + static const introGradientStart = Color(0xFFF7F9FC); + static const introGradientEnd = Color(0xFFFDF5EA); } diff --git a/useragent/pubspec.lock b/useragent/pubspec.lock index 1bbf08a..a051ec2 100644 --- a/useragent/pubspec.lock +++ b/useragent/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" auto_route: dependency: "direct main" description: @@ -69,10 +69,10 @@ packages: dependency: "direct main" description: name: biometric_signature - sha256: "0d22580388e5cc037f6f829b29ecda74e343fd61d26c55e33cbcf48a48e5c1dc" + sha256: "86a37a8b514eb3b56980ab6cc9df48ffc3c551147bdf8e3bf2afb2265a7f89e8" url: "https://pub.dev" source: hosted - version: "10.2.0" + version: "11.0.1" bloc: dependency: transitive description: @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: build - sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c url: "https://pub.dev" source: hosted - version: "4.0.4" + version: "4.0.5" build_config: dependency: transitive description: @@ -117,10 +117,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "7981eb922842c77033026eb4341d5af651562008cdb116bdfa31fc46516b6462" + sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e" url: "https://pub.dev" source: hosted - version: "2.12.2" + version: "2.13.1" built_collection: dependency: transitive description: @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: built_value - sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" + sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" url: "https://pub.dev" source: hosted - version: "8.12.4" + version: "8.12.5" characters: dependency: transitive description: @@ -245,10 +245,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" dart_style: dependency: transitive description: @@ -311,6 +311,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.1" + flutter_form_builder: + dependency: "direct main" + description: + name: flutter_form_builder + sha256: "1233251b4bc1d5deb245745d2a89dcebf4cdd382e1ec3f21f1c6703b700e574f" + url: "https://pub.dev" + source: hosted + version: "10.3.0+2" flutter_hooks: dependency: "direct main" description: @@ -653,10 +661,10 @@ packages: dependency: transitive description: name: mockito - sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 url: "https://pub.dev" source: hosted - version: "5.6.3" + version: "5.6.4" mtcore: dependency: "direct main" description: @@ -669,10 +677,10 @@ packages: dependency: transitive description: name: native_toolchain_c - sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" url: "https://pub.dev" source: hosted - version: "0.17.5" + version: "0.17.6" nested: dependency: transitive description: @@ -725,10 +733,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" url: "https://pub.dev" source: hosted - version: "2.2.22" + version: "2.2.23" path_provider_foundation: dependency: transitive description: @@ -938,10 +946,10 @@ packages: dependency: transitive description: name: source_gen - sha256: adc962c96fffb2de1728ef396a995aaedcafbe635abdca13d2a987ce17e57751 + sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd" url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "4.2.2" source_helper: dependency: transitive description: @@ -1018,26 +1026,26 @@ packages: dependency: "direct main" description: name: talker - sha256: "46a60c6014a870a71ab8e3fa22f9580a8d36faf9b39431dcf124940601c0c266" + sha256: c364edc0fbd6c648e1a78e6edd89cccd64df2150ca96d899ecd486b76c185042 url: "https://pub.dev" source: hosted - version: "5.1.15" + version: "5.1.16" talker_flutter: dependency: transitive description: name: talker_flutter - sha256: "7e1819c505cdcbf2cf6497410b8fa3b33d170e6f137716bd278940c0e509f414" + sha256: "54cbbf852101721664faf4a05639fd2fdefdc37178327990abea00390690d4bc" url: "https://pub.dev" source: hosted - version: "5.1.15" + version: "5.1.16" talker_logger: dependency: transitive description: name: talker_logger - sha256: "70112d016d7b978ccb7ef0b0f4941e0f0b0de88d80589db43143cea1d744eae0" + sha256: cea1b8283a28c2118a0b197057fc5beb5b0672c75e40a48725e5e452c0278ff3 url: "https://pub.dev" source: hosted - version: "5.1.15" + version: "5.1.16" term_glyph: dependency: transitive description: diff --git a/useragent/pubspec.yaml b/useragent/pubspec.yaml index 85201b6..9a77966 100644 --- a/useragent/pubspec.yaml +++ b/useragent/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: riverpod: ^3.1.0 hooks_riverpod: ^3.1.0 sizer: ^3.1.3 - biometric_signature: ^10.2.0 + biometric_signature: ^11.0.1 mtcore: hosted: https://git.markettakers.org/api/packages/MarketTakers/pub/ version: ^1.0.6 @@ -34,6 +34,7 @@ dependencies: freezed_annotation: ^3.1.0 json_annotation: ^4.9.0 timeago: ^3.7.1 + flutter_form_builder: ^10.3.0+2 dev_dependencies: flutter_test: