fix(clippy): apply auto-fixable linting suggestions

This commit is contained in:
Clippy Bot
2026-06-23 18:47:23 +00:00
parent 4fd75701c7
commit cc21036448
425 changed files with 79190 additions and 79190 deletions

View File

@@ -1,39 +1,39 @@
import 'dart:async';
import 'package:arbiter/providers/key.dart';
import 'package:arbiter/router.gr.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:mtcore/markettakers.dart';
@RoutePage()
class Bootstrap extends HookConsumerWidget {
const Bootstrap({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final container = ProviderScope.containerOf(context);
final completer = useMemoized(() {
final completer = Completer<void>();
completer.future.then((_) async {
if (context.mounted) {
final router = AutoRouter.of(context);
router.replace(const ServerInfoSetupRoute());
}
});
return completer;
}, []);
final stages = useMemoized(() {
return [KeyBootstrapper(ref: container)];
}, []);
final bootstrapper = useMemoized(
() => Bootstrapper(stages: stages, onCompleted: completer),
[stages],
);
return Scaffold(body: bootstrapper);
}
}
import 'dart:async';
import 'package:arbiter/providers/key.dart';
import 'package:arbiter/router.gr.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:mtcore/markettakers.dart';
@RoutePage()
class Bootstrap extends HookConsumerWidget {
const Bootstrap({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final container = ProviderScope.containerOf(context);
final completer = useMemoized(() {
final completer = Completer<void>();
completer.future.then((_) async {
if (context.mounted) {
final router = AutoRouter.of(context);
router.replace(const ServerInfoSetupRoute());
}
});
return completer;
}, []);
final stages = useMemoized(() {
return [KeyBootstrapper(ref: container)];
}, []);
final bootstrapper = useMemoized(
() => Bootstrapper(stages: stages, onCompleted: completer),
[stages],
);
return Scaffold(body: bootstrapper);
}
}

View File

@@ -1,143 +1,143 @@
import 'package:arbiter/proto/shared/client.pb.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
class SdkConnectCallout extends StatelessWidget {
const SdkConnectCallout({
super.key,
required this.pubkey,
required this.clientInfo,
this.onAccept,
this.onDecline,
});
final String pubkey;
final ClientInfo clientInfo;
final VoidCallback? onAccept;
final VoidCallback? onDecline;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final name = clientInfo.hasName() && clientInfo.name.isNotEmpty
? clientInfo.name
: _shortPubkey(pubkey);
final hasDescription =
clientInfo.hasDescription() && clientInfo.description.isNotEmpty;
final hasVersion = clientInfo.hasVersion() && clientInfo.version.isNotEmpty;
final showInfoCard = hasDescription || hasVersion;
return CreamFrame(
padding: EdgeInsets.all(2.4.h),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 1.6.h,
children: [
// if (clientInfo.iconUrl != null)
// CircleAvatar(
// radius: 36,
// backgroundColor: Palette.line,
// backgroundImage: NetworkImage(iconUrl!),
// ),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 0.4.h,
children: [
Text(
name,
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
Text(
'is requesting a connection',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: Palette.ink.withValues(alpha: 0.55),
),
),
],
),
if (showInfoCard)
Container(
width: double.infinity,
decoration: BoxDecoration(
color: Palette.ink.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Palette.line),
),
padding: EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 0.6.h,
children: [
if (hasDescription)
Text(
clientInfo.description,
style: theme.textTheme.bodyMedium?.copyWith(
color: Palette.ink.withValues(alpha: 0.80),
height: 1.5,
),
),
if (hasVersion)
Text(
'v${clientInfo.version}',
style: theme.textTheme.bodySmall?.copyWith(
color: Palette.ink.withValues(alpha: 0.50),
),
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: OutlinedButton(
onPressed: onDecline,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.coral,
side: BorderSide(
color: Palette.coral.withValues(alpha: 0.50),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: EdgeInsets.symmetric(vertical: 1.4.h),
),
child: const Text('Decline'),
),
),
Expanded(
child: FilledButton(
onPressed: onAccept,
style: FilledButton.styleFrom(
backgroundColor: Palette.ink,
foregroundColor: Palette.cream,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: EdgeInsets.symmetric(vertical: 1.4.h),
),
child: const Text('Accept'),
),
),
],
),
],
),
);
}
}
String _shortPubkey(String base64Pubkey) {
if (base64Pubkey.length < 12) return base64Pubkey;
return '${base64Pubkey.substring(0, 8)}${base64Pubkey.substring(base64Pubkey.length - 4)}';
}
import 'package:arbiter/proto/shared/client.pb.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
class SdkConnectCallout extends StatelessWidget {
const SdkConnectCallout({
super.key,
required this.pubkey,
required this.clientInfo,
this.onAccept,
this.onDecline,
});
final String pubkey;
final ClientInfo clientInfo;
final VoidCallback? onAccept;
final VoidCallback? onDecline;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final name = clientInfo.hasName() && clientInfo.name.isNotEmpty
? clientInfo.name
: _shortPubkey(pubkey);
final hasDescription =
clientInfo.hasDescription() && clientInfo.description.isNotEmpty;
final hasVersion = clientInfo.hasVersion() && clientInfo.version.isNotEmpty;
final showInfoCard = hasDescription || hasVersion;
return CreamFrame(
padding: EdgeInsets.all(2.4.h),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 1.6.h,
children: [
// if (clientInfo.iconUrl != null)
// CircleAvatar(
// radius: 36,
// backgroundColor: Palette.line,
// backgroundImage: NetworkImage(iconUrl!),
// ),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 0.4.h,
children: [
Text(
name,
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
Text(
'is requesting a connection',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: Palette.ink.withValues(alpha: 0.55),
),
),
],
),
if (showInfoCard)
Container(
width: double.infinity,
decoration: BoxDecoration(
color: Palette.ink.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Palette.line),
),
padding: EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 0.6.h,
children: [
if (hasDescription)
Text(
clientInfo.description,
style: theme.textTheme.bodyMedium?.copyWith(
color: Palette.ink.withValues(alpha: 0.80),
height: 1.5,
),
),
if (hasVersion)
Text(
'v${clientInfo.version}',
style: theme.textTheme.bodySmall?.copyWith(
color: Palette.ink.withValues(alpha: 0.50),
),
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: OutlinedButton(
onPressed: onDecline,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.coral,
side: BorderSide(
color: Palette.coral.withValues(alpha: 0.50),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: EdgeInsets.symmetric(vertical: 1.4.h),
),
child: const Text('Decline'),
),
),
Expanded(
child: FilledButton(
onPressed: onAccept,
style: FilledButton.styleFrom(
backgroundColor: Palette.ink,
foregroundColor: Palette.cream,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: EdgeInsets.symmetric(vertical: 1.4.h),
),
child: const Text('Accept'),
),
),
],
),
],
),
);
}
}
String _shortPubkey(String base64Pubkey) {
if (base64Pubkey.length < 12) return base64Pubkey;
return '${base64Pubkey.substring(0, 8)}${base64Pubkey.substring(base64Pubkey.length - 4)}';
}

View File

@@ -1,121 +1,121 @@
import 'package:arbiter/features/callouts/callout_manager.dart';
import 'package:arbiter/features/callouts/show_callout_list.dart';
import 'package:arbiter/router.gr.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
const breakpoints = MaterialAdaptiveBreakpoints();
final routes = [
const EvmRoute(),
const ClientsRoute(),
const EvmGrantsRoute(),
const AboutRoute(),
];
@RoutePage()
class DashboardRouter extends StatelessWidget {
const DashboardRouter({super.key});
@override
Widget build(BuildContext context) {
final title = Container(
margin: const EdgeInsets.all(16),
child: const Text(
"Arbiter",
style: TextStyle(fontWeight: FontWeight.w800),
),
);
return AutoTabsRouter(
routes: routes,
transitionBuilder: (context, child, animation) =>
FadeTransition(opacity: animation, child: child),
builder: (context, child) {
final tabsRouter = AutoTabsRouter.of(context);
final currentActive = tabsRouter.activeIndex;
return AdaptiveScaffold(
destinations: const [
NavigationDestination(
icon: Icon(Icons.account_balance_wallet_outlined),
selectedIcon: Icon(Icons.account_balance_wallet),
label: "Wallets",
),
NavigationDestination(
icon: Icon(Icons.devices_other_outlined),
selectedIcon: Icon(Icons.devices_other),
label: "Clients",
),
NavigationDestination(
icon: Icon(Icons.policy_outlined),
selectedIcon: Icon(Icons.policy),
label: "Grants",
),
NavigationDestination(
icon: Icon(Icons.info_outline),
selectedIcon: Icon(Icons.info),
label: "About",
),
],
body: (ctx) => child,
onSelectedIndexChange: (index) {
tabsRouter.navigate(routes[index]);
},
leadingExtendedNavRail: title,
leadingUnextendedNavRail: title,
selectedIndex: currentActive,
transitionDuration: const Duration(milliseconds: 800),
internalAnimations: true,
trailingNavRail: const _CalloutBell(),
);
},
);
}
}
class _CalloutBell extends ConsumerWidget {
const _CalloutBell();
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(calloutManagerProvider.select((map) => map.length));
return IconButton(
onPressed: () => showCalloutList(context, ref),
icon: Stack(
clipBehavior: Clip.none,
children: [
Icon(
count > 0 ? Icons.notifications : Icons.notifications_outlined,
color: Palette.ink,
),
if (count > 0)
Positioned(
top: -2,
right: -4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(10),
),
child: Text(
count > 99 ? '99+' : '$count',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.w800,
height: 1.2,
),
),
),
),
],
),
);
}
}
import 'package:arbiter/features/callouts/callout_manager.dart';
import 'package:arbiter/features/callouts/show_callout_list.dart';
import 'package:arbiter/router.gr.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
const breakpoints = MaterialAdaptiveBreakpoints();
final routes = [
const EvmRoute(),
const ClientsRoute(),
const EvmGrantsRoute(),
const AboutRoute(),
];
@RoutePage()
class DashboardRouter extends StatelessWidget {
const DashboardRouter({super.key});
@override
Widget build(BuildContext context) {
final title = Container(
margin: const EdgeInsets.all(16),
child: const Text(
"Arbiter",
style: TextStyle(fontWeight: FontWeight.w800),
),
);
return AutoTabsRouter(
routes: routes,
transitionBuilder: (context, child, animation) =>
FadeTransition(opacity: animation, child: child),
builder: (context, child) {
final tabsRouter = AutoTabsRouter.of(context);
final currentActive = tabsRouter.activeIndex;
return AdaptiveScaffold(
destinations: const [
NavigationDestination(
icon: Icon(Icons.account_balance_wallet_outlined),
selectedIcon: Icon(Icons.account_balance_wallet),
label: "Wallets",
),
NavigationDestination(
icon: Icon(Icons.devices_other_outlined),
selectedIcon: Icon(Icons.devices_other),
label: "Clients",
),
NavigationDestination(
icon: Icon(Icons.policy_outlined),
selectedIcon: Icon(Icons.policy),
label: "Grants",
),
NavigationDestination(
icon: Icon(Icons.info_outline),
selectedIcon: Icon(Icons.info),
label: "About",
),
],
body: (ctx) => child,
onSelectedIndexChange: (index) {
tabsRouter.navigate(routes[index]);
},
leadingExtendedNavRail: title,
leadingUnextendedNavRail: title,
selectedIndex: currentActive,
transitionDuration: const Duration(milliseconds: 800),
internalAnimations: true,
trailingNavRail: const _CalloutBell(),
);
},
);
}
}
class _CalloutBell extends ConsumerWidget {
const _CalloutBell();
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(calloutManagerProvider.select((map) => map.length));
return IconButton(
onPressed: () => showCalloutList(context, ref),
icon: Stack(
clipBehavior: Clip.none,
children: [
Icon(
count > 0 ? Icons.notifications : Icons.notifications_outlined,
color: Palette.ink,
),
if (count > 0)
Positioned(
top: -2,
right: -4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(10),
),
child: Text(
count > 99 ? '99+' : '$count',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.w800,
height: 1.2,
),
),
),
),
],
),
);
}
}

View File

@@ -1,13 +1,13 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:mtcore/markettakers.dart' as mt;
@RoutePage()
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
@override
Widget build(BuildContext context) {
return mt.AboutScreen(decription: "Arbiter is bla bla bla");
}
}
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:mtcore/markettakers.dart' as mt;
@RoutePage()
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
@override
Widget build(BuildContext context) {
return mt.AboutScreen(decription: "Arbiter is bla bla bla");
}
}

View File

@@ -1,15 +1,15 @@
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@RoutePage()
class ClientDetails extends ConsumerWidget {
final ua_sdk.Entry client;
const ClientDetails({super.key, required this.client});
@override
Widget build(BuildContext context, WidgetRef ref) {
throw UnimplementedError();
}
}
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@RoutePage()
class ClientDetails extends ConsumerWidget {
final ua_sdk.Entry client;
const ClientDetails({super.key, required this.client});
@override
Widget build(BuildContext context, WidgetRef ref) {
throw UnimplementedError();
}
}

View File

@@ -1,56 +1,56 @@
import 'package:arbiter/providers/sdk_clients/details.dart';
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_content.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@RoutePage()
class ClientDetailsScreen extends ConsumerWidget {
const ClientDetailsScreen({super.key, @pathParam required this.clientId});
final int clientId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final clientAsync = ref.watch(clientDetailsProvider(clientId));
return Scaffold(
body: SafeArea(
child: clientAsync.when(
data: (client) =>
_ClientDetailsState(clientId: clientId, client: client),
error: (error, _) => ClientDetailsStatePanel(
title: 'Client unavailable',
body: error.toString(),
icon: Icons.sync_problem,
),
loading: () => const ClientDetailsStatePanel(
title: 'Loading client',
body: 'Pulling client details from Arbiter.',
icon: Icons.hourglass_top,
),
),
),
);
}
}
class _ClientDetailsState extends StatelessWidget {
const _ClientDetailsState({required this.clientId, required this.client});
final int clientId;
final ua_sdk.Entry? client;
@override
Widget build(BuildContext context) {
if (client == null) {
return const ClientDetailsStatePanel(
title: 'Client not found',
body: 'The selected SDK client is no longer available.',
icon: Icons.person_off_outlined,
);
}
return ClientDetailsContent(clientId: clientId, client: client!);
}
}
import 'package:arbiter/providers/sdk_clients/details.dart';
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_content.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@RoutePage()
class ClientDetailsScreen extends ConsumerWidget {
const ClientDetailsScreen({super.key, @pathParam required this.clientId});
final int clientId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final clientAsync = ref.watch(clientDetailsProvider(clientId));
return Scaffold(
body: SafeArea(
child: clientAsync.when(
data: (client) =>
_ClientDetailsState(clientId: clientId, client: client),
error: (error, _) => ClientDetailsStatePanel(
title: 'Client unavailable',
body: error.toString(),
icon: Icons.sync_problem,
),
loading: () => const ClientDetailsStatePanel(
title: 'Loading client',
body: 'Pulling client details from Arbiter.',
icon: Icons.hourglass_top,
),
),
),
);
}
}
class _ClientDetailsState extends StatelessWidget {
const _ClientDetailsState({required this.clientId, required this.client});
final int clientId;
final ua_sdk.Entry? client;
@override
Widget build(BuildContext context) {
if (client == null) {
return const ClientDetailsStatePanel(
title: 'Client not found',
body: 'The selected SDK client is no longer available.',
icon: Icons.person_off_outlined,
);
}
return ClientDetailsContent(clientId: clientId, client: client!);
}
}

View File

@@ -1,55 +1,55 @@
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_header.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_summary_card.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_section.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ClientDetailsContent extends ConsumerWidget {
const ClientDetailsContent({
super.key,
required this.clientId,
required this.client,
});
final int clientId;
final ua_sdk.Entry client;
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(clientWalletAccessControllerProvider(clientId));
final notifier = ref.read(
clientWalletAccessControllerProvider(clientId).notifier,
);
final saveMutation = ref.watch(saveClientWalletAccessMutation(clientId));
return ListView(
padding: const EdgeInsets.all(16),
children: [
const ClientDetailsHeader(),
const SizedBox(height: 16),
ClientSummaryCard(client: client),
const SizedBox(height: 16),
WalletAccessSection(
clientId: clientId,
state: state,
accessSelectionAsync: ref.watch(
clientWalletAccessSelectionProvider(clientId),
),
isSavePending: saveMutation is MutationPending,
onSearchChanged: notifier.setSearchQuery,
onToggleWallet: notifier.toggleWallet,
),
const SizedBox(height: 16),
WalletAccessSaveBar(
state: state,
saveMutation: saveMutation,
onDiscard: notifier.discardChanges,
onSave: () => executeSaveClientWalletAccess(ref, clientId: clientId),
),
],
);
}
}
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_header.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_summary_card.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_section.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ClientDetailsContent extends ConsumerWidget {
const ClientDetailsContent({
super.key,
required this.clientId,
required this.client,
});
final int clientId;
final ua_sdk.Entry client;
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(clientWalletAccessControllerProvider(clientId));
final notifier = ref.read(
clientWalletAccessControllerProvider(clientId).notifier,
);
final saveMutation = ref.watch(saveClientWalletAccessMutation(clientId));
return ListView(
padding: const EdgeInsets.all(16),
children: [
const ClientDetailsHeader(),
const SizedBox(height: 16),
ClientSummaryCard(client: client),
const SizedBox(height: 16),
WalletAccessSection(
clientId: clientId,
state: state,
accessSelectionAsync: ref.watch(
clientWalletAccessSelectionProvider(clientId),
),
isSavePending: saveMutation is MutationPending,
onSearchChanged: notifier.setSearchQuery,
onToggleWallet: notifier.toggleWallet,
),
const SizedBox(height: 16),
WalletAccessSaveBar(
state: state,
saveMutation: saveMutation,
onDiscard: notifier.discardChanges,
onSave: () => executeSaveClientWalletAccess(ref, clientId: clientId),
),
],
);
}
}

View File

@@ -1,23 +1,23 @@
import 'package:flutter/material.dart';
class ClientDetailsHeader extends StatelessWidget {
const ClientDetailsHeader({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
BackButton(onPressed: () => Navigator.of(context).maybePop()),
Expanded(
child: Text(
'Client Details',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
],
);
}
}
import 'package:flutter/material.dart';
class ClientDetailsHeader extends StatelessWidget {
const ClientDetailsHeader({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
BackButton(onPressed: () => Navigator.of(context).maybePop()),
Expanded(
child: Text(
'Client Details',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
],
);
}
}

View File

@@ -1,37 +1,37 @@
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
class ClientDetailsStatePanel extends StatelessWidget {
const ClientDetailsStatePanel({
super.key,
required this.title,
required this.body,
required this.icon,
});
final String title;
final String body;
final IconData icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: CreamFrame(
margin: const EdgeInsets.all(24),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Palette.coral),
const SizedBox(height: 12),
Text(title, style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
Text(body, textAlign: TextAlign.center),
],
),
),
);
}
}
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
class ClientDetailsStatePanel extends StatelessWidget {
const ClientDetailsStatePanel({
super.key,
required this.title,
required this.body,
required this.icon,
});
final String title;
final String body;
final IconData icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: CreamFrame(
margin: const EdgeInsets.all(24),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Palette.coral),
const SizedBox(height: 12),
Text(title, style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
Text(body, textAlign: TextAlign.center),
],
),
),
);
}
}

View File

@@ -1,69 +1,69 @@
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
class ClientSummaryCard extends StatelessWidget {
const ClientSummaryCard({super.key, required this.client});
final ua_sdk.Entry client;
@override
Widget build(BuildContext context) {
return CreamFrame(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(client.info.name, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text(client.info.description),
const SizedBox(height: 16),
Wrap(
runSpacing: 8,
spacing: 16,
children: [
_Fact(label: 'Client ID', value: '${client.id}'),
_Fact(label: 'Version', value: client.info.version),
_Fact(label: 'Registered', value: _formatDate(client.createdAt)),
_Fact(label: 'Pubkey', value: _shortPubkey(client.pubkey)),
],
),
],
),
);
}
}
class _Fact extends StatelessWidget {
const _Fact({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.labelMedium),
Text(value.isEmpty ? '' : value, style: theme.textTheme.bodyMedium),
],
);
}
}
String _formatDate(int unixSecs) {
final dt = DateTime.fromMillisecondsSinceEpoch(unixSecs * 1000).toLocal();
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
}
String _shortPubkey(List<int> bytes) {
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
if (hex.length < 12) {
return '0x$hex';
}
return '0x${hex.substring(0, 8)}...${hex.substring(hex.length - 4)}';
}
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
class ClientSummaryCard extends StatelessWidget {
const ClientSummaryCard({super.key, required this.client});
final ua_sdk.Entry client;
@override
Widget build(BuildContext context) {
return CreamFrame(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(client.info.name, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text(client.info.description),
const SizedBox(height: 16),
Wrap(
runSpacing: 8,
spacing: 16,
children: [
_Fact(label: 'Client ID', value: '${client.id}'),
_Fact(label: 'Version', value: client.info.version),
_Fact(label: 'Registered', value: _formatDate(client.createdAt)),
_Fact(label: 'Pubkey', value: _shortPubkey(client.pubkey)),
],
),
],
),
);
}
}
class _Fact extends StatelessWidget {
const _Fact({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.labelMedium),
Text(value.isEmpty ? '' : value, style: theme.textTheme.bodyMedium),
],
);
}
}
String _formatDate(int unixSecs) {
final dt = DateTime.fromMillisecondsSinceEpoch(unixSecs * 1000).toLocal();
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
}
String _shortPubkey(List<int> bytes) {
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
if (hex.length < 12) {
return '0x$hex';
}
return '0x${hex.substring(0, 8)}...${hex.substring(hex.length - 4)}';
}

View File

@@ -1,33 +1,33 @@
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_tile.dart';
import 'package:flutter/material.dart';
class WalletAccessList extends StatelessWidget {
const WalletAccessList({
super.key,
required this.options,
required this.selectedWalletIds,
required this.enabled,
required this.onToggleWallet,
});
final List<ClientWalletOption> options;
final Set<int> selectedWalletIds;
final bool enabled;
final ValueChanged<int> onToggleWallet;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (final option in options)
WalletAccessTile(
option: option,
value: selectedWalletIds.contains(option.walletId),
enabled: enabled,
onChanged: () => onToggleWallet(option.walletId),
),
],
);
}
}
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_tile.dart';
import 'package:flutter/material.dart';
class WalletAccessList extends StatelessWidget {
const WalletAccessList({
super.key,
required this.options,
required this.selectedWalletIds,
required this.enabled,
required this.onToggleWallet,
});
final List<ClientWalletOption> options;
final Set<int> selectedWalletIds;
final bool enabled;
final ValueChanged<int> onToggleWallet;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (final option in options)
WalletAccessTile(
option: option,
value: selectedWalletIds.contains(option.walletId),
enabled: enabled,
onChanged: () => onToggleWallet(option.walletId),
),
],
);
}
}

View File

@@ -1,54 +1,54 @@
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
class WalletAccessSaveBar extends StatelessWidget {
const WalletAccessSaveBar({
super.key,
required this.state,
required this.saveMutation,
required this.onDiscard,
required this.onSave,
});
final ClientWalletAccessState state;
final MutationState<void> saveMutation;
final VoidCallback onDiscard;
final Future<void> Function() onSave;
@override
Widget build(BuildContext context) {
final isPending = saveMutation is MutationPending;
final errorText = switch (saveMutation) {
MutationError(:final error) => error.toString(),
_ => null,
};
return CreamFrame(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (errorText != null) ...[
Text(errorText, style: TextStyle(color: Palette.coral)),
const SizedBox(height: 12),
],
Row(
children: [
TextButton(
onPressed: state.hasChanges && !isPending ? onDiscard : null,
child: const Text('Reset'),
),
const Spacer(),
FilledButton(
onPressed: state.hasChanges && !isPending ? onSave : null,
child: Text(isPending ? 'Saving...' : 'Save changes'),
),
],
),
],
),
);
}
}
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
class WalletAccessSaveBar extends StatelessWidget {
const WalletAccessSaveBar({
super.key,
required this.state,
required this.saveMutation,
required this.onDiscard,
required this.onSave,
});
final ClientWalletAccessState state;
final MutationState<void> saveMutation;
final VoidCallback onDiscard;
final Future<void> Function() onSave;
@override
Widget build(BuildContext context) {
final isPending = saveMutation is MutationPending;
final errorText = switch (saveMutation) {
MutationError(:final error) => error.toString(),
_ => null,
};
return CreamFrame(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (errorText != null) ...[
Text(errorText, style: TextStyle(color: Palette.coral)),
const SizedBox(height: 12),
],
Row(
children: [
TextButton(
onPressed: state.hasChanges && !isPending ? onDiscard : null,
child: const Text('Reset'),
),
const Spacer(),
FilledButton(
onPressed: state.hasChanges && !isPending ? onSave : null,
child: Text(isPending ? 'Saving...' : 'Save changes'),
),
],
),
],
),
);
}
}

View File

@@ -1,24 +1,24 @@
import 'package:flutter/material.dart';
class WalletAccessSearchField extends StatelessWidget {
const WalletAccessSearchField({
super.key,
required this.searchQuery,
required this.onChanged,
});
final String searchQuery;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return TextFormField(
initialValue: searchQuery,
decoration: const InputDecoration(
labelText: 'Search wallets',
prefixIcon: Icon(Icons.search),
),
onChanged: onChanged,
);
}
}
import 'package:flutter/material.dart';
class WalletAccessSearchField extends StatelessWidget {
const WalletAccessSearchField({
super.key,
required this.searchQuery,
required this.onChanged,
});
final String searchQuery;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return TextFormField(
initialValue: searchQuery,
decoration: const InputDecoration(
labelText: 'Search wallets',
prefixIcon: Icon(Icons.search),
),
onChanged: onChanged,
);
}
}

View File

@@ -1,166 +1,166 @@
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_list.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class WalletAccessSection extends ConsumerWidget {
const WalletAccessSection({
super.key,
required this.clientId,
required this.state,
required this.accessSelectionAsync,
required this.isSavePending,
required this.onSearchChanged,
required this.onToggleWallet,
});
final int clientId;
final ClientWalletAccessState state;
final AsyncValue<Set<int>> accessSelectionAsync;
final bool isSavePending;
final ValueChanged<String> onSearchChanged;
final ValueChanged<int> onToggleWallet;
@override
Widget build(BuildContext context, WidgetRef ref) {
final optionsAsync = ref.watch(clientWalletOptionsProvider);
return CreamFrame(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Wallet access', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text('Choose which managed wallets this client can see.'),
const SizedBox(height: 16),
_WalletAccessBody(
clientId: clientId,
state: state,
accessSelectionAsync: accessSelectionAsync,
isSavePending: isSavePending,
optionsAsync: optionsAsync,
onSearchChanged: onSearchChanged,
onToggleWallet: onToggleWallet,
),
],
),
);
}
}
class _WalletAccessBody extends StatelessWidget {
const _WalletAccessBody({
required this.clientId,
required this.state,
required this.accessSelectionAsync,
required this.isSavePending,
required this.optionsAsync,
required this.onSearchChanged,
required this.onToggleWallet,
});
final int clientId;
final ClientWalletAccessState state;
final AsyncValue<Set<int>> accessSelectionAsync;
final bool isSavePending;
final AsyncValue<List<ClientWalletOption>> optionsAsync;
final ValueChanged<String> onSearchChanged;
final ValueChanged<int> onToggleWallet;
@override
Widget build(BuildContext context) {
final selectionState = accessSelectionAsync;
if (selectionState.isLoading) {
return const ClientDetailsStatePanel(
title: 'Loading wallet access',
body: 'Pulling the current wallet permissions for this client.',
icon: Icons.hourglass_top,
);
}
if (selectionState.hasError) {
return ClientDetailsStatePanel(
title: 'Wallet access unavailable',
body: selectionState.error.toString(),
icon: Icons.lock_outline,
);
}
return optionsAsync.when(
data: (options) => _WalletAccessLoaded(
state: state,
isSavePending: isSavePending,
options: options,
onSearchChanged: onSearchChanged,
onToggleWallet: onToggleWallet,
),
error: (error, _) => ClientDetailsStatePanel(
title: 'Wallet list unavailable',
body: error.toString(),
icon: Icons.sync_problem,
),
loading: () => const ClientDetailsStatePanel(
title: 'Loading wallets',
body: 'Pulling the managed wallet inventory.',
icon: Icons.hourglass_top,
),
);
}
}
class _WalletAccessLoaded extends StatelessWidget {
const _WalletAccessLoaded({
required this.state,
required this.isSavePending,
required this.options,
required this.onSearchChanged,
required this.onToggleWallet,
});
final ClientWalletAccessState state;
final bool isSavePending;
final List<ClientWalletOption> options;
final ValueChanged<String> onSearchChanged;
final ValueChanged<int> onToggleWallet;
@override
Widget build(BuildContext context) {
if (options.isEmpty) {
return const ClientDetailsStatePanel(
title: 'No wallets yet',
body: 'Create a managed wallet before assigning client access.',
icon: Icons.account_balance_wallet_outlined,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
WalletAccessSearchField(
searchQuery: state.searchQuery,
onChanged: onSearchChanged,
),
const SizedBox(height: 16),
WalletAccessList(
options: _filterOptions(options, state.searchQuery),
selectedWalletIds: state.selectedWalletIds,
enabled: !isSavePending,
onToggleWallet: onToggleWallet,
),
],
);
}
}
List<ClientWalletOption> _filterOptions(
List<ClientWalletOption> options,
String query,
) {
if (query.isEmpty) {
return options;
}
final normalized = query.toLowerCase();
return options
.where((option) => option.address.toLowerCase().contains(normalized))
.toList(growable: false);
}
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_list.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class WalletAccessSection extends ConsumerWidget {
const WalletAccessSection({
super.key,
required this.clientId,
required this.state,
required this.accessSelectionAsync,
required this.isSavePending,
required this.onSearchChanged,
required this.onToggleWallet,
});
final int clientId;
final ClientWalletAccessState state;
final AsyncValue<Set<int>> accessSelectionAsync;
final bool isSavePending;
final ValueChanged<String> onSearchChanged;
final ValueChanged<int> onToggleWallet;
@override
Widget build(BuildContext context, WidgetRef ref) {
final optionsAsync = ref.watch(clientWalletOptionsProvider);
return CreamFrame(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Wallet access', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text('Choose which managed wallets this client can see.'),
const SizedBox(height: 16),
_WalletAccessBody(
clientId: clientId,
state: state,
accessSelectionAsync: accessSelectionAsync,
isSavePending: isSavePending,
optionsAsync: optionsAsync,
onSearchChanged: onSearchChanged,
onToggleWallet: onToggleWallet,
),
],
),
);
}
}
class _WalletAccessBody extends StatelessWidget {
const _WalletAccessBody({
required this.clientId,
required this.state,
required this.accessSelectionAsync,
required this.isSavePending,
required this.optionsAsync,
required this.onSearchChanged,
required this.onToggleWallet,
});
final int clientId;
final ClientWalletAccessState state;
final AsyncValue<Set<int>> accessSelectionAsync;
final bool isSavePending;
final AsyncValue<List<ClientWalletOption>> optionsAsync;
final ValueChanged<String> onSearchChanged;
final ValueChanged<int> onToggleWallet;
@override
Widget build(BuildContext context) {
final selectionState = accessSelectionAsync;
if (selectionState.isLoading) {
return const ClientDetailsStatePanel(
title: 'Loading wallet access',
body: 'Pulling the current wallet permissions for this client.',
icon: Icons.hourglass_top,
);
}
if (selectionState.hasError) {
return ClientDetailsStatePanel(
title: 'Wallet access unavailable',
body: selectionState.error.toString(),
icon: Icons.lock_outline,
);
}
return optionsAsync.when(
data: (options) => _WalletAccessLoaded(
state: state,
isSavePending: isSavePending,
options: options,
onSearchChanged: onSearchChanged,
onToggleWallet: onToggleWallet,
),
error: (error, _) => ClientDetailsStatePanel(
title: 'Wallet list unavailable',
body: error.toString(),
icon: Icons.sync_problem,
),
loading: () => const ClientDetailsStatePanel(
title: 'Loading wallets',
body: 'Pulling the managed wallet inventory.',
icon: Icons.hourglass_top,
),
);
}
}
class _WalletAccessLoaded extends StatelessWidget {
const _WalletAccessLoaded({
required this.state,
required this.isSavePending,
required this.options,
required this.onSearchChanged,
required this.onToggleWallet,
});
final ClientWalletAccessState state;
final bool isSavePending;
final List<ClientWalletOption> options;
final ValueChanged<String> onSearchChanged;
final ValueChanged<int> onToggleWallet;
@override
Widget build(BuildContext context) {
if (options.isEmpty) {
return const ClientDetailsStatePanel(
title: 'No wallets yet',
body: 'Create a managed wallet before assigning client access.',
icon: Icons.account_balance_wallet_outlined,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
WalletAccessSearchField(
searchQuery: state.searchQuery,
onChanged: onSearchChanged,
),
const SizedBox(height: 16),
WalletAccessList(
options: _filterOptions(options, state.searchQuery),
selectedWalletIds: state.selectedWalletIds,
enabled: !isSavePending,
onToggleWallet: onToggleWallet,
),
],
);
}
}
List<ClientWalletOption> _filterOptions(
List<ClientWalletOption> options,
String query,
) {
if (query.isEmpty) {
return options;
}
final normalized = query.toLowerCase();
return options
.where((option) => option.address.toLowerCase().contains(normalized))
.toList(growable: false);
}

View File

@@ -1,28 +1,28 @@
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:flutter/material.dart';
class WalletAccessTile extends StatelessWidget {
const WalletAccessTile({
super.key,
required this.option,
required this.value,
required this.enabled,
required this.onChanged,
});
final ClientWalletOption option;
final bool value;
final bool enabled;
final VoidCallback onChanged;
@override
Widget build(BuildContext context) {
return CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: value,
onChanged: enabled ? (_) => onChanged() : null,
title: Text('Wallet ${option.walletId}'),
subtitle: Text(option.address),
);
}
}
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:flutter/material.dart';
class WalletAccessTile extends StatelessWidget {
const WalletAccessTile({
super.key,
required this.option,
required this.value,
required this.enabled,
required this.onChanged,
});
final ClientWalletOption option;
final bool value;
final bool enabled;
final VoidCallback onChanged;
@override
Widget build(BuildContext context) {
return CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: value,
onChanged: enabled ? (_) => onChanged() : null,
title: Text('Wallet ${option.walletId}'),
subtitle: Text(option.address),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,100 +1,100 @@
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/screens/dashboard/evm/wallets/header.dart';
import 'package:arbiter/screens/dashboard/evm/wallets/table.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/widgets/page_header.dart';
import 'package:arbiter/widgets/state_panel.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
@RoutePage()
class EvmScreen extends HookConsumerWidget {
const EvmScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final evm = ref.watch(evmProvider);
final wallets = evm.asData?.value;
final loadedWallets = wallets ?? const <WalletEntry>[];
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 (e) {
showMessage('Failed to refresh wallets: ${_formatError(e)}');
}
}
final content = switch (evm) {
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,
),
AsyncData(:final value) when value == null => 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(),
),
_ => 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: [
PageHeader(
title: 'EVM Wallet Vault',
isBusy: evm.isLoading,
actions: [
const CreateWalletButton(),
SizedBox(width: 1.w),
const RefreshWalletButton(),
],
),
SizedBox(height: 1.8.h),
content,
],
),
),
),
);
}
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/screens/dashboard/evm/wallets/header.dart';
import 'package:arbiter/screens/dashboard/evm/wallets/table.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/widgets/page_header.dart';
import 'package:arbiter/widgets/state_panel.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
@RoutePage()
class EvmScreen extends HookConsumerWidget {
const EvmScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final evm = ref.watch(evmProvider);
final wallets = evm.asData?.value;
final loadedWallets = wallets ?? const <WalletEntry>[];
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 (e) {
showMessage('Failed to refresh wallets: ${_formatError(e)}');
}
}
final content = switch (evm) {
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,
),
AsyncData(:final value) when value == null => 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(),
),
_ => 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: [
PageHeader(
title: 'EVM Wallet Vault',
isBusy: evm.isLoading,
actions: [
const CreateWalletButton(),
SizedBox(width: 1.w),
const RefreshWalletButton(),
],
),
SizedBox(height: 1.8.h),
content,
],
),
),
),
);
}
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}

View File

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

View File

@@ -1,40 +1,40 @@
// lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
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 <ua_sdk.Entry>[];
return FormBuilderDropdown<int>(
name: 'clientId',
decoration: const InputDecoration(
labelText: 'Client',
border: OutlineInputBorder(),
),
items: [
for (final c in clients)
DropdownMenuItem(
value: c.id,
child: Text(c.info.name.isEmpty ? 'Client #${c.id}' : c.info.name),
),
],
onChanged: clients.isEmpty
? null
: (value) {
ref.read(grantCreationProvider.notifier).setClientId(value);
FormBuilder.of(
context,
)?.fields['walletAccessId']?.didChange(null);
},
);
}
}
// lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
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 <ua_sdk.Entry>[];
return FormBuilderDropdown<int>(
name: 'clientId',
decoration: const InputDecoration(
labelText: 'Client',
border: OutlineInputBorder(),
),
items: [
for (final c in clients)
DropdownMenuItem(
value: c.id,
child: Text(c.info.name.isEmpty ? 'Client #${c.id}' : c.info.name),
),
],
onChanged: clients.isEmpty
? null
: (value) {
ref.read(grantCreationProvider.notifier).setClientId(value);
FormBuilder.of(
context,
)?.fields['walletAccessId']?.didChange(null);
},
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,57 +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/sdk_client.pb.dart' as ua_sdk;
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 <ua_sdk.WalletAccessEntry>[];
final wallets =
ref.watch(evmProvider).asData?.value ?? const <WalletEntry>[];
final walletById = <int, WalletEntry>{for (final w in wallets) w.id: w};
final accesses = state.selectedClientId == null
? const <ua_sdk.WalletAccessEntry>[]
: allAccesses
.where((a) => a.access.sdkClientId == state.selectedClientId)
.toList();
return FormBuilderDropdown<int>(
name: 'walletAccessId',
enabled: accesses.isNotEmpty,
decoration: InputDecoration(
labelText: 'Wallet access',
helperText: state.selectedClientId == null
? 'Select a client first'
: accesses.isEmpty
? 'No wallet accesses for this client'
: null,
border: const OutlineInputBorder(),
),
items: [
for (final a in accesses)
DropdownMenuItem(
value: a.id,
child: Text(() {
final wallet = walletById[a.access.walletId];
return wallet != null
? shortAddress(wallet.address)
: 'Wallet #${a.access.walletId}';
}()),
),
],
);
}
}
// 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/sdk_client.pb.dart' as ua_sdk;
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 <ua_sdk.WalletAccessEntry>[];
final wallets =
ref.watch(evmProvider).asData?.value ?? const <WalletEntry>[];
final walletById = <int, WalletEntry>{for (final w in wallets) w.id: w};
final accesses = state.selectedClientId == null
? const <ua_sdk.WalletAccessEntry>[]
: allAccesses
.where((a) => a.access.sdkClientId == state.selectedClientId)
.toList();
return FormBuilderDropdown<int>(
name: 'walletAccessId',
enabled: accesses.isNotEmpty,
decoration: InputDecoration(
labelText: 'Wallet access',
helperText: state.selectedClientId == null
? 'Select a client first'
: accesses.isEmpty
? 'No wallet accesses for this client'
: null,
border: const OutlineInputBorder(),
),
items: [
for (final a in accesses)
DropdownMenuItem(
value: a.id,
child: Text(() {
final wallet = walletById[a.access.walletId];
return wallet != null
? shortAddress(wallet.address)
: 'Wallet #${a.access.walletId}';
}()),
),
],
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,351 +1,351 @@
// 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/fields/chain_id_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/shared_grant_fields.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:auto_route/auto_route.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:sizer/sizer.dart';
const _etherHandler = EtherTransferGrantHandler();
const _tokenHandler = TokenTransferGrantHandler();
GrantFormHandler _handlerFor(SpecificGrant_Grant type) => switch (type) {
SpecificGrant_Grant.etherTransfer => _etherHandler,
SpecificGrant_Grant.tokenTransfer => _tokenHandler,
_ => throw ArgumentError('Unsupported grant type: $type'),
};
@RoutePage()
class CreateEvmGrantScreen extends HookConsumerWidget {
const CreateEvmGrantScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormBuilderState>());
final createMutation = ref.watch(createEvmGrantMutation);
final state = ref.watch(grantCreationProvider);
final notifier = ref.read(grantCreationProvider.notifier);
final handler = _handlerFor(state.grantType);
Future<void> submit() async {
if (!(formKey.currentState?.saveAndValidate() ?? false)) return;
final formValues = formKey.currentState!.value;
final accessId = formValues['walletAccessId'] as int?;
if (accessId == null) {
_showSnackBar(context, 'Select a client and wallet access.');
return;
}
try {
final specific = handler.buildSpecificGrant(formValues, ref);
final sharedSettings = SharedSettings(
walletAccessId: accessId,
chainId: Int64.parseInt(
(formValues['chainId'] as String? ?? '').trim(),
),
);
final validFrom = formValues['validFrom'] as DateTime?;
final validUntil = formValues['validUntil'] as DateTime?;
if (validFrom != null)
sharedSettings.validFrom = toTimestamp(validFrom);
if (validUntil != null) {
sharedSettings.validUntil = toTimestamp(validUntil);
}
final gasBytes = optionalBigIntBytes(
formValues['maxGasFeePerGas'] as String? ?? '',
);
if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes;
final priorityBytes = optionalBigIntBytes(
formValues['maxPriorityFeePerGas'] as String? ?? '',
);
if (priorityBytes != null) {
sharedSettings.maxPriorityFeePerGas = priorityBytes;
}
final rateLimit = buildRateLimit(
formValues['txCount'] as String? ?? '',
formValues['txWindow'] as String? ?? '',
);
if (rateLimit != null) sharedSettings.rateLimit = rateLimit;
await executeCreateEvmGrant(
ref,
sharedSettings: sharedSettings,
specific: specific,
);
if (!context.mounted) return;
context.router.pop();
} catch (error) {
if (!context.mounted) return;
_showSnackBar(context, _formatError(error));
}
}
return Scaffold(
appBar: AppBar(title: const Text('Create EVM Grant')),
body: SafeArea(
child: FormBuilder(
key: formKey,
child: ListView(
padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h),
children: [
const _IntroCard(),
SizedBox(height: 1.8.h),
const _Section(
title: 'Authorization',
tooltip:
'Select which SDK client receives this grant and '
'which of its wallet accesses it applies to.',
child: AuthorizationFields(),
),
SizedBox(height: 1.8.h),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(
child: _Section(
title: 'Chain',
tooltip:
'Restrict this grant to a specific EVM chain ID. '
'Leave empty to allow any chain.',
optional: true,
child: ChainIdField(),
),
),
SizedBox(width: 1.8.w),
const Expanded(
child: _Section(
title: 'Timing',
tooltip:
'Set an optional validity window. '
'Signing requests outside this period will be rejected.',
optional: true,
child: ValidityWindowField(),
),
),
],
),
),
SizedBox(height: 1.8.h),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(
child: _Section(
title: 'Gas limits',
tooltip:
'Cap the gas fees this grant may authorize. '
'Transactions exceeding these values will be rejected.',
optional: true,
child: GasFeeOptionsField(),
),
),
SizedBox(width: 1.8.w),
const Expanded(
child: _Section(
title: 'Transaction limits',
tooltip:
'Limit how many transactions can be signed '
'within a rolling time window.',
optional: true,
child: TransactionRateLimitField(),
),
),
],
),
),
SizedBox(height: 1.8.h),
_GrantTypeSelector(
value: state.grantType,
onChanged: notifier.setGrantType,
),
SizedBox(height: 1.8.h),
_Section(
title: 'Grant-specific options',
tooltip:
'Rules specific to the selected transfer type. '
'Switch between Ether and token above to change these fields.',
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.tooltip,
required this.child,
this.optional = false,
});
final String title;
final String tooltip;
final Widget child;
final bool optional;
@override
Widget build(BuildContext context) {
final subtleColor = Theme.of(context).colorScheme.outline;
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: [
Row(
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
),
SizedBox(width: 0.4.w),
Tooltip(
message: tooltip,
child: Icon(
Icons.info_outline_rounded,
size: 16,
color: subtleColor,
),
),
if (optional) ...[
SizedBox(width: 0.6.w),
Text(
'(optional)',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: subtleColor),
),
],
],
),
SizedBox(height: 1.4.h),
child,
],
),
);
}
}
class _GrantTypeSelector extends StatelessWidget {
const _GrantTypeSelector({required this.value, required this.onChanged});
final SpecificGrant_Grant value;
final ValueChanged<SpecificGrant_Grant> onChanged;
@override
Widget build(BuildContext context) {
return SegmentedButton<SpecificGrant_Grant>(
segments: const [
ButtonSegment(
value: SpecificGrant_Grant.etherTransfer,
label: Text('Ether'),
icon: Icon(Icons.bolt_rounded),
),
ButtonSegment(
value: SpecificGrant_Grant.tokenTransfer,
label: Text('Token'),
icon: Icon(Icons.token_rounded),
),
],
selected: {value},
onSelectionChanged: (selection) => onChanged(selection.first),
);
}
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
void _showSnackBar(BuildContext context, String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
String _formatError(Object error) {
final text = error.toString();
return text.startsWith('Exception: ')
? text.substring('Exception: '.length)
: text;
}
// 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/fields/chain_id_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/shared_grant_fields.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:auto_route/auto_route.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:sizer/sizer.dart';
const _etherHandler = EtherTransferGrantHandler();
const _tokenHandler = TokenTransferGrantHandler();
GrantFormHandler _handlerFor(SpecificGrant_Grant type) => switch (type) {
SpecificGrant_Grant.etherTransfer => _etherHandler,
SpecificGrant_Grant.tokenTransfer => _tokenHandler,
_ => throw ArgumentError('Unsupported grant type: $type'),
};
@RoutePage()
class CreateEvmGrantScreen extends HookConsumerWidget {
const CreateEvmGrantScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormBuilderState>());
final createMutation = ref.watch(createEvmGrantMutation);
final state = ref.watch(grantCreationProvider);
final notifier = ref.read(grantCreationProvider.notifier);
final handler = _handlerFor(state.grantType);
Future<void> submit() async {
if (!(formKey.currentState?.saveAndValidate() ?? false)) return;
final formValues = formKey.currentState!.value;
final accessId = formValues['walletAccessId'] as int?;
if (accessId == null) {
_showSnackBar(context, 'Select a client and wallet access.');
return;
}
try {
final specific = handler.buildSpecificGrant(formValues, ref);
final sharedSettings = SharedSettings(
walletAccessId: accessId,
chainId: Int64.parseInt(
(formValues['chainId'] as String? ?? '').trim(),
),
);
final validFrom = formValues['validFrom'] as DateTime?;
final validUntil = formValues['validUntil'] as DateTime?;
if (validFrom != null)
sharedSettings.validFrom = toTimestamp(validFrom);
if (validUntil != null) {
sharedSettings.validUntil = toTimestamp(validUntil);
}
final gasBytes = optionalBigIntBytes(
formValues['maxGasFeePerGas'] as String? ?? '',
);
if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes;
final priorityBytes = optionalBigIntBytes(
formValues['maxPriorityFeePerGas'] as String? ?? '',
);
if (priorityBytes != null) {
sharedSettings.maxPriorityFeePerGas = priorityBytes;
}
final rateLimit = buildRateLimit(
formValues['txCount'] as String? ?? '',
formValues['txWindow'] as String? ?? '',
);
if (rateLimit != null) sharedSettings.rateLimit = rateLimit;
await executeCreateEvmGrant(
ref,
sharedSettings: sharedSettings,
specific: specific,
);
if (!context.mounted) return;
context.router.pop();
} catch (error) {
if (!context.mounted) return;
_showSnackBar(context, _formatError(error));
}
}
return Scaffold(
appBar: AppBar(title: const Text('Create EVM Grant')),
body: SafeArea(
child: FormBuilder(
key: formKey,
child: ListView(
padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h),
children: [
const _IntroCard(),
SizedBox(height: 1.8.h),
const _Section(
title: 'Authorization',
tooltip:
'Select which SDK client receives this grant and '
'which of its wallet accesses it applies to.',
child: AuthorizationFields(),
),
SizedBox(height: 1.8.h),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(
child: _Section(
title: 'Chain',
tooltip:
'Restrict this grant to a specific EVM chain ID. '
'Leave empty to allow any chain.',
optional: true,
child: ChainIdField(),
),
),
SizedBox(width: 1.8.w),
const Expanded(
child: _Section(
title: 'Timing',
tooltip:
'Set an optional validity window. '
'Signing requests outside this period will be rejected.',
optional: true,
child: ValidityWindowField(),
),
),
],
),
),
SizedBox(height: 1.8.h),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(
child: _Section(
title: 'Gas limits',
tooltip:
'Cap the gas fees this grant may authorize. '
'Transactions exceeding these values will be rejected.',
optional: true,
child: GasFeeOptionsField(),
),
),
SizedBox(width: 1.8.w),
const Expanded(
child: _Section(
title: 'Transaction limits',
tooltip:
'Limit how many transactions can be signed '
'within a rolling time window.',
optional: true,
child: TransactionRateLimitField(),
),
),
],
),
),
SizedBox(height: 1.8.h),
_GrantTypeSelector(
value: state.grantType,
onChanged: notifier.setGrantType,
),
SizedBox(height: 1.8.h),
_Section(
title: 'Grant-specific options',
tooltip:
'Rules specific to the selected transfer type. '
'Switch between Ether and token above to change these fields.',
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.tooltip,
required this.child,
this.optional = false,
});
final String title;
final String tooltip;
final Widget child;
final bool optional;
@override
Widget build(BuildContext context) {
final subtleColor = Theme.of(context).colorScheme.outline;
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: [
Row(
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
),
SizedBox(width: 0.4.w),
Tooltip(
message: tooltip,
child: Icon(
Icons.info_outline_rounded,
size: 16,
color: subtleColor,
),
),
if (optional) ...[
SizedBox(width: 0.6.w),
Text(
'(optional)',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: subtleColor),
),
],
],
),
SizedBox(height: 1.4.h),
child,
],
),
);
}
}
class _GrantTypeSelector extends StatelessWidget {
const _GrantTypeSelector({required this.value, required this.onChanged});
final SpecificGrant_Grant value;
final ValueChanged<SpecificGrant_Grant> onChanged;
@override
Widget build(BuildContext context) {
return SegmentedButton<SpecificGrant_Grant>(
segments: const [
ButtonSegment(
value: SpecificGrant_Grant.etherTransfer,
label: Text('Ether'),
icon: Icon(Icons.bolt_rounded),
),
ButtonSegment(
value: SpecificGrant_Grant.tokenTransfer,
label: Text('Token'),
icon: Icon(Icons.token_rounded),
),
],
selected: {value},
onSelectionChanged: (selection) => onChanged(selection.first),
);
}
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
void _showSnackBar(BuildContext context, String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
String _formatError(Object error) {
final text = error.toString();
return text.startsWith('Exception: ')
? text.substring('Exception: '.length)
: text;
}

View File

@@ -1,21 +1,21 @@
// lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/client_picker_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';
class AuthorizationFields extends StatelessWidget {
const AuthorizationFields({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const ClientPickerField(),
SizedBox(height: 1.6.h),
const WalletAccessPickerField(),
],
);
}
}
// lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/client_picker_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';
class AuthorizationFields extends StatelessWidget {
const AuthorizationFields({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const ClientPickerField(),
SizedBox(height: 1.6.h),
const WalletAccessPickerField(),
],
);
}
}

View File

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

View File

@@ -1,157 +1,157 @@
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/router.gr.dart';
import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/page_header.dart';
import 'package:arbiter/widgets/state_panel.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
class _GrantList extends StatelessWidget {
const _GrantList({required this.grants});
final List<GrantEntry> grants;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
for (var i = 0; i < grants.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == grants.length - 1 ? 0 : 1.8.h,
),
child: GrantCard(grant: grants[i]),
),
],
),
);
}
}
@RoutePage()
class EvmGrantsScreen extends ConsumerWidget {
const EvmGrantsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Screen watches only the grant list for top-level state decisions
final grantsAsync = ref.watch(evmGrantsProvider);
Future<void> refresh() async {
ref.invalidate(walletAccessListProvider);
ref.invalidate(evmGrantsProvider);
}
void showMessage(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> safeRefresh() async {
try {
await refresh();
} catch (e) {
showMessage(_formatError(e));
}
}
final grantsState = grantsAsync.asData?.value;
final grants = grantsState?.grants;
final content = switch (grantsAsync) {
AsyncLoading() when grantsState == null => const StatePanel(
icon: Icons.hourglass_top,
title: 'Loading grants',
body: 'Pulling grant registry from Arbiter.',
busy: true,
),
AsyncError(:final error) => StatePanel(
icon: Icons.sync_problem,
title: 'Grant registry unavailable',
body: _formatError(error),
actionLabel: 'Retry',
onAction: safeRefresh,
),
AsyncData(:final value) when value == null => StatePanel(
icon: Icons.portable_wifi_off,
title: 'No active server connection',
body: 'Reconnect to Arbiter to list EVM grants.',
actionLabel: 'Refresh',
onAction: safeRefresh,
),
_ when grants != null && grants.isEmpty => StatePanel(
icon: Icons.policy_outlined,
title: 'No grants yet',
body: 'Create a grant to allow SDK clients to sign transactions.',
actionLabel: 'Create grant',
onAction: () async => context.router.push(const CreateEvmGrantRoute()),
),
_ => _GrantList(grants: grants ?? const []),
};
return Scaffold(
body: SafeArea(
child: RefreshIndicator.adaptive(
color: Palette.ink,
backgroundColor: Colors.white,
onRefresh: safeRefresh,
child: ListView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
children: [
PageHeader(
title: 'EVM Grants',
isBusy: grantsAsync.isLoading,
actions: [
FilledButton.icon(
onPressed: () =>
context.router.push(const CreateEvmGrantRoute()),
icon: const Icon(Icons.add_rounded),
label: const Text('Create grant'),
),
SizedBox(width: 1.w),
OutlinedButton.icon(
onPressed: safeRefresh,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.ink,
side: BorderSide(color: Palette.line),
padding: EdgeInsets.symmetric(
horizontal: 1.4.w,
vertical: 1.2.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Refresh'),
),
],
),
SizedBox(height: 1.8.h),
content,
],
),
),
),
);
}
}
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/router.gr.dart';
import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/page_header.dart';
import 'package:arbiter/widgets/state_panel.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
class _GrantList extends StatelessWidget {
const _GrantList({required this.grants});
final List<GrantEntry> grants;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
for (var i = 0; i < grants.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == grants.length - 1 ? 0 : 1.8.h,
),
child: GrantCard(grant: grants[i]),
),
],
),
);
}
}
@RoutePage()
class EvmGrantsScreen extends ConsumerWidget {
const EvmGrantsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Screen watches only the grant list for top-level state decisions
final grantsAsync = ref.watch(evmGrantsProvider);
Future<void> refresh() async {
ref.invalidate(walletAccessListProvider);
ref.invalidate(evmGrantsProvider);
}
void showMessage(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> safeRefresh() async {
try {
await refresh();
} catch (e) {
showMessage(_formatError(e));
}
}
final grantsState = grantsAsync.asData?.value;
final grants = grantsState?.grants;
final content = switch (grantsAsync) {
AsyncLoading() when grantsState == null => const StatePanel(
icon: Icons.hourglass_top,
title: 'Loading grants',
body: 'Pulling grant registry from Arbiter.',
busy: true,
),
AsyncError(:final error) => StatePanel(
icon: Icons.sync_problem,
title: 'Grant registry unavailable',
body: _formatError(error),
actionLabel: 'Retry',
onAction: safeRefresh,
),
AsyncData(:final value) when value == null => StatePanel(
icon: Icons.portable_wifi_off,
title: 'No active server connection',
body: 'Reconnect to Arbiter to list EVM grants.',
actionLabel: 'Refresh',
onAction: safeRefresh,
),
_ when grants != null && grants.isEmpty => StatePanel(
icon: Icons.policy_outlined,
title: 'No grants yet',
body: 'Create a grant to allow SDK clients to sign transactions.',
actionLabel: 'Create grant',
onAction: () async => context.router.push(const CreateEvmGrantRoute()),
),
_ => _GrantList(grants: grants ?? const []),
};
return Scaffold(
body: SafeArea(
child: RefreshIndicator.adaptive(
color: Palette.ink,
backgroundColor: Colors.white,
onRefresh: safeRefresh,
child: ListView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
children: [
PageHeader(
title: 'EVM Grants',
isBusy: grantsAsync.isLoading,
actions: [
FilledButton.icon(
onPressed: () =>
context.router.push(const CreateEvmGrantRoute()),
icon: const Icon(Icons.add_rounded),
label: const Text('Create grant'),
),
SizedBox(width: 1.w),
OutlinedButton.icon(
onPressed: safeRefresh,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.ink,
side: BorderSide(color: Palette.line),
padding: EdgeInsets.symmetric(
horizontal: 1.4.w,
vertical: 1.2.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Refresh'),
),
],
),
SizedBox(height: 1.8.h),
content,
],
),
),
),
);
}
}

View File

@@ -1,219 +1,219 @@
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _shortAddress(List<int> bytes) {
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
class GrantCard extends ConsumerWidget {
const GrantCard({super.key, required this.grant});
final GrantEntry grant;
@override
Widget build(BuildContext context, WidgetRef ref) {
final walletAccesses =
ref.watch(walletAccessListProvider).asData?.value ?? const [];
final wallets = ref.watch(evmProvider).asData?.value ?? const [];
final clients = ref.watch(sdkClientsProvider).asData?.value ?? const [];
final revoking = ref.watch(revokeEvmGrantMutation) is MutationPending;
final isEther =
grant.specific.whichGrant() == SpecificGrant_Grant.etherTransfer;
final accent = isEther ? Palette.coral : Palette.token;
final typeLabel = isEther ? 'Ether' : 'Token';
final theme = Theme.of(context);
final muted = Palette.ink.withValues(alpha: 0.62);
final accessById = <int, ua_sdk.WalletAccessEntry>{
for (final a in walletAccesses) a.id: a,
};
final walletById = <int, WalletEntry>{for (final w in wallets) w.id: w};
final clientNameById = <int, String>{
for (final c in clients) c.id: c.info.name,
};
final accessId = grant.shared.walletAccessId;
final access = accessById[accessId];
final wallet = access != null ? walletById[access.access.walletId] : null;
final walletLabel = wallet != null
? _shortAddress(wallet.address)
: 'Access #$accessId';
final clientLabel = () {
if (access == null) return '';
final name = clientNameById[access.access.sdkClientId] ?? '';
return name.isEmpty ? 'Client #${access.access.sdkClientId}' : name;
}();
void showError(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> revoke() async {
try {
await executeRevokeEvmGrant(ref, grantId: grant.id);
} catch (e) {
showError(_formatError(e));
}
}
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 0.8.w,
decoration: BoxDecoration(
color: accent,
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(24),
),
),
),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 1.6.w,
vertical: 1.4.h,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
typeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w800,
),
),
),
SizedBox(width: 1.w),
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: Palette.ink.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Chain ${grant.shared.chainId}',
style: theme.textTheme.labelSmall?.copyWith(
color: muted,
fontWeight: FontWeight.w700,
),
),
),
const Spacer(),
if (revoking)
SizedBox(
width: 1.8.h,
height: 1.8.h,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Palette.coral,
),
)
else
OutlinedButton.icon(
onPressed: revoke,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.coral,
side: BorderSide(
color: Palette.coral.withValues(alpha: 0.4),
),
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.6.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: const Icon(Icons.block_rounded, size: 16),
label: const Text('Revoke'),
),
],
),
SizedBox(height: 0.8.h),
Row(
children: [
Text(
walletLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: Palette.ink,
fontFamily: 'monospace',
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 0.8.w),
child: Text(
'·',
style: theme.textTheme.bodySmall?.copyWith(
color: muted,
),
),
),
Expanded(
child: Text(
clientLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: muted,
),
),
),
],
),
],
),
),
),
],
),
),
);
}
}
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _shortAddress(List<int> bytes) {
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
class GrantCard extends ConsumerWidget {
const GrantCard({super.key, required this.grant});
final GrantEntry grant;
@override
Widget build(BuildContext context, WidgetRef ref) {
final walletAccesses =
ref.watch(walletAccessListProvider).asData?.value ?? const [];
final wallets = ref.watch(evmProvider).asData?.value ?? const [];
final clients = ref.watch(sdkClientsProvider).asData?.value ?? const [];
final revoking = ref.watch(revokeEvmGrantMutation) is MutationPending;
final isEther =
grant.specific.whichGrant() == SpecificGrant_Grant.etherTransfer;
final accent = isEther ? Palette.coral : Palette.token;
final typeLabel = isEther ? 'Ether' : 'Token';
final theme = Theme.of(context);
final muted = Palette.ink.withValues(alpha: 0.62);
final accessById = <int, ua_sdk.WalletAccessEntry>{
for (final a in walletAccesses) a.id: a,
};
final walletById = <int, WalletEntry>{for (final w in wallets) w.id: w};
final clientNameById = <int, String>{
for (final c in clients) c.id: c.info.name,
};
final accessId = grant.shared.walletAccessId;
final access = accessById[accessId];
final wallet = access != null ? walletById[access.access.walletId] : null;
final walletLabel = wallet != null
? _shortAddress(wallet.address)
: 'Access #$accessId';
final clientLabel = () {
if (access == null) return '';
final name = clientNameById[access.access.sdkClientId] ?? '';
return name.isEmpty ? 'Client #${access.access.sdkClientId}' : name;
}();
void showError(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> revoke() async {
try {
await executeRevokeEvmGrant(ref, grantId: grant.id);
} catch (e) {
showError(_formatError(e));
}
}
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 0.8.w,
decoration: BoxDecoration(
color: accent,
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(24),
),
),
),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 1.6.w,
vertical: 1.4.h,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
typeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w800,
),
),
),
SizedBox(width: 1.w),
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: Palette.ink.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Chain ${grant.shared.chainId}',
style: theme.textTheme.labelSmall?.copyWith(
color: muted,
fontWeight: FontWeight.w700,
),
),
),
const Spacer(),
if (revoking)
SizedBox(
width: 1.8.h,
height: 1.8.h,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Palette.coral,
),
)
else
OutlinedButton.icon(
onPressed: revoke,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.coral,
side: BorderSide(
color: Palette.coral.withValues(alpha: 0.4),
),
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.6.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: const Icon(Icons.block_rounded, size: 16),
label: const Text('Revoke'),
),
],
),
SizedBox(height: 0.8.h),
Row(
children: [
Text(
walletLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: Palette.ink,
fontFamily: 'monospace',
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 0.8.w),
child: Text(
'·',
style: theme.textTheme.bodySmall?.copyWith(
color: muted,
),
),
),
Expanded(
child: Text(
clientLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: muted,
),
),
),
],
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -1,96 +1,96 @@
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
class CreateWalletButton extends ConsumerWidget {
const CreateWalletButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final createWallet = ref.watch(createEvmWallet);
final isCreating = createWallet is MutationPending;
Future<void> handleCreateWallet() async {
try {
await executeCreateEvmWallet(ref);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('New wallet created successfully.'),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to create wallet: ${_formatError(e)}'),
behavior: SnackBarBehavior.floating,
),
);
}
}
return FilledButton.icon(
onPressed: isCreating ? null : () => handleCreateWallet(),
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'),
);
}
}
class RefreshWalletButton extends ConsumerWidget {
const RefreshWalletButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> handleRefreshWallets() async {
try {
await ref.read(evmProvider.notifier).refreshWallets();
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to refresh wallets: ${_formatError(e)}'),
behavior: SnackBarBehavior.floating,
),
);
}
}
return OutlinedButton.icon(
onPressed: () => handleRefreshWallets(),
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'),
);
}
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
class CreateWalletButton extends ConsumerWidget {
const CreateWalletButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final createWallet = ref.watch(createEvmWallet);
final isCreating = createWallet is MutationPending;
Future<void> handleCreateWallet() async {
try {
await executeCreateEvmWallet(ref);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('New wallet created successfully.'),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to create wallet: ${_formatError(e)}'),
behavior: SnackBarBehavior.floating,
),
);
}
}
return FilledButton.icon(
onPressed: isCreating ? null : () => handleCreateWallet(),
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'),
);
}
}
class RefreshWalletButton extends ConsumerWidget {
const RefreshWalletButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> handleRefreshWallets() async {
try {
await ref.read(evmProvider.notifier).refreshWallets();
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to refresh wallets: ${_formatError(e)}'),
behavior: SnackBarBehavior.floating,
),
);
}
}
return OutlinedButton.icon(
onPressed: () => handleRefreshWallets(),
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'),
);
}
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}

View File

@@ -1,200 +1,200 @@
import 'dart:math' as math;
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
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;
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();
}
class WalletTable extends StatelessWidget {
const WalletTable({super.key, required this.wallets});
final List<WalletEntry> wallets;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CreamFrame(
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,
),
),
],
),
),
),
],
),
);
}
}
import 'dart:math' as math;
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
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;
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();
}
class WalletTable extends StatelessWidget {
const WalletTable({super.key, required this.wallets});
final List<WalletEntry> wallets;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CreamFrame(
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,
),
),
],
),
),
),
],
),
);
}
}

View File

@@ -1,59 +1,59 @@
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:arbiter/router.gr.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
@RoutePage()
class ServerConnectionScreen extends HookConsumerWidget {
const ServerConnectionScreen({super.key, this.arbiterUrl});
final String? arbiterUrl;
@override
Widget build(BuildContext context, WidgetRef ref) {
final connectionState = ref.watch(connectionManagerProvider);
ref.listen(connectionManagerProvider, (_, next) {
if (next.value != null && context.mounted) {
context.router.replace(const VaultSetupRoute());
}
});
final body = switch (connectionState) {
AsyncLoading() => const CircularProgressIndicator(),
AsyncError(:final error) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Connection failed',
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
SizedBox(height: 1.5.h),
Text('$error', textAlign: TextAlign.center),
SizedBox(height: 2.h),
TextButton(
onPressed: () {
context.router.replace(const ServerInfoSetupRoute());
},
child: const Text('Back to server setup'),
),
],
),
_ => const CircularProgressIndicator(),
};
return Scaffold(
appBar: AppBar(title: const Text('Connecting')),
body: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w),
child: body,
),
),
);
}
}
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:arbiter/router.gr.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
@RoutePage()
class ServerConnectionScreen extends HookConsumerWidget {
const ServerConnectionScreen({super.key, this.arbiterUrl});
final String? arbiterUrl;
@override
Widget build(BuildContext context, WidgetRef ref) {
final connectionState = ref.watch(connectionManagerProvider);
ref.listen(connectionManagerProvider, (_, next) {
if (next.value != null && context.mounted) {
context.router.replace(const VaultSetupRoute());
}
});
final body = switch (connectionState) {
AsyncLoading() => const CircularProgressIndicator(),
AsyncError(:final error) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Connection failed',
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
SizedBox(height: 1.5.h),
Text('$error', textAlign: TextAlign.center),
SizedBox(height: 2.h),
TextButton(
onPressed: () {
context.router.replace(const ServerInfoSetupRoute());
},
child: const Text('Back to server setup'),
),
],
),
_ => const CircularProgressIndicator(),
};
return Scaffold(
appBar: AppBar(title: const Text('Connecting')),
body: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w),
child: body,
),
),
);
}
}

View File

@@ -1,294 +1,294 @@
import 'package:arbiter/features/connection/arbiter_url.dart';
import 'package:arbiter/features/connection/server_info_storage.dart';
import 'package:arbiter/providers/connection/bootstrap_token.dart';
import 'package:arbiter/providers/server_info.dart';
import 'package:arbiter/router.gr.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 ServerInfoSetupScreen extends HookConsumerWidget {
const ServerInfoSetupScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final storedServerInfo = ref.watch(serverInfoProvider);
final resolvedServerInfo = storedServerInfo.asData?.value;
final controller = useTextEditingController();
final errorText = useState<String?>(null);
final isSaving = useState(false);
useEffect(() {
final serverInfo = resolvedServerInfo;
if (serverInfo != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.router.replace(ServerConnectionRoute());
}
});
}
return null;
}, [context, resolvedServerInfo]);
Future<void> saveRemoteServerInfo() async {
errorText.value = null;
isSaving.value = true;
try {
final arbiterUrl = ArbiterUrl.parse(controller.text.trim());
// set token before triggering reconnection by updating server info
if (arbiterUrl.bootstrapToken != null) {
ref
.read(bootstrapTokenProvider.notifier)
.set(arbiterUrl.bootstrapToken!);
}
await ref
.read(serverInfoProvider.notifier)
.save(
address: arbiterUrl.host,
port: arbiterUrl.port,
caCert: arbiterUrl.caCert,
);
if (context.mounted) {
context.router.replace(
ServerConnectionRoute(arbiterUrl: controller.text.trim()),
);
}
} on FormatException catch (error) {
errorText.value = error.message;
} catch (_) {
errorText.value = 'Failed to store connection settings.';
} finally {
isSaving.value = false;
}
}
if (storedServerInfo.isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (storedServerInfo.hasError) {
return Scaffold(
appBar: AppBar(title: const Text('Server Info Setup')),
body: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 6.w),
child: Text(
'Failed to load stored server info.',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
),
);
}
final serverInfo = resolvedServerInfo;
if (serverInfo != null) {
return _RedirectingView(serverInfo: serverInfo);
}
return Scaffold(
appBar: AppBar(title: const Text('Server Info Setup')),
body: LayoutBuilder(
builder: (context, constraints) {
final useRowLayout = constraints.maxWidth > constraints.maxHeight;
final gap = 2.h;
final horizontalPadding = 6.w;
final verticalPadding = 3.h;
final options = [
const _OptionCard(
title: 'Local',
subtitle:
'Will start and connect to a local service in a future update.',
enabled: false,
child: SizedBox.shrink(),
),
_OptionCard(
title: 'Remote',
subtitle:
'Paste an Arbiter URL to store the server address, port, and CA fingerprint.',
child: _RemoteConnectionForm(
controller: controller,
errorText: errorText.value,
isSaving: isSaving.value,
onSave: saveRemoteServerInfo,
),
),
];
return ListView(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: verticalPadding,
),
children: [
_SetupHeader(gap: gap),
SizedBox(height: gap),
useRowLayout
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: options[0]),
SizedBox(width: 3.w),
Expanded(child: options[1]),
],
)
: Column(
children: [
options[0],
SizedBox(height: gap),
options[1],
],
),
],
);
},
),
);
}
}
class _SetupHeader extends StatelessWidget {
const _SetupHeader({required this.gap});
final double gap;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Choose how this user agent should reach Arbiter.',
style: Theme.of(context).textTheme.headlineSmall,
),
SizedBox(height: gap * 0.5),
Text(
'Remote accepts the shareable Arbiter URL emitted by the server.',
style: Theme.of(context).textTheme.bodyMedium,
),
],
);
}
}
class _RedirectingView extends StatelessWidget {
const _RedirectingView({required this.serverInfo});
final StoredServerInfo serverInfo;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
SizedBox(height: 2.h),
Text('Using saved server ${serverInfo.address}:${serverInfo.port}'),
],
),
),
);
}
}
class _RemoteConnectionForm extends StatelessWidget {
const _RemoteConnectionForm({
required this.controller,
required this.errorText,
required this.isSaving,
required this.onSave,
});
final TextEditingController controller;
final String? errorText;
final bool isSaving;
final VoidCallback onSave;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Arbiter URL',
hintText: 'arbiter://host:port?cert=...',
),
minLines: 2,
maxLines: 4,
),
if (errorText != null) ...[
SizedBox(height: 1.5.h),
Text(
errorText!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
SizedBox(height: 2.h),
Align(
alignment: Alignment.centerLeft,
child: FilledButton(
onPressed: isSaving ? null : onSave,
child: Text(isSaving ? 'Saving...' : 'Save connection'),
),
),
],
);
}
}
class _OptionCard extends StatelessWidget {
const _OptionCard({
required this.title,
required this.subtitle,
required this.child,
this.enabled = true,
});
final String title;
final String subtitle;
final Widget child;
final bool enabled;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: EdgeInsets.all(2.h),
child: Opacity(
opacity: enabled ? 1 : 0.55,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(title, style: theme.textTheme.titleLarge),
if (!enabled) ...[
SizedBox(width: 2.w),
const Chip(label: Text('Coming soon')),
],
],
),
SizedBox(height: 1.h),
Text(subtitle, style: theme.textTheme.bodyMedium),
if (enabled) ...[SizedBox(height: 2.h), child],
],
),
),
),
);
}
}
import 'package:arbiter/features/connection/arbiter_url.dart';
import 'package:arbiter/features/connection/server_info_storage.dart';
import 'package:arbiter/providers/connection/bootstrap_token.dart';
import 'package:arbiter/providers/server_info.dart';
import 'package:arbiter/router.gr.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 ServerInfoSetupScreen extends HookConsumerWidget {
const ServerInfoSetupScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final storedServerInfo = ref.watch(serverInfoProvider);
final resolvedServerInfo = storedServerInfo.asData?.value;
final controller = useTextEditingController();
final errorText = useState<String?>(null);
final isSaving = useState(false);
useEffect(() {
final serverInfo = resolvedServerInfo;
if (serverInfo != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.router.replace(ServerConnectionRoute());
}
});
}
return null;
}, [context, resolvedServerInfo]);
Future<void> saveRemoteServerInfo() async {
errorText.value = null;
isSaving.value = true;
try {
final arbiterUrl = ArbiterUrl.parse(controller.text.trim());
// set token before triggering reconnection by updating server info
if (arbiterUrl.bootstrapToken != null) {
ref
.read(bootstrapTokenProvider.notifier)
.set(arbiterUrl.bootstrapToken!);
}
await ref
.read(serverInfoProvider.notifier)
.save(
address: arbiterUrl.host,
port: arbiterUrl.port,
caCert: arbiterUrl.caCert,
);
if (context.mounted) {
context.router.replace(
ServerConnectionRoute(arbiterUrl: controller.text.trim()),
);
}
} on FormatException catch (error) {
errorText.value = error.message;
} catch (_) {
errorText.value = 'Failed to store connection settings.';
} finally {
isSaving.value = false;
}
}
if (storedServerInfo.isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (storedServerInfo.hasError) {
return Scaffold(
appBar: AppBar(title: const Text('Server Info Setup')),
body: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 6.w),
child: Text(
'Failed to load stored server info.',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
),
);
}
final serverInfo = resolvedServerInfo;
if (serverInfo != null) {
return _RedirectingView(serverInfo: serverInfo);
}
return Scaffold(
appBar: AppBar(title: const Text('Server Info Setup')),
body: LayoutBuilder(
builder: (context, constraints) {
final useRowLayout = constraints.maxWidth > constraints.maxHeight;
final gap = 2.h;
final horizontalPadding = 6.w;
final verticalPadding = 3.h;
final options = [
const _OptionCard(
title: 'Local',
subtitle:
'Will start and connect to a local service in a future update.',
enabled: false,
child: SizedBox.shrink(),
),
_OptionCard(
title: 'Remote',
subtitle:
'Paste an Arbiter URL to store the server address, port, and CA fingerprint.',
child: _RemoteConnectionForm(
controller: controller,
errorText: errorText.value,
isSaving: isSaving.value,
onSave: saveRemoteServerInfo,
),
),
];
return ListView(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: verticalPadding,
),
children: [
_SetupHeader(gap: gap),
SizedBox(height: gap),
useRowLayout
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: options[0]),
SizedBox(width: 3.w),
Expanded(child: options[1]),
],
)
: Column(
children: [
options[0],
SizedBox(height: gap),
options[1],
],
),
],
);
},
),
);
}
}
class _SetupHeader extends StatelessWidget {
const _SetupHeader({required this.gap});
final double gap;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Choose how this user agent should reach Arbiter.',
style: Theme.of(context).textTheme.headlineSmall,
),
SizedBox(height: gap * 0.5),
Text(
'Remote accepts the shareable Arbiter URL emitted by the server.',
style: Theme.of(context).textTheme.bodyMedium,
),
],
);
}
}
class _RedirectingView extends StatelessWidget {
const _RedirectingView({required this.serverInfo});
final StoredServerInfo serverInfo;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
SizedBox(height: 2.h),
Text('Using saved server ${serverInfo.address}:${serverInfo.port}'),
],
),
),
);
}
}
class _RemoteConnectionForm extends StatelessWidget {
const _RemoteConnectionForm({
required this.controller,
required this.errorText,
required this.isSaving,
required this.onSave,
});
final TextEditingController controller;
final String? errorText;
final bool isSaving;
final VoidCallback onSave;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Arbiter URL',
hintText: 'arbiter://host:port?cert=...',
),
minLines: 2,
maxLines: 4,
),
if (errorText != null) ...[
SizedBox(height: 1.5.h),
Text(
errorText!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
SizedBox(height: 2.h),
Align(
alignment: Alignment.centerLeft,
child: FilledButton(
onPressed: isSaving ? null : onSave,
child: Text(isSaving ? 'Saving...' : 'Save connection'),
),
),
],
);
}
}
class _OptionCard extends StatelessWidget {
const _OptionCard({
required this.title,
required this.subtitle,
required this.child,
this.enabled = true,
});
final String title;
final String subtitle;
final Widget child;
final bool enabled;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: EdgeInsets.all(2.h),
child: Opacity(
opacity: enabled ? 1 : 0.55,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(title, style: theme.textTheme.titleLarge),
if (!enabled) ...[
SizedBox(width: 2.w),
const Chip(label: Text('Coming soon')),
],
],
),
SizedBox(height: 1.h),
Text(subtitle, style: theme.textTheme.bodyMedium),
if (enabled) ...[SizedBox(height: 2.h), child],
],
),
),
),
);
}
}

View File

@@ -1,410 +1,410 @@
import 'package:arbiter/features/connection/vault.dart';
import 'package:arbiter/proto/shared/vault.pbenum.dart';
import 'package:arbiter/proto/user_agent/vault/bootstrap.pbenum.dart';
import 'package:arbiter/proto/user_agent/vault/unseal.pbenum.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:arbiter/providers/vault_state.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:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
@RoutePage()
class VaultSetupScreen extends HookConsumerWidget {
const VaultSetupScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vaultState = ref.watch(vaultStateProvider);
final bootstrapPasswordController = useTextEditingController();
final bootstrapConfirmController = useTextEditingController();
final unsealPasswordController = useTextEditingController();
final errorText = useState<String?>(null);
final isSubmitting = useState(false);
useEffect(() {
if (vaultState.asData?.value == VaultState.VAULT_STATE_UNSEALED) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.router.replace(const DashboardRouter());
}
});
}
return null;
}, [context, vaultState.asData?.value]);
Future<void> refreshVaultState() async {
ref.invalidate(vaultStateProvider);
await ref.read(vaultStateProvider.future);
}
Future<void> submitBootstrap() async {
final password = bootstrapPasswordController.text;
final confirmation = bootstrapConfirmController.text;
if (password.isEmpty || confirmation.isEmpty) {
errorText.value = 'Enter the password twice.';
return;
}
if (password != confirmation) {
errorText.value = 'Passwords do not match.';
return;
}
final confirmed = await showBottomPopup<bool>(
context: context,
builder: (popupContext) {
return _WarningPopup(
title: 'Bootstrap vault?',
body:
'This password cannot be recovered. If you lose it, the vault cannot be unsealed.',
confirmLabel: 'Bootstrap',
onCancel: () => Navigator.of(popupContext).pop(false),
onConfirm: () => Navigator.of(popupContext).pop(true),
);
},
);
if (confirmed != true) {
return;
}
errorText.value = null;
isSubmitting.value = true;
try {
final connection = await ref.read(connectionManagerProvider.future);
if (connection == null) {
throw Exception('Not connected to the server.');
}
final result = await bootstrapVault(connection, password);
switch (result) {
case BootstrapResult.BOOTSTRAP_RESULT_SUCCESS:
bootstrapPasswordController.clear();
bootstrapConfirmController.clear();
await refreshVaultState();
break;
case BootstrapResult.BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED:
errorText.value =
'The vault was already bootstrapped. Refreshing vault state.';
await refreshVaultState();
break;
case BootstrapResult.BOOTSTRAP_RESULT_INVALID_KEY:
case BootstrapResult.BOOTSTRAP_RESULT_UNSPECIFIED:
errorText.value = 'Failed to bootstrap the vault.';
break;
}
} catch (error) {
errorText.value = _formatVaultError(error);
} finally {
isSubmitting.value = false;
}
}
Future<void> submitUnseal() async {
final password = unsealPasswordController.text;
if (password.isEmpty) {
errorText.value = 'Enter the vault password.';
return;
}
errorText.value = null;
isSubmitting.value = true;
try {
final connection = await ref.read(connectionManagerProvider.future);
if (connection == null) {
throw Exception('Not connected to the server.');
}
final result = await unsealVault(connection, password);
switch (result) {
case UnsealResult.UNSEAL_RESULT_SUCCESS:
unsealPasswordController.clear();
await refreshVaultState();
break;
case UnsealResult.UNSEAL_RESULT_INVALID_KEY:
errorText.value = 'Incorrect password.';
break;
case UnsealResult.UNSEAL_RESULT_UNBOOTSTRAPPED:
errorText.value =
'The vault is not bootstrapped yet. Refreshing vault state.';
await refreshVaultState();
break;
case UnsealResult.UNSEAL_RESULT_UNSPECIFIED:
errorText.value = 'Failed to unseal the vault.';
break;
}
} catch (error) {
errorText.value = _formatVaultError(error);
} finally {
isSubmitting.value = false;
}
}
final body = switch (vaultState) {
AsyncLoading() => const Center(child: CircularProgressIndicator()),
AsyncError(:final error) => _VaultCard(
title: 'Vault unavailable',
subtitle: _formatVaultError(error),
child: FilledButton(
onPressed: () => ref.invalidate(vaultStateProvider),
child: const Text('Retry'),
),
),
AsyncData(:final value) => switch (value) {
VaultState.VAULT_STATE_UNBOOTSTRAPPED => _VaultCard(
title: 'Create vault password',
subtitle:
'Choose the password that will be required to unseal this vault.',
child: _PasswordForm(
errorText: errorText.value,
isSubmitting: isSubmitting.value,
submitLabel: 'Bootstrap vault',
onSubmit: submitBootstrap,
fields: [
_PasswordFieldConfig(
controller: bootstrapPasswordController,
label: 'Password',
textInputAction: TextInputAction.next,
),
_PasswordFieldConfig(
controller: bootstrapConfirmController,
label: 'Confirm password',
textInputAction: TextInputAction.done,
onSubmitted: (_) => submitBootstrap(),
),
],
),
),
VaultState.VAULT_STATE_SEALED => _VaultCard(
title: 'Unseal vault',
subtitle: 'Enter the vault password to continue.',
child: _PasswordForm(
errorText: errorText.value,
isSubmitting: isSubmitting.value,
submitLabel: 'Unseal vault',
onSubmit: submitUnseal,
fields: [
_PasswordFieldConfig(
controller: unsealPasswordController,
label: 'Password',
textInputAction: TextInputAction.done,
onSubmitted: (_) => submitUnseal(),
),
],
),
),
VaultState.VAULT_STATE_UNSEALED => const Center(
child: CircularProgressIndicator(),
),
VaultState.VAULT_STATE_ERROR => _VaultCard(
title: 'Vault state unavailable',
subtitle: 'Unable to determine the current vault state.',
child: FilledButton(
onPressed: () => ref.invalidate(vaultStateProvider),
child: const Text('Retry'),
),
),
VaultState.VAULT_STATE_UNSPECIFIED => _VaultCard(
title: 'Vault state unavailable',
subtitle: 'Unable to determine the current vault state.',
child: FilledButton(
onPressed: () => ref.invalidate(vaultStateProvider),
child: const Text('Retry'),
),
),
null => _VaultCard(
title: 'Vault state unavailable',
subtitle: 'Unable to determine the current vault state.',
child: FilledButton(
onPressed: () => ref.invalidate(vaultStateProvider),
child: const Text('Retry'),
),
),
_ => _VaultCard(
title: 'Vault state unavailable',
subtitle: 'Unable to determine the current vault state.',
child: FilledButton(
onPressed: () => ref.invalidate(vaultStateProvider),
child: const Text('Retry'),
),
),
},
};
return Scaffold(
appBar: AppBar(title: const Text('Vault Setup')),
body: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 3.h),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: body,
),
),
),
);
}
}
class _VaultCard extends StatelessWidget {
const _VaultCard({
required this.title,
required this.subtitle,
required this.child,
});
final String title;
final String subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 12),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 24),
child,
],
),
),
);
}
}
class _PasswordForm extends StatelessWidget {
const _PasswordForm({
required this.fields,
required this.errorText,
required this.isSubmitting,
required this.submitLabel,
required this.onSubmit,
});
final List<_PasswordFieldConfig> fields;
final String? errorText;
final bool isSubmitting;
final String submitLabel;
final Future<void> Function() onSubmit;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final field in fields) ...[
TextField(
controller: field.controller,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
textInputAction: field.textInputAction,
onSubmitted: field.onSubmitted,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: field.label,
),
),
const SizedBox(height: 16),
],
if (errorText != null) ...[
Text(
errorText!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 16),
],
Align(
alignment: Alignment.centerLeft,
child: FilledButton(
onPressed: isSubmitting ? null : () => onSubmit(),
child: Text(isSubmitting ? 'Working...' : submitLabel),
),
),
],
);
}
}
class _PasswordFieldConfig {
const _PasswordFieldConfig({
required this.controller,
required this.label,
required this.textInputAction,
this.onSubmitted,
});
final TextEditingController controller;
final String label;
final TextInputAction textInputAction;
final ValueChanged<String>? onSubmitted;
}
class _WarningPopup extends StatelessWidget {
const _WarningPopup({
required this.title,
required this.body,
required this.confirmLabel,
required this.onCancel,
required this.onConfirm,
});
final String title;
final String body;
final String confirmLabel;
final VoidCallback onCancel;
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Text(body, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(onPressed: onCancel, child: const Text('Cancel')),
const SizedBox(width: 12),
FilledButton(onPressed: onConfirm, child: Text(confirmLabel)),
],
),
],
),
),
);
}
}
String _formatVaultError(Object error) {
final message = error.toString();
if (message.contains('GrpcError')) {
return 'The server rejected the vault request. Check the password and try again.';
}
return message.replaceFirst('Exception: ', '');
}
import 'package:arbiter/features/connection/vault.dart';
import 'package:arbiter/proto/shared/vault.pbenum.dart';
import 'package:arbiter/proto/user_agent/vault/bootstrap.pbenum.dart';
import 'package:arbiter/proto/user_agent/vault/unseal.pbenum.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:arbiter/providers/vault_state.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:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
@RoutePage()
class VaultSetupScreen extends HookConsumerWidget {
const VaultSetupScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vaultState = ref.watch(vaultStateProvider);
final bootstrapPasswordController = useTextEditingController();
final bootstrapConfirmController = useTextEditingController();
final unsealPasswordController = useTextEditingController();
final errorText = useState<String?>(null);
final isSubmitting = useState(false);
useEffect(() {
if (vaultState.asData?.value == VaultState.VAULT_STATE_UNSEALED) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.router.replace(const DashboardRouter());
}
});
}
return null;
}, [context, vaultState.asData?.value]);
Future<void> refreshVaultState() async {
ref.invalidate(vaultStateProvider);
await ref.read(vaultStateProvider.future);
}
Future<void> submitBootstrap() async {
final password = bootstrapPasswordController.text;
final confirmation = bootstrapConfirmController.text;
if (password.isEmpty || confirmation.isEmpty) {
errorText.value = 'Enter the password twice.';
return;
}
if (password != confirmation) {
errorText.value = 'Passwords do not match.';
return;
}
final confirmed = await showBottomPopup<bool>(
context: context,
builder: (popupContext) {
return _WarningPopup(
title: 'Bootstrap vault?',
body:
'This password cannot be recovered. If you lose it, the vault cannot be unsealed.',
confirmLabel: 'Bootstrap',
onCancel: () => Navigator.of(popupContext).pop(false),
onConfirm: () => Navigator.of(popupContext).pop(true),
);
},
);
if (confirmed != true) {
return;
}
errorText.value = null;
isSubmitting.value = true;
try {
final connection = await ref.read(connectionManagerProvider.future);
if (connection == null) {
throw Exception('Not connected to the server.');
}
final result = await bootstrapVault(connection, password);
switch (result) {
case BootstrapResult.BOOTSTRAP_RESULT_SUCCESS:
bootstrapPasswordController.clear();
bootstrapConfirmController.clear();
await refreshVaultState();
break;
case BootstrapResult.BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED:
errorText.value =
'The vault was already bootstrapped. Refreshing vault state.';
await refreshVaultState();
break;
case BootstrapResult.BOOTSTRAP_RESULT_INVALID_KEY:
case BootstrapResult.BOOTSTRAP_RESULT_UNSPECIFIED:
errorText.value = 'Failed to bootstrap the vault.';
break;
}
} catch (error) {
errorText.value = _formatVaultError(error);
} finally {
isSubmitting.value = false;
}
}
Future<void> submitUnseal() async {
final password = unsealPasswordController.text;
if (password.isEmpty) {
errorText.value = 'Enter the vault password.';
return;
}
errorText.value = null;
isSubmitting.value = true;
try {
final connection = await ref.read(connectionManagerProvider.future);
if (connection == null) {
throw Exception('Not connected to the server.');
}
final result = await unsealVault(connection, password);
switch (result) {
case UnsealResult.UNSEAL_RESULT_SUCCESS:
unsealPasswordController.clear();
await refreshVaultState();
break;
case UnsealResult.UNSEAL_RESULT_INVALID_KEY:
errorText.value = 'Incorrect password.';
break;
case UnsealResult.UNSEAL_RESULT_UNBOOTSTRAPPED:
errorText.value =
'The vault is not bootstrapped yet. Refreshing vault state.';
await refreshVaultState();
break;
case UnsealResult.UNSEAL_RESULT_UNSPECIFIED:
errorText.value = 'Failed to unseal the vault.';
break;
}
} catch (error) {
errorText.value = _formatVaultError(error);
} finally {
isSubmitting.value = false;
}
}
final body = switch (vaultState) {
AsyncLoading() => const Center(child: CircularProgressIndicator()),
AsyncError(:final error) => _VaultCard(
title: 'Vault unavailable',
subtitle: _formatVaultError(error),
child: FilledButton(
onPressed: () => ref.invalidate(vaultStateProvider),
child: const Text('Retry'),
),
),
AsyncData(:final value) => switch (value) {
VaultState.VAULT_STATE_UNBOOTSTRAPPED => _VaultCard(
title: 'Create vault password',
subtitle:
'Choose the password that will be required to unseal this vault.',
child: _PasswordForm(
errorText: errorText.value,
isSubmitting: isSubmitting.value,
submitLabel: 'Bootstrap vault',
onSubmit: submitBootstrap,
fields: [
_PasswordFieldConfig(
controller: bootstrapPasswordController,
label: 'Password',
textInputAction: TextInputAction.next,
),
_PasswordFieldConfig(
controller: bootstrapConfirmController,
label: 'Confirm password',
textInputAction: TextInputAction.done,
onSubmitted: (_) => submitBootstrap(),
),
],
),
),
VaultState.VAULT_STATE_SEALED => _VaultCard(
title: 'Unseal vault',
subtitle: 'Enter the vault password to continue.',
child: _PasswordForm(
errorText: errorText.value,
isSubmitting: isSubmitting.value,
submitLabel: 'Unseal vault',
onSubmit: submitUnseal,
fields: [
_PasswordFieldConfig(
controller: unsealPasswordController,
label: 'Password',
textInputAction: TextInputAction.done,
onSubmitted: (_) => submitUnseal(),
),
],
),
),
VaultState.VAULT_STATE_UNSEALED => const Center(
child: CircularProgressIndicator(),
),
VaultState.VAULT_STATE_ERROR => _VaultCard(
title: 'Vault state unavailable',
subtitle: 'Unable to determine the current vault state.',
child: FilledButton(
onPressed: () => ref.invalidate(vaultStateProvider),
child: const Text('Retry'),
),
),
VaultState.VAULT_STATE_UNSPECIFIED => _VaultCard(
title: 'Vault state unavailable',
subtitle: 'Unable to determine the current vault state.',
child: FilledButton(
onPressed: () => ref.invalidate(vaultStateProvider),
child: const Text('Retry'),
),
),
null => _VaultCard(
title: 'Vault state unavailable',
subtitle: 'Unable to determine the current vault state.',
child: FilledButton(
onPressed: () => ref.invalidate(vaultStateProvider),
child: const Text('Retry'),
),
),
_ => _VaultCard(
title: 'Vault state unavailable',
subtitle: 'Unable to determine the current vault state.',
child: FilledButton(
onPressed: () => ref.invalidate(vaultStateProvider),
child: const Text('Retry'),
),
),
},
};
return Scaffold(
appBar: AppBar(title: const Text('Vault Setup')),
body: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 3.h),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: body,
),
),
),
);
}
}
class _VaultCard extends StatelessWidget {
const _VaultCard({
required this.title,
required this.subtitle,
required this.child,
});
final String title;
final String subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 12),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 24),
child,
],
),
),
);
}
}
class _PasswordForm extends StatelessWidget {
const _PasswordForm({
required this.fields,
required this.errorText,
required this.isSubmitting,
required this.submitLabel,
required this.onSubmit,
});
final List<_PasswordFieldConfig> fields;
final String? errorText;
final bool isSubmitting;
final String submitLabel;
final Future<void> Function() onSubmit;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final field in fields) ...[
TextField(
controller: field.controller,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
textInputAction: field.textInputAction,
onSubmitted: field.onSubmitted,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: field.label,
),
),
const SizedBox(height: 16),
],
if (errorText != null) ...[
Text(
errorText!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 16),
],
Align(
alignment: Alignment.centerLeft,
child: FilledButton(
onPressed: isSubmitting ? null : () => onSubmit(),
child: Text(isSubmitting ? 'Working...' : submitLabel),
),
),
],
);
}
}
class _PasswordFieldConfig {
const _PasswordFieldConfig({
required this.controller,
required this.label,
required this.textInputAction,
this.onSubmitted,
});
final TextEditingController controller;
final String label;
final TextInputAction textInputAction;
final ValueChanged<String>? onSubmitted;
}
class _WarningPopup extends StatelessWidget {
const _WarningPopup({
required this.title,
required this.body,
required this.confirmLabel,
required this.onCancel,
required this.onConfirm,
});
final String title;
final String body;
final String confirmLabel;
final VoidCallback onCancel;
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Text(body, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(onPressed: onCancel, child: const Text('Cancel')),
const SizedBox(width: 12),
FilledButton(onPressed: onConfirm, child: Text(confirmLabel)),
],
),
],
),
),
);
}
}
String _formatVaultError(Object error) {
final message = error.toString();
if (message.contains('GrpcError')) {
return 'The server rejected the vault request. Check the password and try again.';
}
return message.replaceFirst('Exception: ', '');
}