fix(clippy): apply auto-fixable linting suggestions
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}';
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}';
|
||||
}()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: ', '');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user