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 a3203936d2
commit 0c2d4986a2
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/features/connection/evm/grants.dart';
import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:fixnum/fixnum.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/experimental/mutation.dart'; import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:mtcore/markettakers.dart'; import 'package:mtcore/markettakers.dart';
import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'evm_grants.freezed.dart'; part 'evm_grants.freezed.dart';

View File

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

View File

@@ -1,4 +1,5 @@
import 'package:arbiter/theme/palette.dart'; import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ClientDetailsStatePanel extends StatelessWidget { class ClientDetailsStatePanel extends StatelessWidget {
@@ -17,27 +18,18 @@ class ClientDetailsStatePanel extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Center( return Center(
child: Padding( child: CreamFrame(
margin: const EdgeInsets.all(24),
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: DecoratedBox( child: Column(
decoration: BoxDecoration( mainAxisSize: MainAxisSize.min,
color: Palette.cream, children: [
borderRadius: BorderRadius.circular(24), Icon(icon, color: Palette.coral),
border: Border.all(color: Palette.line), const SizedBox(height: 12),
), Text(title, style: theme.textTheme.titleLarge),
child: Padding( const SizedBox(height: 8),
padding: const EdgeInsets.all(24), Text(body, textAlign: TextAlign.center),
child: Column( ],
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Palette.coral),
const SizedBox(height: 12),
Text(title, style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
Text(body, textAlign: TextAlign.center),
],
),
),
), ),
), ),
); );

View File

@@ -1,5 +1,5 @@
import 'package:arbiter/proto/user_agent.pb.dart'; 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'; import 'package:flutter/material.dart';
class ClientSummaryCard extends StatelessWidget { class ClientSummaryCard extends StatelessWidget {
@@ -9,15 +9,9 @@ class ClientSummaryCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DecoratedBox( return CreamFrame(
decoration: BoxDecoration( padding: const EdgeInsets.all(20),
color: Palette.cream, child: Column(
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Palette.line),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@@ -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/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/theme/palette.dart'; import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart'; import 'package:hooks_riverpod/experimental/mutation.dart';
@@ -24,15 +25,9 @@ class WalletAccessSaveBar extends StatelessWidget {
MutationError(:final error) => error.toString(), MutationError(:final error) => error.toString(),
_ => null, _ => null,
}; };
return DecoratedBox( return CreamFrame(
decoration: BoxDecoration( padding: const EdgeInsets.all(16),
color: Palette.cream, child: Column(
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Palette.line),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (errorText != null) ...[ if (errorText != null) ...[
@@ -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/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_list.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_search_field.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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -27,15 +27,9 @@ class WalletAccessSection extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final optionsAsync = ref.watch(clientWalletOptionsProvider); final optionsAsync = ref.watch(clientWalletOptionsProvider);
return DecoratedBox( return CreamFrame(
decoration: BoxDecoration( padding: const EdgeInsets.all(20),
color: Palette.cream, child: Column(
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Palette.line),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@@ -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:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:arbiter/theme/palette.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'; import 'package:sizer/sizer.dart';
// ─── Column width getters ───────────────────────────────────────────────────── // ─── Column width getters ─────────────────────────────────────────────────────
@@ -59,79 +61,6 @@ String _formatError(Object error) {
return message; 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 ─────────────────────────────────────────────────────────────────── // ─── Header ───────────────────────────────────────────────────────────────────
class _Header extends StatelessWidget { class _Header extends StatelessWidget {
@@ -443,17 +372,11 @@ class _ClientTable extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return CreamFrame(
decoration: BoxDecoration( padding: EdgeInsets.all(2.h),
borderRadius: BorderRadius.circular(24), child: LayoutBuilder(
color: Palette.cream.withValues(alpha: 0.92), builder: (context, constraints) {
border: Border.all(color: Palette.line), final tableWidth = math.max(_tableMinWidth, constraints.maxWidth);
),
child: Padding(
padding: EdgeInsets.all(2.h),
child: LayoutBuilder(
builder: (context, constraints) {
final tableWidth = math.max(_tableMinWidth, constraints.maxWidth);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -497,7 +420,6 @@ class _ClientTable extends StatelessWidget {
); );
}, },
), ),
),
); );
} }
} }
@@ -533,27 +455,27 @@ class ClientsScreen extends HookConsumerWidget {
final clients = clientsAsync.asData?.value; final clients = clientsAsync.asData?.value;
final content = switch (clientsAsync) { final content = switch (clientsAsync) {
AsyncLoading() when clients == null => const _StatePanel( AsyncLoading() when clients == null => const StatePanel(
icon: Icons.hourglass_top, icon: Icons.hourglass_top,
title: 'Loading clients', title: 'Loading clients',
body: 'Pulling client registry from Arbiter.', body: 'Pulling client registry from Arbiter.',
busy: true, busy: true,
), ),
AsyncError(:final error) => _StatePanel( AsyncError(:final error) => StatePanel(
icon: Icons.sync_problem, icon: Icons.sync_problem,
title: 'Client registry unavailable', title: 'Client registry unavailable',
body: _formatError(error), body: _formatError(error),
actionLabel: 'Retry', actionLabel: 'Retry',
onAction: refresh, onAction: refresh,
), ),
_ when !isConnected => _StatePanel( _ when !isConnected => StatePanel(
icon: Icons.portable_wifi_off, icon: Icons.portable_wifi_off,
title: 'No active server connection', title: 'No active server connection',
body: 'Reconnect to Arbiter to list SDK clients.', body: 'Reconnect to Arbiter to list SDK clients.',
actionLabel: 'Refresh', actionLabel: 'Refresh',
onAction: refresh, onAction: refresh,
), ),
_ when clients != null && clients.isEmpty => _StatePanel( _ when clients != null && clients.isEmpty => StatePanel(
icon: Icons.devices_other_outlined, icon: Icons.devices_other_outlined,
title: 'No clients yet', title: 'No clients yet',
body: 'SDK clients appear here once they register with Arbiter.', 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/theme/palette.dart';
import 'package:arbiter/providers/evm/evm.dart'; import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/widgets/page_header.dart'; import 'package:arbiter/widgets/page_header.dart';
import 'package:arbiter/widgets/state_panel.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -36,20 +37,20 @@ class EvmScreen extends HookConsumerWidget {
} }
final content = switch (evm) { final content = switch (evm) {
AsyncLoading() when wallets == null => const _StatePanel( AsyncLoading() when wallets == null => const StatePanel(
icon: Icons.hourglass_top, icon: Icons.hourglass_top,
title: 'Loading wallets', title: 'Loading wallets',
body: 'Pulling wallet registry from Arbiter.', body: 'Pulling wallet registry from Arbiter.',
busy: true, busy: true,
), ),
AsyncError(:final error) => _StatePanel( AsyncError(:final error) => StatePanel(
icon: Icons.sync_problem, icon: Icons.sync_problem,
title: 'Wallet registry unavailable', title: 'Wallet registry unavailable',
body: _formatError(error), body: _formatError(error),
actionLabel: 'Retry', actionLabel: 'Retry',
onAction: refreshWallets, onAction: refreshWallets,
), ),
AsyncData(:final value) when value == null => _StatePanel( AsyncData(:final value) when value == null => StatePanel(
icon: Icons.portable_wifi_off, icon: Icons.portable_wifi_off,
title: 'No active server connection', title: 'No active server connection',
body: 'Reconnect to Arbiter to list or create EVM wallets.', 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) { String _formatError(Object error) {
final message = error.toString(); final message = error.toString();
if (message.startsWith('Exception: ')) { 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/screens/dashboard/evm/grants/widgets/grant_card.dart';
import 'package:arbiter/theme/palette.dart'; import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/page_header.dart'; import 'package:arbiter/widgets/page_header.dart';
import 'package:arbiter/widgets/state_panel.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -18,79 +19,6 @@ String _formatError(Object error) {
return message; 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 ─────────────────────────────────────────────────────────────── // ─── Grant list ───────────────────────────────────────────────────────────────
class _GrantList extends StatelessWidget { class _GrantList extends StatelessWidget {
@@ -149,27 +77,27 @@ class EvmGrantsScreen extends ConsumerWidget {
final grants = grantsState?.grants; final grants = grantsState?.grants;
final content = switch (grantsAsync) { final content = switch (grantsAsync) {
AsyncLoading() when grantsState == null => const _StatePanel( AsyncLoading() when grantsState == null => const StatePanel(
icon: Icons.hourglass_top, icon: Icons.hourglass_top,
title: 'Loading grants', title: 'Loading grants',
body: 'Pulling grant registry from Arbiter.', body: 'Pulling grant registry from Arbiter.',
busy: true, busy: true,
), ),
AsyncError(:final error) => _StatePanel( AsyncError(:final error) => StatePanel(
icon: Icons.sync_problem, icon: Icons.sync_problem,
title: 'Grant registry unavailable', title: 'Grant registry unavailable',
body: _formatError(error), body: _formatError(error),
actionLabel: 'Retry', actionLabel: 'Retry',
onAction: safeRefresh, onAction: safeRefresh,
), ),
AsyncData(:final value) when value == null => _StatePanel( AsyncData(:final value) when value == null => StatePanel(
icon: Icons.portable_wifi_off, icon: Icons.portable_wifi_off,
title: 'No active server connection', title: 'No active server connection',
body: 'Reconnect to Arbiter to list EVM grants.', body: 'Reconnect to Arbiter to list EVM grants.',
actionLabel: 'Refresh', actionLabel: 'Refresh',
onAction: safeRefresh, onAction: safeRefresh,
), ),
_ when grants != null && grants.isEmpty => _StatePanel( _ when grants != null && grants.isEmpty => StatePanel(
icon: Icons.policy_outlined, icon: Icons.policy_outlined,
title: 'No grants yet', title: 'No grants yet',
body: 'Create a grant to allow SDK clients to sign transactions.', body: 'Create a grant to allow SDK clients to sign transactions.',

View File

@@ -1,6 +1,7 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/theme/palette.dart'; import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart'; import 'package:sizer/sizer.dart';
@@ -32,15 +33,9 @@ class WalletTable extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return CreamFrame(
decoration: BoxDecoration( padding: EdgeInsets.all(2.h),
borderRadius: BorderRadius.circular(24), child: LayoutBuilder(
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: Padding(
padding: EdgeInsets.all(2.h),
child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final tableWidth = math.max(_tableMinWidth, constraints.maxWidth); final tableWidth = math.max(_tableMinWidth, constraints.maxWidth);
@@ -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!),
),
],
],
),
);
}
}