refactor(useragent): moved shared CreamPanel and StatePanel into generic widgets

This commit is contained in:
hdbg
2026-03-28 17:57:50 +01:00
parent 54b2183be5
commit 32743741e1
12 changed files with 151 additions and 310 deletions

View File

@@ -1,11 +1,9 @@
import 'package:arbiter/features/connection/evm/grants.dart';
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:fixnum/fixnum.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:mtcore/markettakers.dart';
import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'evm_grants.freezed.dart';

View File

@@ -1,5 +1,6 @@
import 'package:arbiter/proto/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';
@@ -31,12 +32,7 @@ class SdkConnectCallout extends StatelessWidget {
clientInfo.hasVersion() && clientInfo.version.isNotEmpty;
final showInfoCard = hasDescription || hasVersion;
return Container(
decoration: BoxDecoration(
color: Palette.cream,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Palette.line),
),
return CreamFrame(
padding: EdgeInsets.all(2.4.h),
child: Column(
mainAxisSize: MainAxisSize.min,

View File

@@ -1,4 +1,5 @@
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
class ClientDetailsStatePanel extends StatelessWidget {
@@ -17,15 +18,8 @@ class ClientDetailsStatePanel extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: DecoratedBox(
decoration: BoxDecoration(
color: Palette.cream,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Palette.line),
),
child: Padding(
child: CreamFrame(
margin: const EdgeInsets.all(24),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -38,8 +32,6 @@ class ClientDetailsStatePanel extends StatelessWidget {
],
),
),
),
),
);
}
}

View File

@@ -1,5 +1,5 @@
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
class ClientSummaryCard extends StatelessWidget {
@@ -9,13 +9,7 @@ class ClientSummaryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Palette.cream,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Palette.line),
),
child: Padding(
return CreamFrame(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -42,7 +36,6 @@ class ClientSummaryCard extends StatelessWidget {
),
],
),
),
);
}
}

View File

@@ -1,5 +1,6 @@
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';
@@ -24,13 +25,7 @@ class WalletAccessSaveBar extends StatelessWidget {
MutationError(:final error) => error.toString(),
_ => null,
};
return DecoratedBox(
decoration: BoxDecoration(
color: Palette.cream,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Palette.line),
),
child: Padding(
return CreamFrame(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -54,7 +49,6 @@ class WalletAccessSaveBar extends StatelessWidget {
),
],
),
),
);
}
}

View File

@@ -2,7 +2,7 @@ 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/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -27,13 +27,7 @@ class WalletAccessSection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final optionsAsync = ref.watch(clientWalletOptionsProvider);
return DecoratedBox(
decoration: BoxDecoration(
color: Palette.cream,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Palette.line),
),
child: Padding(
return CreamFrame(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -56,7 +50,6 @@ class WalletAccessSection extends ConsumerWidget {
),
],
),
),
);
}
}

View File

@@ -10,6 +10,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:arbiter/widgets/state_panel.dart';
import 'package:sizer/sizer.dart';
// ─── Column width getters ─────────────────────────────────────────────────────
@@ -59,79 +61,6 @@ String _formatError(Object error) {
return message;
}
// ─── State panel ─────────────────────────────────────────────────────────────
class _StatePanel extends StatelessWidget {
const _StatePanel({
required this.icon,
required this.title,
required this.body,
this.actionLabel,
this.onAction,
this.busy = false,
});
final IconData icon;
final String title;
final String body;
final String? actionLabel;
final Future<void> Function()? onAction;
final bool busy;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: Padding(
padding: EdgeInsets.all(2.8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (busy)
SizedBox(
width: 2.8.h,
height: 2.8.h,
child: const CircularProgressIndicator(strokeWidth: 2.5),
)
else
Icon(icon, size: 34, color: Palette.coral),
SizedBox(height: 1.8.h),
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 1.h),
Text(
body,
style: theme.textTheme.bodyLarge?.copyWith(
color: Palette.ink.withValues(alpha: 0.72),
height: 1.5,
),
),
if (actionLabel != null && onAction != null) ...[
SizedBox(height: 2.h),
OutlinedButton.icon(
onPressed: () => onAction!(),
icon: const Icon(Icons.refresh),
label: Text(actionLabel!),
),
],
],
),
),
);
}
}
// ─── Header ───────────────────────────────────────────────────────────────────
class _Header extends StatelessWidget {
@@ -443,13 +372,7 @@ class _ClientTable extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: Padding(
return CreamFrame(
padding: EdgeInsets.all(2.h),
child: LayoutBuilder(
builder: (context, constraints) {
@@ -497,7 +420,6 @@ class _ClientTable extends StatelessWidget {
);
},
),
),
);
}
}
@@ -533,27 +455,27 @@ class ClientsScreen extends HookConsumerWidget {
final clients = clientsAsync.asData?.value;
final content = switch (clientsAsync) {
AsyncLoading() when clients == null => const _StatePanel(
AsyncLoading() when clients == null => const StatePanel(
icon: Icons.hourglass_top,
title: 'Loading clients',
body: 'Pulling client registry from Arbiter.',
busy: true,
),
AsyncError(:final error) => _StatePanel(
AsyncError(:final error) => StatePanel(
icon: Icons.sync_problem,
title: 'Client registry unavailable',
body: _formatError(error),
actionLabel: 'Retry',
onAction: refresh,
),
_ when !isConnected => _StatePanel(
_ when !isConnected => StatePanel(
icon: Icons.portable_wifi_off,
title: 'No active server connection',
body: 'Reconnect to Arbiter to list SDK clients.',
actionLabel: 'Refresh',
onAction: refresh,
),
_ when clients != null && clients.isEmpty => _StatePanel(
_ when clients != null && clients.isEmpty => StatePanel(
icon: Icons.devices_other_outlined,
title: 'No clients yet',
body: 'SDK clients appear here once they register with Arbiter.',

View File

@@ -4,6 +4,7 @@ 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';
@@ -36,20 +37,20 @@ class EvmScreen extends HookConsumerWidget {
}
final content = switch (evm) {
AsyncLoading() when wallets == null => const _StatePanel(
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(
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(
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.',
@@ -90,77 +91,6 @@ class EvmScreen extends HookConsumerWidget {
}
}
class _StatePanel extends StatelessWidget {
const _StatePanel({
required this.icon,
required this.title,
required this.body,
this.actionLabel,
this.onAction,
this.busy = false,
});
final IconData icon;
final String title;
final String body;
final String? actionLabel;
final Future<void> Function()? onAction;
final bool busy;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: Padding(
padding: EdgeInsets.all(2.8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (busy)
SizedBox(
width: 2.8.h,
height: 2.8.h,
child: CircularProgressIndicator(strokeWidth: 2.5),
)
else
Icon(icon, size: 34, color: Palette.coral),
SizedBox(height: 1.8.h),
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 1.h),
Text(
body,
style: theme.textTheme.bodyLarge?.copyWith(
color: Palette.ink.withValues(alpha: 0.72),
height: 1.5,
),
),
if (actionLabel != null && onAction != null) ...[
SizedBox(height: 2.h),
OutlinedButton.icon(
onPressed: () => onAction!(),
icon: const Icon(Icons.refresh),
label: Text(actionLabel!),
),
],
],
),
),
);
}
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {

View File

@@ -5,6 +5,7 @@ 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';
@@ -18,79 +19,6 @@ String _formatError(Object error) {
return message;
}
// ─── State panel ──────────────────────────────────────────────────────────────
class _StatePanel extends StatelessWidget {
const _StatePanel({
required this.icon,
required this.title,
required this.body,
this.actionLabel,
this.onAction,
this.busy = false,
});
final IconData icon;
final String title;
final String body;
final String? actionLabel;
final Future<void> Function()? onAction;
final bool busy;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: Padding(
padding: EdgeInsets.all(2.8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (busy)
SizedBox(
width: 2.8.h,
height: 2.8.h,
child: const CircularProgressIndicator(strokeWidth: 2.5),
)
else
Icon(icon, size: 34, color: Palette.coral),
SizedBox(height: 1.8.h),
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 1.h),
Text(
body,
style: theme.textTheme.bodyLarge?.copyWith(
color: Palette.ink.withValues(alpha: 0.72),
height: 1.5,
),
),
if (actionLabel != null && onAction != null) ...[
SizedBox(height: 2.h),
OutlinedButton.icon(
onPressed: () => onAction!(),
icon: const Icon(Icons.refresh),
label: Text(actionLabel!),
),
],
],
),
),
);
}
}
// ─── Grant list ───────────────────────────────────────────────────────────────
class _GrantList extends StatelessWidget {
@@ -149,27 +77,27 @@ class EvmGrantsScreen extends ConsumerWidget {
final grants = grantsState?.grants;
final content = switch (grantsAsync) {
AsyncLoading() when grantsState == null => const _StatePanel(
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(
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(
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(
_ 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.',

View File

@@ -1,6 +1,7 @@
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';
@@ -32,13 +33,7 @@ class WalletTable extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: Padding(
return CreamFrame(
padding: EdgeInsets.all(2.h),
child: LayoutBuilder(
builder: (context, constraints) {
@@ -89,7 +84,6 @@ class WalletTable extends StatelessWidget {
);
},
),
),
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
/// A card-shaped frame with the cream background, rounded corners, and a
/// subtle border. Use [padding] for interior spacing and [margin] for exterior
/// spacing.
class CreamFrame extends StatelessWidget {
const CreamFrame({
super.key,
required this.child,
this.padding = EdgeInsets.zero,
this.margin,
});
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context) {
return Container(
margin: margin,
padding: padding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream,
border: Border.all(color: Palette.line),
),
child: child,
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
class StatePanel extends StatelessWidget {
const StatePanel({
super.key,
required this.icon,
required this.title,
required this.body,
this.actionLabel,
this.onAction,
this.busy = false,
});
final IconData icon;
final String title;
final String body;
final String? actionLabel;
final Future<void> Function()? onAction;
final bool busy;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CreamFrame(
padding: EdgeInsets.all(2.8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (busy)
SizedBox(
width: 2.8.h,
height: 2.8.h,
child: const CircularProgressIndicator(strokeWidth: 2.5),
)
else
Icon(icon, size: 34, color: Palette.coral),
SizedBox(height: 1.8.h),
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 1.h),
Text(
body,
style: theme.textTheme.bodyLarge?.copyWith(
color: Palette.ink.withValues(alpha: 0.72),
height: 1.5,
),
),
if (actionLabel != null && onAction != null) ...[
SizedBox(height: 2.h),
OutlinedButton.icon(
onPressed: () => onAction!(),
icon: const Icon(Icons.refresh),
label: Text(actionLabel!),
),
],
],
),
);
}
}