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.dart'; import 'package:arbiter/providers/evm_grants.dart'; import 'package:arbiter/router.gr.dart'; import 'package:arbiter/widgets/bottom_popup.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/experimental/mutation.dart'; import 'package:sizer/sizer.dart'; @RoutePage() class EvmGrantsScreen extends ConsumerWidget { const EvmGrantsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final grantsAsync = ref.watch(evmGrantsProvider); final grantsState = grantsAsync.asData?.value; final wallets = ref.watch(evmProvider).asData?.value ?? const []; final revokeMutation = ref.watch(revokeEvmGrantMutation); final isConnected = ref.watch(connectionManagerProvider).asData?.value != null; Future refresh() async { try { await ref.read(evmGrantsProvider.notifier).refresh(); } catch (error) { if (!context.mounted) { return; } _showMessage(context, _formatError(error)); } } Future revokeGrant(GrantEntry grant) async { try { await executeRevokeEvmGrant(ref, grantId: grant.id); if (context.mounted) { Navigator.of(context).pop(); _showMessage(context, 'Grant revoked.'); } } catch (error) { if (!context.mounted) { return; } _showMessage(context, _formatError(error)); } } Future openCreate() async { await context.router.push(const CreateEvmGrantRoute()); } final content = switch (grantsAsync) { AsyncLoading() when grantsState == null => const _GrantStatePanel( icon: Icons.hourglass_top, title: 'Loading grants', body: 'Pulling EVM grants and wallet context from Arbiter.', busy: true, ), AsyncError(:final error) => _GrantStatePanel( icon: Icons.sync_problem, title: 'Grant registry unavailable', body: _formatError(error), actionLabel: 'Retry', onAction: refresh, ), _ when !isConnected => _GrantStatePanel( icon: Icons.portable_wifi_off, title: 'No active server connection', body: 'Reconnect to Arbiter to inspect or create EVM grants.', actionLabel: 'Refresh', onAction: refresh, ), _ when grantsState == null => const SizedBox.shrink(), _ when grantsState.grants.isEmpty => _GrantStatePanel( icon: Icons.rule_folder_outlined, title: 'No grants yet', body: 'Create the first grant to authorize scoped transaction signing for a client.', actionLabel: 'Create grant', onAction: openCreate, ), _ => _GrantGrid( state: grantsState, wallets: wallets, onGrantTap: (grant) { return showBottomPopup( context: context, builder: (popupContext) => _GrantDetailSheet( grant: grant, wallets: wallets, isRevoking: revokeMutation is MutationPending, onRevoke: () => revokeGrant(grant), ), ); }, ), }; return Scaffold( body: SafeArea( child: RefreshIndicator.adaptive( color: _GrantPalette.ink, backgroundColor: Colors.white, onRefresh: refresh, child: ListView( physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h), children: [ _GrantHeader( state: grantsState, isRefreshing: grantsAsync.isLoading, onRefresh: refresh, onCreate: openCreate, onToggleShowRevoked: (value) { ref.read(evmGrantsProvider.notifier).toggleShowRevoked(value); if (value) { _showMessage( context, 'Revoked grant history is waiting on backend support. Active grants only for now.', ); } }, ), if (grantsState?.showRevoked == true) Padding( padding: EdgeInsets.only(top: 1.4.h), child: const _RevokedSupportBanner(), ), SizedBox(height: 1.8.h), content, ], ), ), ), ); } } class _GrantPalette { static const ink = Color(0xFF17324A); static const sea = Color(0xFF0F766E); static const gold = Color(0xFFE19A2A); static const coral = Color(0xFFE46B56); static const mist = Color(0xFFF7F8FB); static const line = Color(0x1A17324A); } class _GrantHeader extends StatelessWidget { const _GrantHeader({ required this.state, required this.isRefreshing, required this.onRefresh, required this.onCreate, required this.onToggleShowRevoked, }); final EvmGrantsState? state; final bool isRefreshing; final Future Function() onRefresh; final Future Function() onCreate; final ValueChanged onToggleShowRevoked; @override Widget build(BuildContext context) { final theme = Theme.of(context); final menuValue = state?.showRevoked ?? false; return Container( padding: EdgeInsets.symmetric(horizontal: 1.8.w, vertical: 1.4.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(22), gradient: const LinearGradient( colors: [Color(0xFFF6F8FC), Color(0xFFFDF7EF)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), border: Border.all(color: _GrantPalette.line), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'EVM Grants', style: theme.textTheme.titleLarge?.copyWith( color: _GrantPalette.ink, fontWeight: FontWeight.w800, ), ), SizedBox(height: 0.4.h), Text( 'Browse active permissions, inspect policy details, and create new grants.', style: theme.textTheme.bodyMedium?.copyWith( color: _GrantPalette.ink.withValues(alpha: 0.70), height: 1.35, ), ), ], ), ), IconButton.filledTonal( tooltip: 'Filters', onPressed: () async { final button = context.findRenderObject() as RenderBox?; final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; if (button == null || overlay == null) { return; } final selected = await showMenu( context: context, position: RelativeRect.fromRect( Rect.fromPoints( button.localToGlobal(Offset.zero, ancestor: overlay), button.localToGlobal( button.size.bottomRight(Offset.zero), ancestor: overlay, ), ), Offset.zero & overlay.size, ), items: [ CheckedPopupMenuItem( value: !menuValue, checked: menuValue, child: Text( menuValue ? 'Hide revoked grants' : 'Show revoked grants', ), ), ], ); if (selected != null) { onToggleShowRevoked(selected); } }, icon: const Icon(Icons.filter_list_rounded), ), SizedBox(width: 0.8.w), OutlinedButton.icon( onPressed: isRefreshing ? null : () => onRefresh(), icon: isRefreshing ? SizedBox( width: 1.8.h, height: 1.8.h, child: const CircularProgressIndicator(strokeWidth: 2.2), ) : const Icon(Icons.refresh_rounded, size: 18), label: const Text('Refresh'), ), SizedBox(width: 0.8.w), FilledButton.icon( onPressed: () => onCreate(), style: FilledButton.styleFrom( backgroundColor: _GrantPalette.ink, foregroundColor: Colors.white, ), icon: const Icon(Icons.add_rounded, size: 18), label: const Text('Create'), ), ], ), ); } } class _RevokedSupportBanner extends StatelessWidget { const _RevokedSupportBanner(); @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.all(1.6.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), color: _GrantPalette.gold.withValues(alpha: 0.12), border: Border.all(color: _GrantPalette.gold.withValues(alpha: 0.28)), ), child: Row( children: [ Icon(Icons.info_outline_rounded, color: _GrantPalette.gold), SizedBox(width: 1.2.w), Expanded( child: Text( 'Revoked grant history is not exposed by the current backend yet. This screen still shows active grants only.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: _GrantPalette.ink.withValues(alpha: 0.78), height: 1.35, ), ), ), ], ), ); } } class _GrantGrid extends StatelessWidget { const _GrantGrid({ required this.state, required this.wallets, required this.onGrantTap, }); final EvmGrantsState state; final List wallets; final Future Function(GrantEntry grant) onGrantTap; @override Widget build(BuildContext context) { final grantsByWallet = >{}; for (final grant in state.grants) { grantsByWallet.putIfAbsent(grant.shared.walletId, () => []).add(grant); } final walletIds = grantsByWallet.keys.toList()..sort(); return Column( children: [ for (final walletId in walletIds) Padding( padding: EdgeInsets.only(bottom: 1.8.h), child: _WalletGrantSection( walletId: walletId, walletAddress: _addressForWalletId(wallets, walletId), grants: grantsByWallet[walletId]!, onGrantTap: onGrantTap, ), ), ], ); } } class _WalletGrantSection extends StatelessWidget { const _WalletGrantSection({ required this.walletId, required this.walletAddress, required this.grants, required this.onGrantTap, }); final int walletId; final List? walletAddress; final List grants; final Future Function(GrantEntry grant) onGrantTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), color: _GrantPalette.mist, border: Border.all(color: _GrantPalette.line), ), padding: EdgeInsets.all(2.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Wallet ${walletId.toString().padLeft(2, '0')}', style: theme.textTheme.titleMedium?.copyWith( color: _GrantPalette.ink, fontWeight: FontWeight.w800, ), ), SizedBox(height: 0.4.h), Text( walletAddress == null ? 'Wallet address unavailable in the current API.' : _hexAddress(walletAddress!), style: theme.textTheme.bodySmall?.copyWith( color: _GrantPalette.ink.withValues(alpha: 0.70), ), ), SizedBox(height: 1.8.h), LayoutBuilder( builder: (context, constraints) { final maxWidth = constraints.maxWidth; final cardWidth = maxWidth >= 900 ? (maxWidth - 2.w * 2) / 3 : maxWidth >= 620 ? (maxWidth - 2.w) / 2 : maxWidth; return Wrap( spacing: 1.4.w, runSpacing: 1.4.h, children: [ for (final grant in grants) SizedBox( width: math.max(280, cardWidth), child: _GrantCardRouter( grant: grant, walletAddress: walletAddress, onTap: () => onGrantTap(grant), ), ), ], ); }, ), ], ), ); } } class _GrantCardRouter extends StatelessWidget { const _GrantCardRouter({ required this.grant, required this.walletAddress, required this.onTap, }); final GrantEntry grant; final List? walletAddress; final VoidCallback onTap; @override Widget build(BuildContext context) { return switch (grant.specific.whichGrant()) { SpecificGrant_Grant.etherTransfer => _EtherGrantCard( grant: grant, walletAddress: walletAddress, onTap: onTap, ), SpecificGrant_Grant.tokenTransfer => _TokenGrantCard( grant: grant, walletAddress: walletAddress, onTap: onTap, ), _ => _UnsupportedGrantCard(grant: grant, onTap: onTap), }; } } class _GrantCardFrame extends StatelessWidget { const _GrantCardFrame({ required this.icon, required this.iconColor, required this.title, required this.subtitle, required this.chips, required this.onTap, }); final IconData icon; final Color iconColor; final String title; final String subtitle; final List chips; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Material( color: Colors.white, borderRadius: BorderRadius.circular(22), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(22), child: Ink( padding: EdgeInsets.all(2.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(22), border: Border.all(color: _GrantPalette.line), boxShadow: [ BoxShadow( color: _GrantPalette.ink.withValues(alpha: 0.05), blurRadius: 24, offset: const Offset(0, 12), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Align( child: Container( width: 6.2.h, height: 6.2.h, decoration: BoxDecoration( shape: BoxShape.circle, color: iconColor.withValues(alpha: 0.14), ), child: Icon(icon, color: iconColor, size: 3.h), ), ), SizedBox(height: 1.6.h), Text( title, style: theme.textTheme.titleMedium?.copyWith( color: _GrantPalette.ink, fontWeight: FontWeight.w800, ), ), SizedBox(height: 0.5.h), Text( subtitle, style: theme.textTheme.bodyMedium?.copyWith( color: _GrantPalette.ink.withValues(alpha: 0.72), height: 1.35, ), ), SizedBox(height: 1.4.h), Wrap( spacing: 0.8.w, runSpacing: 0.8.h, children: [ for (final chip in chips) _GrantChip(label: chip), ], ), ], ), ), ), ); } } class _EtherGrantCard extends StatelessWidget { const _EtherGrantCard({ required this.grant, required this.walletAddress, required this.onTap, }); final GrantEntry grant; final List? walletAddress; final VoidCallback onTap; @override Widget build(BuildContext context) { final settings = grant.specific.etherTransfer; final targets = settings.targets.length; final subtitle = targets == 0 ? 'ETH transfers with a shared limit profile.' : '$targets target${targets == 1 ? '' : 's'} authorized.'; return _GrantCardFrame( icon: Icons.bolt_rounded, iconColor: _GrantPalette.gold, title: 'Ether Transfer', subtitle: subtitle, chips: [ 'Client ${grant.clientId}', 'Wallet ${grant.shared.walletId}', if (walletAddress != null) _shortAddress(walletAddress!), ], onTap: onTap, ); } } class _TokenGrantCard extends StatelessWidget { const _TokenGrantCard({ required this.grant, required this.walletAddress, required this.onTap, }); final GrantEntry grant; final List? walletAddress; final VoidCallback onTap; @override Widget build(BuildContext context) { final settings = grant.specific.tokenTransfer; return _GrantCardFrame( icon: Icons.token_rounded, iconColor: _GrantPalette.sea, title: 'Token Transfer', subtitle: 'Contract ${_shortAddress(settings.tokenContract)} with ${settings.volumeLimits.length} volume rule${settings.volumeLimits.length == 1 ? '' : 's'}.', chips: [ 'Client ${grant.clientId}', 'Wallet ${grant.shared.walletId}', if (walletAddress != null) _shortAddress(walletAddress!), ], onTap: onTap, ); } } class _UnsupportedGrantCard extends StatelessWidget { const _UnsupportedGrantCard({required this.grant, required this.onTap}); final GrantEntry grant; final VoidCallback onTap; @override Widget build(BuildContext context) { return _GrantCardFrame( icon: Icons.help_outline_rounded, iconColor: _GrantPalette.coral, title: 'Unsupported Grant', subtitle: 'This grant type cannot be rendered in the current useragent.', chips: ['Grant ${grant.id}'], onTap: onTap, ); } } class _GrantChip extends StatelessWidget { const _GrantChip({required this.label}); final String label; @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.symmetric(horizontal: 1.1.w, vertical: 0.7.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(999), color: _GrantPalette.ink.withValues(alpha: 0.06), ), child: Text( label, style: Theme.of(context).textTheme.labelMedium?.copyWith( color: _GrantPalette.ink.withValues(alpha: 0.76), fontWeight: FontWeight.w700, ), ), ); } } class _GrantDetailSheet extends StatelessWidget { const _GrantDetailSheet({ required this.grant, required this.wallets, required this.isRevoking, required this.onRevoke, }); final GrantEntry grant; final List wallets; final bool isRevoking; final Future Function() onRevoke; @override Widget build(BuildContext context) { final walletAddress = _addressForWalletId(wallets, grant.shared.walletId); return Container( width: 100.w, constraints: BoxConstraints(maxWidth: 760), decoration: BoxDecoration( borderRadius: BorderRadius.circular(28), color: Colors.white, ), padding: EdgeInsets.all(2.2.h), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( 'Grant #${grant.id}', style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: _GrantPalette.ink, fontWeight: FontWeight.w800, ), ), ), IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close_rounded), ), ], ), SizedBox(height: 1.2.h), Wrap( spacing: 1.w, runSpacing: 0.8.h, children: [ _GrantChip(label: 'Client ${grant.clientId}'), _GrantChip(label: 'Wallet ${grant.shared.walletId}'), if (walletAddress != null) _GrantChip(label: _shortAddress(walletAddress)), ], ), SizedBox(height: 2.h), _SectionTitle(title: 'Shared policy'), _FieldSummary(label: 'Chain ID', value: grant.shared.chainId.toString()), _FieldSummary( label: 'Validity', value: _validitySummary(grant.shared), ), _FieldSummary( label: 'Gas fee cap', value: _optionalBigInt(grant.shared.maxGasFeePerGas), ), _FieldSummary( label: 'Priority fee cap', value: _optionalBigInt(grant.shared.maxPriorityFeePerGas), ), _FieldSummary( label: 'Tx count limit', value: grant.shared.hasRateLimit() ? '${grant.shared.rateLimit.count} tx / ${grant.shared.rateLimit.windowSecs}s' : 'Not set', ), SizedBox(height: 1.8.h), _SectionTitle(title: 'Grant-specific settings'), switch (grant.specific.whichGrant()) { SpecificGrant_Grant.etherTransfer => _EtherGrantDetails( settings: grant.specific.etherTransfer, ), SpecificGrant_Grant.tokenTransfer => _TokenGrantDetails( settings: grant.specific.tokenTransfer, ), _ => const Text('Unsupported grant type'), }, SizedBox(height: 2.2.h), Align( alignment: Alignment.centerRight, child: FilledButton.icon( onPressed: isRevoking ? null : () => onRevoke(), style: FilledButton.styleFrom( backgroundColor: _GrantPalette.coral, foregroundColor: Colors.white, ), icon: isRevoking ? SizedBox( width: 1.8.h, height: 1.8.h, child: const CircularProgressIndicator(strokeWidth: 2.2), ) : const Icon(Icons.block_rounded), label: Text(isRevoking ? 'Revoking...' : 'Revoke grant'), ), ), ], ), ); } } class _EtherGrantDetails extends StatelessWidget { const _EtherGrantDetails({required this.settings}); final EtherTransferSettings settings; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _FieldSummary( label: 'Targets', value: settings.targets.isEmpty ? 'No explicit target restriction' : settings.targets.map(_hexAddress).join(', '), ), _FieldSummary( label: 'Volume limit', value: settings.hasLimit() ? '${_optionalBigInt(settings.limit.maxVolume)} / ${settings.limit.windowSecs}s' : 'Not set', ), ], ); } } class _TokenGrantDetails extends StatelessWidget { const _TokenGrantDetails({required this.settings}); final TokenTransferSettings settings; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _FieldSummary( label: 'Token contract', value: _hexAddress(settings.tokenContract), ), _FieldSummary( label: 'Recipient', value: settings.hasTarget() ? _hexAddress(settings.target) : 'Any recipient', ), _FieldSummary( label: 'Volume rules', value: settings.volumeLimits.isEmpty ? 'Not set' : settings.volumeLimits .map( (limit) => '${_optionalBigInt(limit.maxVolume)} / ${limit.windowSecs}s', ) .join('\n'), ), ], ); } } class _FieldSummary extends StatelessWidget { const _FieldSummary({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only(bottom: 1.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: Theme.of(context).textTheme.labelLarge?.copyWith( color: _GrantPalette.ink.withValues(alpha: 0.60), fontWeight: FontWeight.w800, ), ), SizedBox(height: 0.3.h), Text( value, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: _GrantPalette.ink, height: 1.4, ), ), ], ), ); } } class _SectionTitle extends StatelessWidget { const _SectionTitle({required this.title}); final String title; @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only(bottom: 1.1.h), child: Text( title, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: _GrantPalette.ink, fontWeight: FontWeight.w800, ), ), ); } } class _GrantStatePanel extends StatelessWidget { const _GrantStatePanel({ required this.icon, required this.title, required this.body, this.actionLabel, this.onAction, this.busy = false, }); final IconData icon; final String title; final String body; final String? actionLabel; final Future Function()? onAction; final bool busy; @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), color: _GrantPalette.mist, border: Border.all(color: _GrantPalette.line), ), child: Padding( padding: EdgeInsets.all(2.8.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (busy) SizedBox( width: 2.8.h, height: 2.8.h, child: const CircularProgressIndicator(strokeWidth: 2.5), ) else Icon(icon, size: 34, color: _GrantPalette.coral), SizedBox(height: 1.8.h), Text( title, style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: _GrantPalette.ink, fontWeight: FontWeight.w800, ), ), SizedBox(height: 1.h), Text( body, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: _GrantPalette.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!), ), ], ], ), ), ); } } List? _addressForWalletId(List wallets, int walletId) { final index = walletId - 1; if (index < 0 || index >= wallets.length) { return null; } return wallets[index].address; } String _shortAddress(List bytes) { final value = _hexAddress(bytes); if (value.length <= 14) { return value; } return '${value.substring(0, 8)}...${value.substring(value.length - 4)}'; } String _hexAddress(List bytes) { final hex = bytes .map((byte) => byte.toRadixString(16).padLeft(2, '0')) .join(); return '0x$hex'; } String _optionalBigInt(List bytes) { if (bytes.isEmpty) { return 'Not set'; } return _bytesToBigInt(bytes).toString(); } String _validitySummary(SharedSettings shared) { final from = shared.hasValidFrom() ? DateTime.fromMillisecondsSinceEpoch( shared.validFrom.seconds.toInt() * 1000, isUtc: true, ).toLocal().toString() : 'Immediate'; final until = shared.hasValidUntil() ? DateTime.fromMillisecondsSinceEpoch( shared.validUntil.seconds.toInt() * 1000, isUtc: true, ).toLocal().toString() : 'Open-ended'; return '$from -> $until'; } BigInt _bytesToBigInt(List bytes) { return bytes.fold( BigInt.zero, (value, byte) => (value << 8) | BigInt.from(byte), ); } void _showMessage(BuildContext context, String message) { if (!context.mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), ); } String _formatError(Object error) { final message = error.toString(); if (message.startsWith('Exception: ')) { return message.substring('Exception: '.length); } return message; }