feat(useragent): add SDK clients table screen
This commit is contained in:
489
useragent/lib/screens/dashboard/evm/evm.dart
Normal file
489
useragent/lib/screens/dashboard/evm/evm.dart
Normal file
@@ -0,0 +1,489 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:arbiter/proto/evm.pb.dart';
|
||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||
import 'package:arbiter/providers/evm/evm.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sizer/sizer.dart';
|
||||
|
||||
@RoutePage()
|
||||
class EvmScreen extends HookConsumerWidget {
|
||||
const EvmScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final walletsAsync = ref.watch(evmProvider);
|
||||
final isCreating = useState(false);
|
||||
|
||||
final wallets = walletsAsync.asData?.value;
|
||||
final loadedWallets = wallets ?? const <WalletEntry>[];
|
||||
final isConnected =
|
||||
ref.watch(connectionManagerProvider).asData?.value != null;
|
||||
|
||||
void showMessage(String message) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refreshWallets() async {
|
||||
try {
|
||||
await ref.read(evmProvider.notifier).refreshWallets();
|
||||
} catch (error) {
|
||||
showMessage(_formatError(error));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createWallet() async {
|
||||
if (isCreating.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await ref.read(evmProvider.notifier).createWallet();
|
||||
showMessage('Wallet created.');
|
||||
} catch (error) {
|
||||
showMessage(_formatError(error));
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
final content = switch (walletsAsync) {
|
||||
AsyncLoading() when wallets == null => const _StatePanel(
|
||||
icon: Icons.hourglass_top,
|
||||
title: 'Loading wallets',
|
||||
body: 'Pulling wallet registry from Arbiter.',
|
||||
busy: true,
|
||||
),
|
||||
AsyncError(:final error) => _StatePanel(
|
||||
icon: Icons.sync_problem,
|
||||
title: 'Wallet registry unavailable',
|
||||
body: _formatError(error),
|
||||
actionLabel: 'Retry',
|
||||
onAction: refreshWallets,
|
||||
),
|
||||
_ when !isConnected => _StatePanel(
|
||||
icon: Icons.portable_wifi_off,
|
||||
title: 'No active server connection',
|
||||
body: 'Reconnect to Arbiter to list or create EVM wallets.',
|
||||
actionLabel: 'Refresh',
|
||||
onAction: refreshWallets,
|
||||
),
|
||||
_ when loadedWallets.isEmpty => _StatePanel(
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
title: 'No wallets yet',
|
||||
body:
|
||||
'Create the first vault-backed wallet to start building your EVM registry.',
|
||||
actionLabel: isCreating.value ? 'Creating...' : 'Create wallet',
|
||||
onAction: isCreating.value ? null : createWallet,
|
||||
),
|
||||
_ => _WalletTable(wallets: loadedWallets),
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator.adaptive(
|
||||
color: _Palette.ink,
|
||||
backgroundColor: Colors.white,
|
||||
onRefresh: refreshWallets,
|
||||
child: ListView(
|
||||
physics: const BouncingScrollPhysics(
|
||||
parent: AlwaysScrollableScrollPhysics(),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
|
||||
children: [
|
||||
_Header(
|
||||
isBusy: walletsAsync.isLoading,
|
||||
isCreating: isCreating.value,
|
||||
onCreate: createWallet,
|
||||
onRefresh: refreshWallets,
|
||||
),
|
||||
SizedBox(height: 1.8.h),
|
||||
content,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Palette {
|
||||
static const ink = Color(0xFF15263C);
|
||||
static const coral = Color(0xFFE26254);
|
||||
static const cream = Color(0xFFFFFAF4);
|
||||
static const line = Color(0x1A15263C);
|
||||
}
|
||||
|
||||
double get _accentStripWidth => 0.8.w;
|
||||
double get _cellHorizontalPadding => 1.8.w;
|
||||
double get _walletColumnWidth => 18.w;
|
||||
double get _columnGap => 1.8.w;
|
||||
double get _tableMinWidth => 72.w;
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({
|
||||
required this.isBusy,
|
||||
required this.isCreating,
|
||||
required this.onCreate,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
final bool isBusy;
|
||||
final bool isCreating;
|
||||
final Future<void> Function() onCreate;
|
||||
final Future<void> Function() onRefresh;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
color: _Palette.cream,
|
||||
border: Border.all(color: _Palette.line),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'EVM Wallet Vault',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: _Palette.ink,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isBusy) ...[
|
||||
Text(
|
||||
'Syncing',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _Palette.ink.withValues(alpha: 0.62),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 1.w),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: isCreating ? null : () => onCreate(),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _Palette.ink,
|
||||
foregroundColor: Colors.white,
|
||||
padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
icon: isCreating
|
||||
? SizedBox(
|
||||
width: 1.6.h,
|
||||
height: 1.6.h,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.2),
|
||||
)
|
||||
: const Icon(Icons.add_circle_outline, size: 18),
|
||||
label: Text(isCreating ? 'Creating...' : 'Create'),
|
||||
),
|
||||
SizedBox(width: 1.w),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => onRefresh(),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _Palette.ink,
|
||||
side: BorderSide(color: _Palette.line),
|
||||
padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Refresh'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WalletTable extends StatelessWidget {
|
||||
const _WalletTable({required this.wallets});
|
||||
|
||||
final List<WalletEntry> wallets;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: _Palette.cream.withValues(alpha: 0.92),
|
||||
border: Border.all(color: _Palette.line),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(2.h),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final tableWidth = math.max(_tableMinWidth, constraints.maxWidth);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Managed wallets',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: _Palette.ink,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 0.6.h),
|
||||
Text(
|
||||
'Every address here is generated and held by Arbiter.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: _Palette.ink.withValues(alpha: 0.70),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 1.6.h),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SizedBox(
|
||||
width: tableWidth,
|
||||
child: Column(
|
||||
children: [
|
||||
const _WalletTableHeader(),
|
||||
SizedBox(height: 1.h),
|
||||
for (var i = 0; i < wallets.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: i == wallets.length - 1 ? 0 : 1.h,
|
||||
),
|
||||
child: _WalletTableRow(
|
||||
wallet: wallets[i],
|
||||
index: i,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WalletTableHeader extends StatelessWidget {
|
||||
const _WalletTableHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: _Palette.ink.withValues(alpha: 0.72),
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 1.4.h),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _Palette.ink.withValues(alpha: 0.04),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: _accentStripWidth + _cellHorizontalPadding),
|
||||
SizedBox(
|
||||
width: _walletColumnWidth,
|
||||
child: Text('Wallet', style: style),
|
||||
),
|
||||
SizedBox(width: _columnGap),
|
||||
Expanded(child: Text('Address', style: style)),
|
||||
SizedBox(width: _cellHorizontalPadding),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WalletTableRow extends StatelessWidget {
|
||||
const _WalletTableRow({required this.wallet, required this.index});
|
||||
|
||||
final WalletEntry wallet;
|
||||
final int index;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = _accentColor(wallet.address);
|
||||
final address = _hexAddress(wallet.address);
|
||||
final rowHeight = 5.h;
|
||||
final walletStyle = Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: _Palette.ink);
|
||||
final addressStyle = Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: _Palette.ink);
|
||||
|
||||
return Container(
|
||||
height: rowHeight,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
color: accent.withValues(alpha: 0.10),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.28)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: _accentStripWidth,
|
||||
height: rowHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: accent,
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
left: Radius.circular(18),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: _cellHorizontalPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _walletColumnWidth,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 1.2.h,
|
||||
height: 1.2.h,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: accent,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 1.w),
|
||||
Text(
|
||||
'Wallet ${(index + 1).toString().padLeft(2, '0')}',
|
||||
style: walletStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: _columnGap),
|
||||
Expanded(
|
||||
child: Text(
|
||||
address,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: addressStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatePanel extends StatelessWidget {
|
||||
const _StatePanel({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
this.busy = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String body;
|
||||
final String? actionLabel;
|
||||
final Future<void> Function()? onAction;
|
||||
final bool busy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: _Palette.cream.withValues(alpha: 0.92),
|
||||
border: Border.all(color: _Palette.line),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(2.8.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (busy)
|
||||
SizedBox(
|
||||
width: 2.8.h,
|
||||
height: 2.8.h,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
)
|
||||
else
|
||||
Icon(icon, size: 34, color: _Palette.coral),
|
||||
SizedBox(height: 1.8.h),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: _Palette.ink,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 1.h),
|
||||
Text(
|
||||
body,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: _Palette.ink.withValues(alpha: 0.72),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
SizedBox(height: 2.h),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => onAction!(),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(actionLabel!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _hexAddress(List<int> bytes) {
|
||||
final hex = bytes
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
return '0x$hex';
|
||||
}
|
||||
|
||||
Color _accentColor(List<int> bytes) {
|
||||
final seed = bytes.fold<int>(0, (value, byte) => value + byte);
|
||||
final hue = (seed * 17) % 360;
|
||||
return HSLColor.fromAHSL(1, hue.toDouble(), 0.68, 0.54).toColor();
|
||||
}
|
||||
|
||||
String _formatError(Object error) {
|
||||
final message = error.toString();
|
||||
if (message.startsWith('Exception: ')) {
|
||||
return message.substring('Exception: '.length);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
824
useragent/lib/screens/dashboard/evm/grants/grant_create.dart
Normal file
824
useragent/lib/screens/dashboard/evm/grants/grant_create.dart
Normal file
@@ -0,0 +1,824 @@
|
||||
import 'package:arbiter/proto/evm.pb.dart';
|
||||
import 'package:arbiter/providers/evm/evm.dart';
|
||||
import 'package:arbiter/providers/evm/evm_grants.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:sizer/sizer.dart';
|
||||
|
||||
@RoutePage()
|
||||
class CreateEvmGrantScreen extends HookConsumerWidget {
|
||||
const CreateEvmGrantScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final wallets = ref.watch(evmProvider).asData?.value ?? const <WalletEntry>[];
|
||||
final createMutation = ref.watch(createEvmGrantMutation);
|
||||
|
||||
final selectedWalletIndex = useState<int?>(wallets.isEmpty ? null : 0);
|
||||
final clientIdController = useTextEditingController();
|
||||
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<DateTime?>(null);
|
||||
final validUntil = useState<DateTime?>(null);
|
||||
final grantType = useState<SpecificGrant_Grant>(
|
||||
SpecificGrant_Grant.etherTransfer,
|
||||
);
|
||||
final tokenVolumeLimits = useState<List<_VolumeLimitValue>>([
|
||||
const _VolumeLimitValue(),
|
||||
]);
|
||||
|
||||
Future<void> submit() async {
|
||||
final selectedWallet = selectedWalletIndex.value;
|
||||
if (selectedWallet == null) {
|
||||
_showCreateMessage(context, 'At least one wallet is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final clientId = int.parse(clientIdController.text.trim());
|
||||
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.'),
|
||||
};
|
||||
|
||||
await executeCreateEvmGrant(
|
||||
ref,
|
||||
clientId: clientId,
|
||||
walletId: selectedWallet + 1,
|
||||
chainId: chainId,
|
||||
validFrom: validFrom.value,
|
||||
validUntil: validUntil.value,
|
||||
maxGasFeePerGas: _optionalBigIntBytes(gasFeeController.text),
|
||||
maxPriorityFeePerGas: _optionalBigIntBytes(priorityFeeController.text),
|
||||
rateLimit: rateLimit,
|
||||
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: [
|
||||
_CreateIntroCard(walletCount: wallets.length),
|
||||
SizedBox(height: 1.8.h),
|
||||
_CreateSection(
|
||||
title: 'Shared grant options',
|
||||
children: [
|
||||
_WalletPickerField(
|
||||
wallets: wallets,
|
||||
selectedIndex: selectedWalletIndex.value,
|
||||
onChanged: (value) => selectedWalletIndex.value = value,
|
||||
),
|
||||
_NumberInputField(
|
||||
controller: clientIdController,
|
||||
label: 'Client ID',
|
||||
hint: '42',
|
||||
helper:
|
||||
'Manual for now. The app does not yet expose a client picker.',
|
||||
),
|
||||
_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({required this.walletCount});
|
||||
|
||||
final int walletCount;
|
||||
|
||||
@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(
|
||||
'Compose shared constraints once, then switch between Ether and token transfer rules. $walletCount wallet${walletCount == 1 ? '' : 's'} currently available.',
|
||||
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<Widget> 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 _WalletPickerField extends StatelessWidget {
|
||||
const _WalletPickerField({
|
||||
required this.wallets,
|
||||
required this.selectedIndex,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final List<WalletEntry> wallets;
|
||||
final int? selectedIndex;
|
||||
final ValueChanged<int?> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DropdownButtonFormField<int>(
|
||||
initialValue: selectedIndex,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Wallet',
|
||||
helperText:
|
||||
'Uses the current wallet order. The API still does not expose stable wallet IDs directly.',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (var i = 0; i < wallets.length; i++)
|
||||
DropdownMenuItem(
|
||||
value: i,
|
||||
child: Text(
|
||||
'Wallet ${(i + 1).toString().padLeft(2, '0')} · ${_shortAddress(wallets[i].address)}',
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: wallets.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<DateTime?> onValidFromChanged;
|
||||
final ValueChanged<DateTime?> 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<DateTime?> 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<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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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<List<_VolumeLimitValue>> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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<List<int>> _parseAddresses(String input) {
|
||||
final parts = input
|
||||
.split(RegExp(r'[\n,]'))
|
||||
.map((part) => part.trim())
|
||||
.where((part) => part.isNotEmpty);
|
||||
return parts.map(_parseHexAddress).toList();
|
||||
}
|
||||
|
||||
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)}';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user