import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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 createState() => _PantryScreenState(); } class _PantryScreenState extends ConsumerState 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'), 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 _showAddPantryItemDialog() async { final saved = await showAddPantryItemDialog(context); if (saved != true || !mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Added to your pantry.')), ); } Future _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( 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 _buildCategorySections(PantryInventoryState state) { final sections = []; 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( 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 _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: " 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 _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), ], ), ); } }