700 lines
23 KiB
Dart
700 lines
23 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../../../core/utils/date_time_utils.dart';
|
|
import '../../domain/entities/pantry_category.dart';
|
|
import '../../domain/entities/pantry_item.dart';
|
|
import '../../domain/entities/shopping_list_item.dart';
|
|
import '../../domain/value_objects/measurement_units.dart';
|
|
import '../controllers/pantry_inventory_controller.dart';
|
|
import '../controllers/shopping_list_controller.dart';
|
|
import '../widgets/add_pantry_item_dialog.dart';
|
|
import '../widgets/add_shopping_item_dialog.dart';
|
|
import '../widgets/add_to_pantry_prompt.dart';
|
|
|
|
/// Tabbed pantry hub: on-hand inventory with quick stock counters on one
|
|
/// side, the meal-plan-synced shopping checklist on the other.
|
|
class PantryScreen extends ConsumerStatefulWidget {
|
|
const PantryScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<PantryScreen> createState() => _PantryScreenState();
|
|
}
|
|
|
|
class _PantryScreenState extends ConsumerState<PantryScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late final TabController _tabController =
|
|
TabController(length: 2, vsync: this);
|
|
|
|
/// Mirrors the controller's index so the FAB rebuilds exactly when the
|
|
/// active tab changes (not on every animation tick).
|
|
int _tabIndex = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController.addListener(_handleTabChange);
|
|
}
|
|
|
|
void _handleTabChange() {
|
|
if (_tabIndex == _tabController.index) return;
|
|
setState(() => _tabIndex = _tabController.index);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final shoppingState = ref.watch(shoppingListControllerProvider);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Pantry & Shopping Delta'),
|
|
actions: [
|
|
if (_tabIndex == 0)
|
|
IconButton(
|
|
tooltip: 'Scan item barcode',
|
|
icon: const Icon(Icons.qr_code_scanner),
|
|
onPressed: _openFoodScanner,
|
|
),
|
|
],
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
tabs: [
|
|
const Tab(
|
|
icon: Icon(Icons.kitchen_outlined),
|
|
text: 'Inventory',
|
|
),
|
|
Tab(
|
|
icon: Badge(
|
|
isLabelVisible: shoppingState.remainingCount > 0,
|
|
label: Text('${shoppingState.remainingCount}'),
|
|
child: const Icon(Icons.shopping_basket_outlined),
|
|
),
|
|
text: 'Shopping List',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
_buildInventoryTab(),
|
|
_buildShoppingTab(),
|
|
],
|
|
),
|
|
floatingActionButton: _tabIndex == 0
|
|
? FloatingActionButton.extended(
|
|
onPressed: _showAddPantryItemDialog,
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Add Pantry Item'),
|
|
)
|
|
: FloatingActionButton.extended(
|
|
onPressed: _showAddShoppingItemDialog,
|
|
icon: const Icon(Icons.add_shopping_cart),
|
|
label: const Text('Add Item'),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showAddPantryItemDialog() async {
|
|
final saved = await showAddPantryItemDialog(context);
|
|
if (saved != true || !mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Added to your pantry.')),
|
|
);
|
|
}
|
|
|
|
/// Routes to the dual-source food search in pantry-add preset: scan or
|
|
/// type a UPC/EAN, resolve nutrition data, and stock the pantry from the
|
|
/// product action sheet.
|
|
void _openFoodScanner() => context.push('/food-search?preset=pantry');
|
|
|
|
Future<void> _showAddShoppingItemDialog() async {
|
|
final saved = await showAddShoppingItemDialog(context);
|
|
if (saved != true || !mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Added to your shopping list.')),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Inventory tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildInventoryTab() {
|
|
final state = ref.watch(pantryInventoryControllerProvider);
|
|
|
|
if (!state.hasLoaded && state.items.isEmpty) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: SegmentedButton<PantrySortMode>(
|
|
segments: const [
|
|
ButtonSegment(
|
|
value: PantrySortMode.category,
|
|
icon: Icon(Icons.category_outlined),
|
|
label: Text('Aisles'),
|
|
),
|
|
ButtonSegment(
|
|
value: PantrySortMode.expiration,
|
|
icon: Icon(Icons.schedule),
|
|
label: Text('Expiration'),
|
|
),
|
|
],
|
|
selected: {state.sortMode},
|
|
onSelectionChanged: (selection) => ref
|
|
.read(pantryInventoryControllerProvider.notifier)
|
|
.setSortMode(selection.first),
|
|
),
|
|
),
|
|
if (state.errorMessage != null)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
child: Text(
|
|
state.errorMessage!,
|
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: state.items.isEmpty
|
|
? _buildEmptyInventory()
|
|
: ListView(
|
|
padding: const EdgeInsets.only(bottom: 88),
|
|
children: state.sortMode == PantrySortMode.category
|
|
? _buildCategorySections(state)
|
|
: [
|
|
for (final item in state.byExpiration)
|
|
_buildInventoryTile(item),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Aisle header + tiles for every non-empty category.
|
|
List<Widget> _buildCategorySections(PantryInventoryState state) {
|
|
final sections = <Widget>[];
|
|
for (final entry in state.groupedByCategory.entries) {
|
|
sections.add(
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
_categoryIcon(entry.key),
|
|
size: 16,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'${entry.key.displayName} · ${entry.value.length}',
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
for (final item in entry.value) {
|
|
sections.add(_buildInventoryTile(item));
|
|
}
|
|
}
|
|
return sections;
|
|
}
|
|
|
|
Widget _buildInventoryTile(PantryItem item) {
|
|
final step = _quickStepFor(item.unit);
|
|
final notifier = ref.read(pantryInventoryControllerProvider.notifier);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
child: Dismissible(
|
|
key: ValueKey('pantry-${item.id}'),
|
|
direction: DismissDirection.endToStart,
|
|
background: ColoredBox(
|
|
color: Theme.of(context).colorScheme.errorContainer,
|
|
child: const Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Padding(
|
|
padding: EdgeInsets.only(right: 20),
|
|
child: Icon(Icons.delete_outline),
|
|
),
|
|
),
|
|
),
|
|
onDismissed: (_) => notifier.deleteItem(item.id),
|
|
child: Card(
|
|
margin: EdgeInsets.zero,
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: BorderSide(
|
|
color: Theme.of(context).colorScheme.outlineVariant,
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 16,
|
|
child: Icon(_categoryIcon(item.category), size: 16),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
if (item.expirationDate != null) ...[
|
|
const SizedBox(height: 2),
|
|
Wrap(
|
|
spacing: 6,
|
|
runSpacing: 2,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
Text(
|
|
'Expires ${formatMonthDay(item.expirationDate!)}',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
_buildExpirationBadge(item),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
visualDensity: VisualDensity.compact,
|
|
tooltip: 'Remove one',
|
|
icon: const Icon(Icons.remove_circle_outline),
|
|
onPressed: () => notifier.adjustQuantity(item, -step),
|
|
),
|
|
Text(
|
|
'${formatQuantityForDisplay(item.quantityOnHand)} ${item.unit}',
|
|
style: Theme.of(context).textTheme.labelMedium,
|
|
),
|
|
IconButton(
|
|
visualDensity: VisualDensity.compact,
|
|
tooltip: 'Add one',
|
|
icon: const Icon(Icons.add_circle_outline),
|
|
onPressed: () => notifier.adjustQuantity(item, step),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Compact warning chip for expired / soon-to-expire stock.
|
|
Widget _buildExpirationBadge(PantryItem item) {
|
|
final status = item.expirationStatus();
|
|
if (status == ExpirationStatus.none || status == ExpirationStatus.fresh) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final Color background;
|
|
final Color foreground;
|
|
if (status == ExpirationStatus.expired) {
|
|
background = scheme.errorContainer;
|
|
foreground = scheme.onErrorContainer;
|
|
} else {
|
|
background = scheme.tertiaryContainer;
|
|
foreground = scheme.onTertiaryContainer;
|
|
}
|
|
final days = item.daysUntilExpiration() ?? 0;
|
|
final String label;
|
|
if (status == ExpirationStatus.expired) {
|
|
label = 'Expired';
|
|
} else if (days <= 0) {
|
|
label = 'Expires today';
|
|
} else if (days == 1) {
|
|
label = 'Expires tomorrow';
|
|
} else {
|
|
label = '$days days left';
|
|
}
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: foreground,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyInventory() => Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.kitchen_outlined,
|
|
size: 48,
|
|
color: Theme.of(context).colorScheme.outline,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Your pantry is empty',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Tap "Add Pantry Item" to stock up.',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
/// Stock step for the quick +/- counters, chosen per unit family.
|
|
double _quickStepFor(String rawUnit) {
|
|
switch (canonicalUnit(rawUnit)) {
|
|
case 'g':
|
|
case 'ml':
|
|
return 50;
|
|
case 'kg':
|
|
case 'l':
|
|
return 0.5;
|
|
case 'lb':
|
|
return 0.25;
|
|
default:
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
IconData _categoryIcon(PantryCategory category) {
|
|
switch (category) {
|
|
case PantryCategory.produce:
|
|
return Icons.local_florist_outlined;
|
|
case PantryCategory.meatSeafood:
|
|
return Icons.set_meal_outlined;
|
|
case PantryCategory.dairy:
|
|
return Icons.icecream_outlined;
|
|
case PantryCategory.dryGoods:
|
|
return Icons.grain_outlined;
|
|
case PantryCategory.spices:
|
|
return Icons.spa_outlined;
|
|
case PantryCategory.other:
|
|
return Icons.kitchen_outlined;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shopping list tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildShoppingTab() {
|
|
final state = ref.watch(shoppingListControllerProvider);
|
|
final controller = ref.read(shoppingListControllerProvider.notifier);
|
|
|
|
if (!state.hasLoaded && state.items.isEmpty && !state.isSyncing) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
return Column(
|
|
children: [
|
|
_buildSyncBanner(state, controller),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
|
child: SegmentedButton<ShoppingListFilter>(
|
|
segments: const [
|
|
ButtonSegment(
|
|
value: ShoppingListFilter.all,
|
|
label: Text('All'),
|
|
),
|
|
ButtonSegment(
|
|
value: ShoppingListFilter.toBuy,
|
|
label: Text('To Buy'),
|
|
),
|
|
ButtonSegment(
|
|
value: ShoppingListFilter.checked,
|
|
label: Text('Checked'),
|
|
),
|
|
],
|
|
selected: {state.filter},
|
|
onSelectionChanged: (selection) =>
|
|
controller.setFilter(selection.first),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: state.visibleItems.isEmpty
|
|
? _buildEmptyShoppingList(state)
|
|
: ListView(
|
|
padding: const EdgeInsets.only(bottom: 88),
|
|
children: [
|
|
for (final item in state.visibleItems)
|
|
_buildShoppingTile(item, controller),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Prominent banner that triggers the meal-plan delta engine.
|
|
Widget _buildSyncBanner(
|
|
ShoppingListState state,
|
|
ShoppingListController controller,
|
|
) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: scheme.secondaryContainer,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.sync_alt, color: scheme.onSecondaryContainer),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Sync with Meal Plan',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleSmall
|
|
?.copyWith(
|
|
color: scheme.onSecondaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
Text(
|
|
"Compare this week's recipes against pantry stock.",
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodySmall
|
|
?.copyWith(color: scheme.onSecondaryContainer),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
FilledButton.tonalIcon(
|
|
onPressed:
|
|
state.isSyncing ? null : () => _runSync(controller),
|
|
icon: state.isSyncing
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.play_arrow),
|
|
label: const Text('Run Sync'),
|
|
),
|
|
],
|
|
),
|
|
if (state.statusMessage != null) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
state.statusMessage!,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: scheme.onSecondaryContainer,
|
|
),
|
|
),
|
|
],
|
|
if (state.errorMessage != null) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
state.errorMessage!,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodySmall
|
|
?.copyWith(color: scheme.error),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _runSync(ShoppingListController controller) async {
|
|
final summary = await controller.syncWithMealPlan();
|
|
if (summary == null || !mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(summary)),
|
|
);
|
|
}
|
|
|
|
Widget _buildShoppingTile(
|
|
ShoppingListItem item,
|
|
ShoppingListController controller,
|
|
) {
|
|
final theme = Theme.of(context);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
child: Dismissible(
|
|
key: ValueKey('shopping-${item.id}'),
|
|
direction: DismissDirection.endToStart,
|
|
background: ColoredBox(
|
|
color: theme.colorScheme.errorContainer,
|
|
child: const Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Padding(
|
|
padding: EdgeInsets.only(right: 20),
|
|
child: Icon(Icons.delete_outline),
|
|
),
|
|
),
|
|
),
|
|
onDismissed: (_) => controller.removeItem(item.id),
|
|
child: Card(
|
|
margin: EdgeInsets.zero,
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: BorderSide(color: theme.colorScheme.outlineVariant),
|
|
),
|
|
child: CheckboxListTile(
|
|
value: item.isPurchased,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
onChanged: (_) => _onShoppingItemToggled(item),
|
|
title: Text(
|
|
'${item.name} · '
|
|
'${formatQuantityForDisplay(item.requiredQuantity)} ${item.unit}',
|
|
style: item.isPurchased
|
|
? theme.textTheme.bodyLarge?.copyWith(
|
|
decoration: TextDecoration.lineThrough,
|
|
color: theme.hintColor,
|
|
)
|
|
: theme.textTheme.bodyLarge,
|
|
),
|
|
subtitle:
|
|
item.isAutoGenerated && item.sourceRecipeTitles.isNotEmpty
|
|
? Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: _buildSourceBadges(item),
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Subtle "From: <recipe>" origin badges for auto-generated rows.
|
|
Widget _buildSourceBadges(ShoppingListItem item) {
|
|
final titles = item.sourceRecipeTitles;
|
|
return Wrap(
|
|
spacing: 6,
|
|
runSpacing: 4,
|
|
children: [
|
|
for (final title in titles.take(2)) _sourceBadge('From: $title'),
|
|
if (titles.length > 2) _sourceBadge('+${titles.length - 2} more'),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _sourceBadge(String label) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: scheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.restaurant_menu,
|
|
size: 12,
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
label,
|
|
style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Check-off flow: ticking an item offers the pantry-stock transfer prompt;
|
|
/// unchecking flips the flag directly.
|
|
Future<void> _onShoppingItemToggled(ShoppingListItem item) async {
|
|
final controller = ref.read(shoppingListControllerProvider.notifier);
|
|
if (item.isPurchased) {
|
|
await controller.togglePurchased(item);
|
|
return;
|
|
}
|
|
final addToPantry = await showAddToPantryPrompt(context, item);
|
|
if (addToPantry == null || !mounted) return;
|
|
await controller.completeItem(item, addToPantry: addToPantry);
|
|
}
|
|
|
|
Widget _buildEmptyShoppingList(ShoppingListState state) {
|
|
final String message;
|
|
final String hint;
|
|
switch (state.filter) {
|
|
case ShoppingListFilter.checked:
|
|
message = 'Nothing checked off yet.';
|
|
hint = 'Tick items when you buy them.';
|
|
case ShoppingListFilter.toBuy:
|
|
message = 'Nothing left to buy.';
|
|
hint = 'Run the sync to pull in missing recipe ingredients.';
|
|
case ShoppingListFilter.all:
|
|
message = 'Your shopping list is clear.';
|
|
hint = 'Run the sync to pull in missing recipe ingredients.';
|
|
}
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.checklist,
|
|
size: 48,
|
|
color: Theme.of(context).colorScheme.outline,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(message, style: Theme.of(context).textTheme.titleMedium),
|
|
const SizedBox(height: 4),
|
|
Text(hint, style: Theme.of(context).textTheme.bodySmall),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|