From 5f8e0a65d7b9c62fa94d74d6018efc0eaeccb003 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 2 Nov 2026 00:07:53 -0500 Subject: [PATCH] Implement shopping lists tab, community feed with cloning, and cook mode stopwatch with smart timers --- mobile/lib/core/api/api_client.dart | 132 ++- mobile/lib/core/models/shopping_list.dart | 129 +++ mobile/lib/core/providers.dart | 156 +++ .../features/cook_mode/cook_mode_screen.dart | 300 ++++- .../lib/features/library/recipes_screen.dart | 1005 ++++++++++++----- .../navigation/main_navigation_shell.dart | 66 ++ .../shopping/shopping_lists_screen.dart | 491 ++++++++ mobile/lib/main.dart | 4 +- 8 files changed, 2002 insertions(+), 281 deletions(-) create mode 100644 mobile/lib/core/models/shopping_list.dart create mode 100644 mobile/lib/features/navigation/main_navigation_shell.dart create mode 100644 mobile/lib/features/shopping/shopping_lists_screen.dart diff --git a/mobile/lib/core/api/api_client.dart b/mobile/lib/core/api/api_client.dart index 2074c61..10f192e 100644 --- a/mobile/lib/core/api/api_client.dart +++ b/mobile/lib/core/api/api_client.dart @@ -217,7 +217,137 @@ class ApiClient { } } - // 7. Logout + // 7. Public Recipes (Community Feed) + Future> getPublicRecipes() async { + try { + final response = await _dio.get('/api/recipes/public'); + final list = response.data as List; + return list.map((json) => Recipe.fromJson(json as Map)).toList(); + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to load community recipes'); + } + } + + // 8. Clone Recipe to User's Library + Future cloneRecipe(String id) async { + try { + final response = await _dio.post('/api/recipes/clone/$id'); + return Recipe.fromJson(response.data as Map); + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to clone recipe'); + } + } + + // 9. Shopping Lists + Future> getShoppingLists() async { + try { + final response = await _dio.get('/api/shopping-lists'); + return response.data as List; + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to load shopping lists'); + } + } + + Future> createShoppingList(String name) async { + try { + final response = await _dio.post('/api/shopping-lists', data: {'name': name}); + return response.data as Map; + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to create shopping list'); + } + } + + Future deleteShoppingList(String id) async { + try { + await _dio.delete('/api/shopping-lists/$id'); + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to delete shopping list'); + } + } + + Future> addShoppingListItems( + String listId, + List items, { + String? recipeId, + String? recipeTitle, + }) async { + try { + final response = await _dio.post( + '/api/shopping-lists/$listId/items', + data: { + 'items': items, + if (recipeId != null) 'recipeId': recipeId, + if (recipeTitle != null) 'recipeTitle': recipeTitle, + }, + ); + return response.data as Map; + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to add items to shopping list'); + } + } + + Future> checkShoppingListItems( + String listId, + List itemIds, + bool checked, + ) async { + try { + final response = await _dio.put( + '/api/shopping-lists/$listId/items/check', + data: { + 'itemIds': itemIds, + 'checked': checked, + }, + ); + return response.data as Map; + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to update item check status'); + } + } + + Future> deleteShoppingListItems(String listId, List itemIds) async { + try { + final response = await _dio.post( + '/api/shopping-lists/$listId/items/delete', + data: {'itemIds': itemIds}, + ); + return response.data as Map; + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to delete items'); + } + } + + Future> clearShoppingList(String listId, {bool onlyChecked = true}) async { + try { + final response = await _dio.post( + '/api/shopping-lists/$listId/clear', + data: {'onlyChecked': onlyChecked}, + ); + return response.data as Map; + } on DioException catch (e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(e.message ?? 'Failed to clear shopping list'); + } + } + + // 10. Logout Future logout() async { try { await _dio.post('/api/auth/logout'); diff --git a/mobile/lib/core/models/shopping_list.dart b/mobile/lib/core/models/shopping_list.dart new file mode 100644 index 0000000..e86678f --- /dev/null +++ b/mobile/lib/core/models/shopping_list.dart @@ -0,0 +1,129 @@ +class ShoppingListItem { + final String id; + final String listId; + final String rawText; + final String? itemName; + final num? quantity; + final String? unit; + final String? category; + final String? recipeId; + final String? recipeTitle; + final bool checked; + final String? createdAt; + + ShoppingListItem({ + required this.id, + required this.listId, + required this.rawText, + this.itemName, + this.quantity, + this.unit, + this.category, + this.recipeId, + this.recipeTitle, + this.checked = false, + this.createdAt, + }); + + factory ShoppingListItem.fromJson(Map json) { + num? qty; + if (json['quantity'] != null) { + if (json['quantity'] is num) { + qty = json['quantity'] as num; + } else { + qty = num.tryParse(json['quantity'].toString()); + } + } + + return ShoppingListItem( + id: json['id'] as String, + listId: json['listId'] as String? ?? '', + rawText: json['rawText'] as String? ?? '', + itemName: json['itemName'] as String?, + quantity: qty, + unit: json['unit'] as String?, + category: json['category'] as String?, + recipeId: json['recipeId'] as String?, + recipeTitle: json['recipeTitle'] as String?, + checked: json['checked'] == true || json['checked'] == 1, + createdAt: json['createdAt'] as String?, + ); + } + + ShoppingListItem copyWith({ + String? id, + String? listId, + String? rawText, + String? itemName, + num? quantity, + String? unit, + String? category, + String? recipeId, + String? recipeTitle, + bool? checked, + String? createdAt, + }) { + return ShoppingListItem( + id: id ?? this.id, + listId: listId ?? this.listId, + rawText: rawText ?? this.rawText, + itemName: itemName ?? this.itemName, + quantity: quantity ?? this.quantity, + unit: unit ?? this.unit, + category: category ?? this.category, + recipeId: recipeId ?? this.recipeId, + recipeTitle: recipeTitle ?? this.recipeTitle, + checked: checked ?? this.checked, + createdAt: createdAt ?? this.createdAt, + ); + } +} + +class ShoppingList { + final String id; + final String name; + final dynamic userId; + final String? createdBy; + final String? shareToken; + final String? createdAt; + final String? updatedAt; + final int visitorCount; + final List items; + + ShoppingList({ + required this.id, + required this.name, + this.userId, + this.createdBy, + this.shareToken, + this.createdAt, + this.updatedAt, + this.visitorCount = 0, + this.items = const [], + }); + + factory ShoppingList.fromJson(Map json) { + var rawItems = json['items']; + List parsedItems = []; + if (rawItems is List) { + parsedItems = rawItems + .map((item) => ShoppingListItem.fromJson(item as Map)) + .toList(); + } + + return ShoppingList( + id: json['id'] as String, + name: json['name'] as String? ?? 'Shopping List', + userId: json['userId'], + createdBy: json['createdBy'] as String?, + shareToken: json['shareToken'] as String?, + createdAt: json['createdAt'] as String?, + updatedAt: json['updatedAt'] as String?, + visitorCount: (json['visitorCount'] as num?)?.toInt() ?? 0, + items: parsedItems, + ); + } + + int get uncheckedCount => items.where((i) => !i.checked).length; + int get checkedCount => items.where((i) => i.checked).length; +} diff --git a/mobile/lib/core/providers.dart b/mobile/lib/core/providers.dart index 462c55e..07aadd0 100644 --- a/mobile/lib/core/providers.dart +++ b/mobile/lib/core/providers.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'api/api_client.dart'; import 'models/recipe.dart'; +import 'models/shopping_list.dart'; import 'storage/secure_storage_service.dart'; final secureStorageProvider = Provider((ref) { @@ -133,3 +134,158 @@ final recipeListProvider = final api = ref.watch(apiClientProvider); return RecipeListNotifier(api); }); + +// Community / Public Recipes State Notifier +class PublicRecipesNotifier extends StateNotifier>> { + final ApiClient _api; + + PublicRecipesNotifier(this._api) : super(const AsyncValue.loading()) { + loadPublicRecipes(); + } + + Future loadPublicRecipes() async { + state = const AsyncValue.loading(); + try { + final recipes = await _api.getPublicRecipes(); + state = AsyncValue.data(recipes); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future cloneRecipe(String id, WidgetRef? ref) async { + final cloned = await _api.cloneRecipe(id); + if (ref != null) { + ref.read(recipeListProvider.notifier).loadRecipes(); + } + return cloned; + } +} + +final publicRecipesProvider = + StateNotifierProvider>>((ref) { + final api = ref.watch(apiClientProvider); + return PublicRecipesNotifier(api); +}); + +// Shopping Lists State Notifier +class ShoppingListsNotifier extends StateNotifier>> { + final ApiClient _api; + String? _activeListId; + + ShoppingListsNotifier(this._api) : super(const AsyncValue.loading()) { + loadLists(); + } + + String? get activeListId => _activeListId; + + void setActiveListId(String id) { + _activeListId = id; + // Trigger notification if data present + if (state.hasValue) { + state = AsyncValue.data([...state.value!]); + } + } + + Future loadLists() async { + state = const AsyncValue.loading(); + try { + final data = await _api.getShoppingLists(); + final lists = data.map((json) => ShoppingList.fromJson(json as Map)).toList(); + if (lists.isNotEmpty && (_activeListId == null || !lists.any((l) => l.id == _activeListId))) { + _activeListId = lists.first.id; + } + state = AsyncValue.data(lists); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future createList(String name) async { + final res = await _api.createShoppingList(name); + final created = ShoppingList.fromJson(res); + _activeListId = created.id; + final current = state.value ?? []; + state = AsyncValue.data([...current, created]); + } + + Future deleteList(String id) async { + await _api.deleteShoppingList(id); + final current = (state.value ?? []).where((l) => l.id != id).toList(); + if (_activeListId == id) { + _activeListId = current.isNotEmpty ? current.first.id : null; + } + state = AsyncValue.data(current); + } + + Future toggleItemCheck(String listId, String itemId, bool checked) async { + // Optimistic local update + final current = state.value ?? []; + final listIdx = current.indexWhere((l) => l.id == listId); + if (listIdx >= 0) { + final list = current[listIdx]; + final itemIdx = list.items.indexWhere((i) => i.id == itemId); + if (itemIdx >= 0) { + final updatedItems = [...list.items]; + updatedItems[itemIdx] = updatedItems[itemIdx].copyWith(checked: checked); + current[listIdx] = ShoppingList( + id: list.id, + name: list.name, + userId: list.userId, + createdBy: list.createdBy, + shareToken: list.shareToken, + createdAt: list.createdAt, + updatedAt: DateTime.now().toIso8601String(), + visitorCount: list.visitorCount, + items: updatedItems, + ); + state = AsyncValue.data([...current]); + } + } + + try { + final updatedJson = await _api.checkShoppingListItems(listId, [itemId], checked); + final updatedList = ShoppingList.fromJson(updatedJson); + _replaceList(updatedList); + } catch (e) { + // Reload on failure to revert optimistic update + loadLists(); + rethrow; + } + } + + Future addItems(String listId, List items, {String? recipeId, String? recipeTitle}) async { + final updatedJson = await _api.addShoppingListItems(listId, items, recipeId: recipeId, recipeTitle: recipeTitle); + final updatedList = ShoppingList.fromJson(updatedJson); + _replaceList(updatedList); + } + + Future deleteItems(String listId, List itemIds) async { + final updatedJson = await _api.deleteShoppingListItems(listId, itemIds); + final updatedList = ShoppingList.fromJson(updatedJson); + _replaceList(updatedList); + } + + Future clearList(String listId, {bool onlyChecked = true}) async { + final updatedJson = await _api.clearShoppingList(listId, onlyChecked: onlyChecked); + final updatedList = ShoppingList.fromJson(updatedJson); + _replaceList(updatedList); + } + + void _replaceList(ShoppingList updated) { + final current = state.value ?? []; + final idx = current.indexWhere((l) => l.id == updated.id); + if (idx >= 0) { + current[idx] = updated; + state = AsyncValue.data([...current]); + } else { + state = AsyncValue.data([...current, updated]); + } + } +} + +final shoppingListsProvider = + StateNotifierProvider>>((ref) { + final api = ref.watch(apiClientProvider); + return ShoppingListsNotifier(api); +}); diff --git a/mobile/lib/features/cook_mode/cook_mode_screen.dart b/mobile/lib/features/cook_mode/cook_mode_screen.dart index 37f9dd1..3345256 100644 --- a/mobile/lib/features/cook_mode/cook_mode_screen.dart +++ b/mobile/lib/features/cook_mode/cook_mode_screen.dart @@ -1,9 +1,17 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import '../../core/constants/app_theme.dart'; import '../../core/models/recipe.dart'; +class DetectedDuration { + final int totalMinutes; + final String label; + + DetectedDuration({required this.totalMinutes, required this.label}); +} + class CookModeScreen extends StatefulWidget { final Recipe recipe; @@ -18,10 +26,17 @@ class _CookModeScreenState extends State { double _scaleMultiplier = 1.0; final PageController _pageController = PageController(); - // Simple Timer State + // Countdown Timer State Timer? _activeTimer; int _timerSecondsRemaining = 0; bool _isTimerRunning = false; + int _currentTimerMinutes = 0; + + // Stopwatch Mode State + Timer? _stopwatchTimer; + int _stopwatchSeconds = 0; + bool _isStopwatchRunning = false; + bool _showStopwatch = false; @override void initState() { @@ -34,13 +49,16 @@ class _CookModeScreenState extends State { void dispose() { WakelockPlus.disable(); _activeTimer?.cancel(); + _stopwatchTimer?.cancel(); _pageController.dispose(); super.dispose(); } void _startTimer(int minutes) { _activeTimer?.cancel(); + HapticFeedback.mediumImpact(); setState(() { + _currentTimerMinutes = minutes; _timerSecondsRemaining = minutes * 60; _isTimerRunning = true; }); @@ -52,6 +70,7 @@ class _CookModeScreenState extends State { _isTimerRunning = false; _timerSecondsRemaining = 0; }); + HapticFeedback.vibrate(); if (mounted) { showDialog( context: context, @@ -76,15 +95,163 @@ class _CookModeScreenState extends State { }); } + void _toggleStopwatch() { + HapticFeedback.lightImpact(); + if (_isStopwatchRunning) { + _stopwatchTimer?.cancel(); + setState(() => _isStopwatchRunning = false); + } else { + setState(() => _isStopwatchRunning = true); + _stopwatchTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) { + setState(() => _stopwatchSeconds++); + } + }); + } + } + + void _resetStopwatch() { + HapticFeedback.lightImpact(); + _stopwatchTimer?.cancel(); + setState(() { + _isStopwatchRunning = false; + _stopwatchSeconds = 0; + }); + } + String _formatTimerTime(int totalSeconds) { - final m = totalSeconds ~/ 60; + final h = totalSeconds ~/ 3600; + final m = (totalSeconds % 3600) ~/ 60; final s = totalSeconds % 60; + if (h > 0) { + return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + } return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; } + List _detectDurationsInStep(String text) { + final List results = []; + final regex = RegExp( + r'(?:for\s+)?(\d+(?:\.\d+)?)\s*(?:-|to)?\s*(\d+(?:\.\d+)?)?\s*(hours?|hrs?|minutes?|mins?|seconds?|secs?)\b', + caseSensitive: false, + ); + + final matches = regex.allMatches(text); + for (final match in matches) { + final firstNum = match.group(1); + final secondNum = match.group(2); + final unit = match.group(3)?.toLowerCase() ?? ''; + + final numStr = secondNum ?? firstNum; + if (numStr == null) continue; + final val = double.tryParse(numStr); + if (val == null || val <= 0) continue; + + int minutes = 0; + if (unit.startsWith('h')) { + minutes = (val * 60).round(); + } else if (unit.startsWith('s')) { + minutes = (val / 60).ceil(); + if (minutes < 1) minutes = 1; + } else { + minutes = val.round(); + } + + if (minutes > 0 && !results.any((d) => d.totalMinutes == minutes)) { + results.add(DetectedDuration( + totalMinutes: minutes, + label: '${match.group(0)?.trim()}', + )); + } + } + return results; + } + + void _showIngredientsSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => DraggableScrollableSheet( + initialChildSize: 0.65, + minChildSize: 0.4, + maxChildSize: 0.9, + builder: (_, controller) => Container( + decoration: const BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: AppTheme.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Row( + children: [ + const Icon(Icons.restaurant_menu_rounded, color: AppTheme.primary, size: 22), + const SizedBox(width: 8), + Text( + 'Ingredients (${_scaleMultiplier}x)', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + const SizedBox(height: 12), + Expanded( + child: widget.recipe.ingredients.isEmpty + ? const Center(child: Text('No ingredients found.', style: TextStyle(color: AppTheme.textMuted))) + : ListView.separated( + controller: controller, + itemCount: widget.recipe.ingredients.length, + separatorBuilder: (_, __) => const Divider(color: AppTheme.border, height: 1), + itemBuilder: (c, idx) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('• ', style: TextStyle(color: AppTheme.primary, fontSize: 16)), + Expanded( + child: Text( + widget.recipe.ingredients[idx], + style: const TextStyle(fontSize: 15, color: Colors.white, height: 1.3), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final totalSteps = widget.recipe.instructions.isNotEmpty ? widget.recipe.instructions.length : 1; + final currentStepText = (widget.recipe.instructions.isNotEmpty && _currentStepIndex < widget.recipe.instructions.length) + ? widget.recipe.instructions[_currentStepIndex] + : ''; + final detectedTimers = _detectDurationsInStep(currentStepText); return Scaffold( backgroundColor: Colors.black, @@ -92,6 +259,25 @@ class _CookModeScreenState extends State { backgroundColor: Colors.black, title: Text(widget.recipe.title, maxLines: 1, overflow: TextOverflow.ellipsis), actions: [ + // Ingredients Peek Sheet Button + IconButton( + tooltip: 'View Ingredients', + icon: const Icon(Icons.restaurant_menu_rounded, color: AppTheme.secondary), + onPressed: _showIngredientsSheet, + ), + // Stopwatch Toggle Button + IconButton( + tooltip: 'Stopwatch', + icon: Icon( + Icons.timer_outlined, + color: _isStopwatchRunning || _showStopwatch ? AppTheme.primary : Colors.white70, + ), + onPressed: () { + setState(() { + _showStopwatch = !_showStopwatch; + }); + }, + ), // Scale selector PopupMenuButton( initialValue: _scaleMultiplier, @@ -122,13 +308,13 @@ class _CookModeScreenState extends State { body: SafeArea( child: Column( children: [ - // Top: Active Timer Pill (if active) + // Top: Active Countdown Timer Pill (if active) if (_isTimerRunning || _timerSecondsRemaining > 0) Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( - color: AppTheme.primary.withOpacity(0.2), + color: AppTheme.primary.withOpacity(0.15), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppTheme.primary), ), @@ -155,6 +341,43 @@ class _CookModeScreenState extends State { ), ), + // Top: Stopwatch Pill (if shown or running) + if (_showStopwatch || _isStopwatchRunning || _stopwatchSeconds > 0) + Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.secondary), + ), + child: Row( + children: [ + const Icon(Icons.av_timer_rounded, color: AppTheme.secondary, size: 22), + const SizedBox(width: 10), + Text( + 'Stopwatch: ${_formatTimerTime(_stopwatchSeconds)}', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: AppTheme.secondary), + ), + const Spacer(), + IconButton( + visualDensity: VisualDensity.compact, + icon: Icon( + _isStopwatchRunning ? Icons.pause_circle_filled_rounded : Icons.play_circle_fill_rounded, + color: AppTheme.primary, + size: 26, + ), + onPressed: _toggleStopwatch, + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.restart_alt_rounded, color: AppTheme.textMuted, size: 22), + onPressed: _resetStopwatch, + ), + ], + ), + ), + // Step Progress Bar Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), @@ -179,31 +402,66 @@ class _CookModeScreenState extends State { onPageChanged: (i) => setState(() => _currentStepIndex = i), itemBuilder: (ctx, index) { final stepText = widget.recipe.instructions[index]; + final stepDurations = _detectDurationsInStep(stepText); + return Padding( padding: const EdgeInsets.all(24.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'STEP ${index + 1} OF $totalSteps', - style: const TextStyle( - color: AppTheme.primary, - fontSize: 14, - fontWeight: FontWeight.w800, - letterSpacing: 1.2, - ), + Row( + children: [ + Text( + 'STEP ${index + 1} OF $totalSteps', + style: const TextStyle( + color: AppTheme.primary, + fontSize: 14, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + ), + ), + ], ), const SizedBox(height: 16), Expanded( child: SingleChildScrollView( - child: Text( - stepText, - style: const TextStyle( - color: Colors.white, - fontSize: 22, - height: 1.5, - fontWeight: FontWeight.w500, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + stepText, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + height: 1.5, + fontWeight: FontWeight.w500, + ), + ), + // Smart Auto-detected Timers for this step + if (stepDurations.isNotEmpty) ...[ + const SizedBox(height: 20), + Wrap( + spacing: 8, + runSpacing: 8, + children: stepDurations.map((d) { + return ActionChip( + backgroundColor: AppTheme.primary.withOpacity(0.18), + side: const BorderSide(color: AppTheme.primary), + avatar: const Icon(Icons.bolt_rounded, size: 16, color: AppTheme.primary), + label: Text( + 'Start ${d.totalMinutes}m Timer', + style: const TextStyle( + color: AppTheme.primary, + fontWeight: FontWeight.w700, + fontSize: 13, + ), + ), + onPressed: () => _startTimer(d.totalMinutes), + ); + }).toList(), + ), + ], + ], ), ), ), diff --git a/mobile/lib/features/library/recipes_screen.dart b/mobile/lib/features/library/recipes_screen.dart index 3b05130..62fb934 100644 --- a/mobile/lib/features/library/recipes_screen.dart +++ b/mobile/lib/features/library/recipes_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/constants/app_theme.dart'; import '../../core/models/recipe.dart'; +import '../../core/models/shopping_list.dart'; import '../../core/providers.dart'; import '../cook_mode/cook_mode_screen.dart'; import '../editor/recipe_edit_screen.dart'; @@ -19,6 +20,8 @@ class RecipesScreen extends ConsumerStatefulWidget { class _RecipesScreenState extends ConsumerState { final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; + static const MethodChannel _shareChannel = MethodChannel('com.forkfolio.mobile/native_share'); + int _selectedTab = 0; // 0: My Cookbook, 1: Community Feed @override void dispose() { @@ -26,18 +29,106 @@ class _RecipesScreenState extends ConsumerState { super.dispose(); } + Future _shareRecipe(Recipe recipe) async { + final buffer = StringBuffer(); + buffer.writeln('${recipe.title}\n'); + if (recipe.description.isNotEmpty) { + buffer.writeln('${recipe.description}\n'); + } + if (recipe.ingredients.isNotEmpty) { + buffer.writeln('Ingredients:'); + for (final ing in recipe.ingredients) { + buffer.writeln('• $ing'); + } + buffer.writeln(); + } + if (recipe.instructions.isNotEmpty) { + buffer.writeln('Instructions:'); + for (var i = 0; i < recipe.instructions.length; i++) { + buffer.writeln('${i + 1}. ${recipe.instructions[i]}'); + } + buffer.writeln(); + } + if (recipe.shareToken != null && recipe.shareToken!.isNotEmpty) { + final storage = ref.read(secureStorageProvider); + final serverUrl = await storage.getServerUrl(); + if (serverUrl != null) { + buffer.writeln('View online: $serverUrl/recipes/share/${recipe.shareToken}'); + } + } + + try { + await _shareChannel.invokeMethod('shareText', { + 'title': recipe.title, + 'text': buffer.toString().trim(), + }); + } on MissingPluginException { + await Clipboard.setData(ClipboardData(text: buffer.toString().trim())); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + backgroundColor: AppTheme.surfaceElevated, + content: Text('Recipe text copied to clipboard!'), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: AppTheme.danger, + content: Text('Failed to share: $e'), + ), + ); + } + } + } + + Future _cloneRecipe(Recipe recipe) async { + if (recipe.id == null) return; + try { + final cloned = await ref.read(publicRecipesProvider.notifier).cloneRecipe(recipe.id!, ref); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: AppTheme.surfaceElevated, + content: Text('Cloned "${cloned.title}" to your private library!'), + action: SnackBarAction( + label: 'VIEW', + textColor: AppTheme.primary, + onPressed: () { + setState(() => _selectedTab = 0); + }, + ), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: AppTheme.danger, + content: Text('Failed to clone recipe: $e'), + ), + ); + } + } + } + @override Widget build(BuildContext context) { final recipesAsync = ref.watch(recipeListProvider); + final publicRecipesAsync = ref.watch(publicRecipesProvider); final user = ref.watch(authProvider).user; + final currentUsername = user?['username']?.toString().toLowerCase(); return Scaffold( appBar: AppBar( - title: Row( + title: const Row( children: [ - const Icon(Icons.fork_right_rounded, color: AppTheme.primary, size: 24), - const SizedBox(width: 8), - const Text('ForkFolio'), + Icon(Icons.fork_right_rounded, color: AppTheme.primary, size: 24), + SizedBox(width: 8), + Text('ForkFolio', style: TextStyle(fontWeight: FontWeight.w700)), ], ), actions: [ @@ -55,28 +146,86 @@ class _RecipesScreenState extends ConsumerState { ), ], ), - floatingActionButton: FloatingActionButton.extended( - backgroundColor: AppTheme.primary, - foregroundColor: Colors.black, - icon: const Icon(Icons.add_rounded), - label: const Text('New Recipe', style: TextStyle(fontWeight: FontWeight.w700)), - onPressed: () { - Navigator.push(context, MaterialPageRoute(builder: (_) => const RecipeEditScreen())); - }, - ), + floatingActionButton: _selectedTab == 0 + ? FloatingActionButton.extended( + backgroundColor: AppTheme.primary, + foregroundColor: Colors.black, + icon: const Icon(Icons.add_rounded), + label: const Text('New Recipe', style: TextStyle(fontWeight: FontWeight.w700)), + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const RecipeEditScreen())); + }, + ) + : null, body: RefreshIndicator( color: AppTheme.primary, - onRefresh: () => ref.read(recipeListProvider.notifier).loadRecipes(), + onRefresh: () async { + if (_selectedTab == 0) { + await ref.read(recipeListProvider.notifier).loadRecipes(); + } else { + await ref.read(publicRecipesProvider.notifier).loadPublicRecipes(); + } + }, child: Column( children: [ + // Tab Selector: My Cookbook vs Community Feed + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + Expanded( + child: ChoiceChip( + selected: _selectedTab == 0, + label: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.bookmark_outline_rounded, size: 16), + SizedBox(width: 6), + Text('My Cookbook', style: TextStyle(fontWeight: FontWeight.w600)), + ], + ), + selectedColor: AppTheme.primary, + backgroundColor: AppTheme.surfaceElevated, + side: BorderSide(color: _selectedTab == 0 ? AppTheme.primary : AppTheme.border), + labelStyle: TextStyle( + color: _selectedTab == 0 ? Colors.black : Colors.white70, + ), + onSelected: (_) => setState(() => _selectedTab = 0), + ), + ), + const SizedBox(width: 10), + Expanded( + child: ChoiceChip( + selected: _selectedTab == 1, + label: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.explore_outlined, size: 16), + SizedBox(width: 6), + Text('Community Feed', style: TextStyle(fontWeight: FontWeight.w600)), + ], + ), + selectedColor: AppTheme.primary, + backgroundColor: AppTheme.surfaceElevated, + side: BorderSide(color: _selectedTab == 1 ? AppTheme.primary : AppTheme.border), + labelStyle: TextStyle( + color: _selectedTab == 1 ? Colors.black : Colors.white70, + ), + onSelected: (_) => setState(() => _selectedTab = 1), + ), + ), + ], + ), + ), + // Search Bar Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: TextField( controller: _searchController, onChanged: (val) => setState(() => _searchQuery = val.trim().toLowerCase()), decoration: InputDecoration( - hintText: 'Search recipes, ingredients...', + hintText: _selectedTab == 0 ? 'Search my recipes, ingredients...' : 'Search community recipes...', prefixIcon: const Icon(Icons.search_rounded, size: 20), suffixIcon: _searchQuery.isNotEmpty ? IconButton( @@ -91,204 +240,305 @@ class _RecipesScreenState extends ConsumerState { ), ), - // Recipe List + // Recipe List (Active Tab) Expanded( - child: recipesAsync.when( - loading: () => const Center( - child: CircularProgressIndicator(color: AppTheme.primary), + child: _selectedTab == 0 + ? _buildRecipeList(recipesAsync, isCommunity: false, currentUsername: currentUsername) + : _buildRecipeList(publicRecipesAsync, isCommunity: true, currentUsername: currentUsername), + ), + ], + ), + ), + ); + } + + Widget _buildRecipeList( + AsyncValue> stateAsync, { + required bool isCommunity, + required String? currentUsername, + }) { + return stateAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: AppTheme.primary), + ), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.cloud_off_rounded, color: AppTheme.textMuted, size: 48), + const SizedBox(height: 12), + Text( + 'Could not load recipes: $err', + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.textMuted), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + if (isCommunity) { + ref.read(publicRecipesProvider.notifier).loadPublicRecipes(); + } else { + ref.read(recipeListProvider.notifier).loadRecipes(); + } + }, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Retry'), + ), + ], + ), + ), + ), + data: (recipes) { + final filtered = recipes.where((r) { + if (_searchQuery.isEmpty) return true; + final matchesTitle = r.title.toLowerCase().contains(_searchQuery); + final matchesIngredient = r.ingredients.any((i) => i.toLowerCase().contains(_searchQuery)); + final matchesAuthor = (r.createdBy ?? '').toLowerCase().contains(_searchQuery); + return matchesTitle || matchesIngredient || matchesAuthor; + }).toList(); + + if (filtered.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isCommunity ? Icons.explore_off_outlined : Icons.menu_book_rounded, + color: AppTheme.textDisabled.withOpacity(0.5), + size: 64, ), - error: (err, _) => Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.cloud_off_rounded, color: AppTheme.textMuted, size: 48), - const SizedBox(height: 12), - Text( - 'Could not load recipes: $err', - textAlign: TextAlign.center, - style: const TextStyle(color: AppTheme.textMuted), + const SizedBox(height: 14), + Text( + _searchQuery.isEmpty + ? (isCommunity ? 'No Public Recipes Found' : 'Your Cookbook is Empty') + : 'No recipes match "$_searchQuery"', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.textMuted), + ), + const SizedBox(height: 8), + if (!isCommunity && _searchQuery.isEmpty) + ElevatedButton.icon( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const RecipeEditScreen())); + }, + icon: const Icon(Icons.add), + label: const Text('Add Your First Recipe'), + ), + ], + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: filtered.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (ctx, index) { + final recipe = filtered[index]; + final isOwnRecipe = currentUsername != null && + recipe.createdBy != null && + recipe.createdBy!.toLowerCase() == currentUsername; + + return Card( + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => _openRecipeDetail(recipe, isCommunity: isCommunity, isOwnRecipe: isOwnRecipe), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + // Recipe Thumbnail + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + width: 80, + height: 80, + color: AppTheme.surfaceElevated, + child: recipe.image != null && recipe.image!.isNotEmpty + ? (recipe.image!.startsWith('data:image') + ? Image.memory( + base64Decode(recipe.image!.split(',').last), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon(Icons.restaurant, color: AppTheme.textMuted), + ) + : Image.network( + recipe.image!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon(Icons.restaurant, color: AppTheme.textMuted), + )) + : const Icon(Icons.restaurant, color: AppTheme.textMuted), ), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: () => ref.read(recipeListProvider.notifier).loadRecipes(), - icon: const Icon(Icons.refresh_rounded), - label: const Text('Retry'), + ), + const SizedBox(width: 14), + // Recipe Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recipe.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + if (isCommunity && recipe.createdBy != null && recipe.createdBy!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + const Icon(Icons.person_pin_rounded, size: 13, color: AppTheme.secondary), + const SizedBox(width: 4), + Text( + '@${recipe.createdBy}', + style: const TextStyle(fontSize: 12, color: AppTheme.secondary, fontWeight: FontWeight.w600), + ), + ], + ), + ), + Text( + recipe.description.isNotEmpty + ? recipe.description + : '${recipe.ingredients.length} ingredients', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, color: AppTheme.textMuted), + ), + const SizedBox(height: 6), + Row( + children: [ + if (recipe.calculatedTotalTime.isNotEmpty) ...[ + const Icon(Icons.timer_outlined, size: 14, color: AppTheme.secondary), + const SizedBox(width: 4), + Text( + recipe.calculatedTotalTime, + style: const TextStyle(fontSize: 12, color: AppTheme.secondary, fontWeight: FontWeight.w600), + ), + const SizedBox(width: 12), + ], + const Icon(Icons.format_list_bulleted_rounded, size: 14, color: AppTheme.textMuted), + const SizedBox(width: 4), + Text( + '${recipe.ingredients.length} items', + style: const TextStyle(fontSize: 12, color: AppTheme.textMuted), + ), + ], + ), + ], ), - ], - ), + ), + // Action icon + if (isCommunity && !isOwnRecipe) + IconButton( + tooltip: 'Clone to My Library', + icon: const Icon(Icons.bookmark_add_outlined, color: AppTheme.primary), + onPressed: () => _cloneRecipe(recipe), + ) + else + const Icon(Icons.chevron_right_rounded, color: AppTheme.textMuted), + ], ), ), - data: (recipes) { - final filtered = recipes.where((r) { - if (_searchQuery.isEmpty) return true; - final matchesTitle = r.title.toLowerCase().contains(_searchQuery); - final matchesIngredient = r.ingredients.any((i) => i.toLowerCase().contains(_searchQuery)); - return matchesTitle || matchesIngredient; - }).toList(); - - if (filtered.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.menu_book_rounded, color: AppTheme.textDisabled.withOpacity(0.5), size: 64), - const SizedBox(height: 14), - Text( - _searchQuery.isEmpty ? 'Your Cookbook is Empty' : 'No recipes match "$_searchQuery"', - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.textMuted), - ), - const SizedBox(height: 6), - const Text( - 'Share a link from Instagram or tap the camera icon to scan a recipe.', - style: TextStyle(fontSize: 12, color: AppTheme.textDisabled), - ), - ], - ), - ); - } - - return ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - itemCount: filtered.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (ctx, index) { - final recipe = filtered[index]; - return _buildRecipeCard(recipe); - }, - ); - }, ), - ), - ], - ), - ), - floatingActionButton: FloatingActionButton.extended( - backgroundColor: AppTheme.primary, - onPressed: () { - Navigator.push(context, MaterialPageRoute(builder: (_) => const ScannerScreen())); - }, - icon: const Icon(Icons.camera_enhance_rounded, color: Colors.white), - label: const Text('Scan Recipe', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700)), - ), + ); + }, + ); + }, ); } - Widget _buildRecipeCard(Recipe recipe) { - return Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => _openRecipeDetail(recipe), - child: Row( - children: [ - // Thumbnail - Container( - width: 100, - height: 100, - color: AppTheme.surfaceElevated, - child: recipe.image != null && recipe.image!.isNotEmpty - ? (recipe.image!.startsWith('data:image') - ? Image.memory(base64Decode(recipe.image!.split(',').last), fit: BoxFit.cover) - : Image.network( - recipe.image!, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon(Icons.restaurant_rounded, color: AppTheme.textDisabled), - )) - : const Icon(Icons.restaurant_rounded, color: AppTheme.textDisabled, size: 32), - ), - - // Info - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - recipe.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppTheme.textMain), - ), - const SizedBox(height: 8), - Row( - children: [ - if (recipe.calculatedTotalTime.isNotEmpty) ...[ - const Icon(Icons.schedule_rounded, size: 14, color: AppTheme.primary), - const SizedBox(width: 4), - Text(recipe.calculatedTotalTime, style: const TextStyle(fontSize: 12, color: AppTheme.textMuted)), - const SizedBox(width: 12), - ], - if (recipe.servings != null && recipe.servings!.isNotEmpty) ...[ - const Icon(Icons.people_outline_rounded, size: 14, color: AppTheme.secondary), - const SizedBox(width: 4), - Text(recipe.servings!, style: const TextStyle(fontSize: 12, color: AppTheme.textMuted)), - ], - ], - ), - ], - ), - ), - ), - - // Action - IconButton( - icon: const Icon(Icons.outdoor_grill_rounded, color: AppTheme.primary), - tooltip: 'Start Cook Mode', - onPressed: () { - Navigator.push(context, MaterialPageRoute(builder: (_) => CookModeScreen(recipe: recipe))); - }, - ), - ], - ), - ), - ); - } - - void _openRecipeDetail(Recipe recipe) { + void _openRecipeDetail(Recipe recipe, {bool isCommunity = false, bool isOwnRecipe = true}) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (ctx) => DraggableScrollableSheet( - initialChildSize: 0.75, - maxChildSize: 0.95, + initialChildSize: 0.85, minChildSize: 0.5, + maxChildSize: 0.95, builder: (_, scrollController) => Container( decoration: const BoxDecoration( color: AppTheme.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: ListView( controller: scrollController, + padding: const EdgeInsets.all(20), children: [ Center( child: Container( - width: 44, + width: 40, height: 4, - decoration: BoxDecoration(color: AppTheme.border, borderRadius: BorderRadius.circular(2)), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: AppTheme.border, + borderRadius: BorderRadius.circular(2), + ), ), ), + // Header image if present + if (recipe.image != null && recipe.image!.isNotEmpty) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: recipe.image!.startsWith('data:image') + ? Image.memory( + base64Decode(recipe.image!.split(',').last), + height: 200, + width: double.infinity, + fit: BoxFit.cover, + ) + : Image.network( + recipe.image!, + height: 200, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + const SizedBox(height: 16), + ], + Text( + recipe.title, + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800), + ), + if (isCommunity && recipe.createdBy != null) ...[ + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.person_outline, size: 15, color: AppTheme.secondary), + const SizedBox(width: 4), + Text( + 'Shared by @${recipe.createdBy}', + style: const TextStyle(fontSize: 13, color: AppTheme.secondary, fontWeight: FontWeight.w600), + ), + ], + ), + ], + if (recipe.description.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + recipe.description, + style: const TextStyle(fontSize: 14, color: AppTheme.textMuted), + ), + ], const SizedBox(height: 16), - Text(recipe.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)), - const SizedBox(height: 12), + // Timing & servings metadata badges Wrap( spacing: 8, runSpacing: 8, children: [ - if (recipe.servings != null && recipe.servings!.isNotEmpty) - _buildMetaChip(Icons.people_outline_rounded, '${recipe.servings} Servings', AppTheme.secondary), - if (recipe.prepTime != null && recipe.prepTime!.isNotEmpty) - _buildMetaChip(Icons.schedule_rounded, 'Prep: ${recipe.prepTime}', AppTheme.primary), - if (recipe.cookTime != null && recipe.cookTime!.isNotEmpty) - _buildMetaChip(Icons.local_fire_department_rounded, 'Cook: ${recipe.cookTime}', Colors.amber), + if (recipe.servings.isNotEmpty) + _buildMetaChip(Icons.people_outline, 'Servings: ${recipe.servings}'), + if (recipe.prepTime.isNotEmpty) + _buildMetaChip(Icons.kitchen_outlined, 'Prep: ${recipe.prepTime}'), + if (recipe.cookTime.isNotEmpty) + _buildMetaChip(Icons.timer_outlined, 'Cook: ${recipe.cookTime}'), if (recipe.calculatedTotalTime.isNotEmpty) - _buildMetaChip(Icons.timer_rounded, 'Total: ${recipe.calculatedTotalTime}', Colors.tealAccent), + _buildMetaChip(Icons.schedule_rounded, 'Total: ${recipe.calculatedTotalTime}', isHighlighted: true), ], ), const SizedBox(height: 16), + // Main Action Buttons Row( children: [ Expanded( @@ -304,24 +554,45 @@ class _RecipesScreenState extends ConsumerState { ), ), ), - const SizedBox(width: 8), - Container( - height: 48, - width: 48, - decoration: BoxDecoration( - color: AppTheme.surfaceElevated, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.border), + if (isCommunity && !isOwnRecipe) ...[ + const SizedBox(width: 8), + Container( + height: 48, + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.primary), + ), + child: IconButton( + tooltip: 'Clone to My Library', + icon: const Icon(Icons.bookmark_add_rounded, color: AppTheme.primary, size: 22), + onPressed: () { + Navigator.pop(ctx); + _cloneRecipe(recipe); + }, + ), ), - child: IconButton( - tooltip: 'Edit Recipe', - icon: const Icon(Icons.edit_outlined, color: AppTheme.secondary, size: 20), - onPressed: () { - Navigator.pop(ctx); - Navigator.push(context, MaterialPageRoute(builder: (_) => RecipeEditScreen(recipe: recipe))); - }, + ], + if (!isCommunity || isOwnRecipe) ...[ + const SizedBox(width: 8), + Container( + height: 48, + width: 48, + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.border), + ), + child: IconButton( + tooltip: 'Edit Recipe', + icon: const Icon(Icons.edit_outlined, color: AppTheme.secondary, size: 20), + onPressed: () { + Navigator.pop(ctx); + Navigator.push(context, MaterialPageRoute(builder: (_) => RecipeEditScreen(recipe: recipe))); + }, + ), ), - ), + ], const SizedBox(width: 8), Container( height: 48, @@ -337,32 +608,50 @@ class _RecipesScreenState extends ConsumerState { onPressed: () => _shareRecipe(recipe), ), ), - const SizedBox(width: 8), - Container( - height: 48, - width: 48, - decoration: BoxDecoration( - color: AppTheme.surfaceElevated, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.border), + if (!isCommunity || isOwnRecipe) ...[ + const SizedBox(width: 8), + Container( + height: 48, + width: 48, + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.border), + ), + child: IconButton( + tooltip: 'Delete Recipe', + icon: const Icon(Icons.delete_outline_rounded, color: AppTheme.danger, size: 20), + onPressed: () => _confirmDelete(ctx, recipe), + ), ), - child: IconButton( - tooltip: 'Delete Recipe', - icon: const Icon(Icons.delete_outline_rounded, color: AppTheme.danger, size: 20), - onPressed: () => _confirmDelete(ctx, recipe), - ), - ), + ], ], ), const SizedBox(height: 24), - const Text('Ingredients', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.primary)), + // Ingredients Header with "Add to Shopping List" + Row( + children: [ + const Text('Ingredients', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.primary)), + const Spacer(), + if (recipe.ingredients.isNotEmpty) + TextButton.icon( + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + foregroundColor: AppTheme.secondary, + ), + icon: const Icon(Icons.add_shopping_cart_rounded, size: 16), + label: const Text('Add to List', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700)), + onPressed: () => _showAddToShoppingListSheet(ctx, recipe), + ), + ], + ), const SizedBox(height: 8), ...recipe.ingredients.map((ing) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('• ', style: TextStyle(color: AppTheme.primary, fontWeight: FontWeight.w800)), + const Text('• ', style: TextStyle(color: AppTheme.primary, fontSize: 16)), Expanded(child: Text(ing, style: const TextStyle(fontSize: 14))), ], ), @@ -375,7 +664,15 @@ class _RecipesScreenState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('${entry.key + 1}. ', style: const TextStyle(color: AppTheme.primary, fontWeight: FontWeight.w700)), + CircleAvatar( + radius: 10, + backgroundColor: AppTheme.surfaceElevated, + child: Text( + '${entry.key + 1}', + style: const TextStyle(fontSize: 10, color: AppTheme.primary, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 10), Expanded(child: Text(entry.value, style: const TextStyle(fontSize: 14, height: 1.4))), ], ), @@ -387,71 +684,32 @@ class _RecipesScreenState extends ConsumerState { ); } - Widget _buildMetaChip(IconData icon, String label, Color color) { + Widget _buildMetaChip(IconData icon, String label, {bool isHighlighted = false}) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: color.withOpacity(0.12), + color: isHighlighted ? AppTheme.primary.withOpacity(0.15) : AppTheme.surfaceElevated, borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withOpacity(0.3)), + border: Border.all(color: isHighlighted ? AppTheme.primary : AppTheme.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 14, color: color), + Icon(icon, size: 14, color: isHighlighted ? AppTheme.primary : AppTheme.textMuted), const SizedBox(width: 5), Text( label, - style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color), + style: TextStyle( + fontSize: 12, + fontWeight: isHighlighted ? FontWeight.w700 : FontWeight.w500, + color: isHighlighted ? AppTheme.primary : Colors.white, + ), ), ], ), ); } - Future _shareRecipe(Recipe recipe) async { - final storage = ref.read(secureStorageProvider); - final serverUrl = await storage.getServerUrl() ?? ''; - final buffer = StringBuffer(); - buffer.writeln('šŸ³ ${recipe.title}'); - if (recipe.calculatedTotalTime.isNotEmpty) { - buffer.writeln('ā±ļø Total Time: ${recipe.calculatedTotalTime}'); - } - if (recipe.servings != null && recipe.servings!.isNotEmpty) { - buffer.writeln('šŸ‘„ Servings: ${recipe.servings}'); - } - if (recipe.shareToken != null && recipe.shareToken!.isNotEmpty && serverUrl.isNotEmpty) { - final cleanBase = serverUrl.endsWith('/') ? serverUrl.substring(0, serverUrl.length - 1) : serverUrl; - buffer.writeln('šŸ”— ForkFolio Link: $cleanBase/share/${recipe.shareToken}'); - } else if (recipe.sourceUrl != null && recipe.sourceUrl!.isNotEmpty) { - buffer.writeln('šŸ”— Source: ${recipe.sourceUrl}'); - } - - buffer.writeln('\nšŸ›’ Ingredients:'); - for (final ing in recipe.ingredients) { - buffer.writeln('• $ing'); - } - - buffer.writeln('\nšŸ“‹ Instructions:'); - for (var i = 0; i < recipe.instructions.length; i++) { - buffer.writeln('${i + 1}. ${recipe.instructions[i]}'); - } - - try { - const platform = MethodChannel('com.forkfolio.mobile/native_share'); - await platform.invokeMethod('shareText', { - 'text': buffer.toString(), - 'subject': recipe.title, - }); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Could not trigger share: $e')), - ); - } - } - } - Future _confirmDelete(BuildContext sheetCtx, Recipe recipe) async { final confirmed = await showDialog( context: context, @@ -498,6 +756,15 @@ class _RecipesScreenState extends ConsumerState { } } + void _showAddToShoppingListSheet(BuildContext parentSheetCtx, Recipe recipe) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (modalCtx) => _AddToShoppingListModal(recipe: recipe), + ); + } + void _showProfileDialog() async { final user = ref.read(authProvider).user; final storage = ref.read(secureStorageProvider); @@ -585,3 +852,227 @@ class _RecipesScreenState extends ConsumerState { ); } } + +class _AddToShoppingListModal extends ConsumerStatefulWidget { + final Recipe recipe; + + const _AddToShoppingListModal({required this.recipe}); + + @override + ConsumerState<_AddToShoppingListModal> createState() => _AddToShoppingListModalState(); +} + +class _AddToShoppingListModalState extends ConsumerState<_AddToShoppingListModal> { + late final Set _selectedIndices; + String? _chosenListId; + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + _selectedIndices = Set.from( + List.generate(widget.recipe.ingredients.length, (i) => i), + ); + } + + @override + Widget build(BuildContext context) { + final listsAsync = ref.watch(shoppingListsProvider); + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + decoration: const BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: AppTheme.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Row( + children: [ + const Icon(Icons.add_shopping_cart_rounded, color: AppTheme.primary, size: 22), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Add to Shopping List', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 12), + // Destination List Selector + listsAsync.when( + loading: () => const LinearProgressIndicator(color: AppTheme.primary), + error: (err, _) => Text('Failed to load lists: $err', style: const TextStyle(color: AppTheme.danger)), + data: (lists) { + if (lists.isEmpty) { + return const Text('No shopping lists available.'); + } + if (_chosenListId == null || !lists.any((l) => l.id == _chosenListId)) { + _chosenListId = lists.first.id; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: _chosenListId, + dropdownColor: AppTheme.surfaceElevated, + items: lists.map((l) { + return DropdownMenuItem( + value: l.id, + child: Text('Add to: ${l.name} (${l.items.length} items)'), + ); + }).toList(), + onChanged: (val) { + if (val != null) setState(() => _chosenListId = val); + }, + ), + ), + ); + }, + ), + const SizedBox(height: 14), + // Select All / Deselect All + Row( + children: [ + Text( + '${_selectedIndices.length} of ${widget.recipe.ingredients.length} selected', + style: const TextStyle(fontSize: 13, color: AppTheme.textMuted), + ), + const Spacer(), + TextButton( + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), + onPressed: () { + setState(() { + if (_selectedIndices.length == widget.recipe.ingredients.length) { + _selectedIndices.clear(); + } else { + _selectedIndices.addAll(List.generate(widget.recipe.ingredients.length, (i) => i)); + } + }); + }, + child: Text( + _selectedIndices.length == widget.recipe.ingredients.length ? 'Deselect All' : 'Select All', + style: const TextStyle(fontSize: 12, color: AppTheme.primary), + ), + ), + ], + ), + const Divider(color: AppTheme.border, height: 1), + // Checkable Ingredients List + Expanded( + child: ListView.builder( + itemCount: widget.recipe.ingredients.length, + itemBuilder: (ctx, i) { + final ing = widget.recipe.ingredients[i]; + final isSelected = _selectedIndices.contains(i); + return CheckboxListTile( + value: isSelected, + activeColor: AppTheme.primary, + checkColor: Colors.black, + dense: true, + contentPadding: EdgeInsets.zero, + title: Text( + ing, + style: TextStyle( + fontSize: 14, + color: isSelected ? Colors.white : AppTheme.textMuted, + ), + ), + onChanged: (val) { + setState(() { + if (val == true) { + _selectedIndices.add(i); + } else { + _selectedIndices.remove(i); + } + }); + }, + ); + }, + ), + ), + const SizedBox(height: 12), + // Submit Button + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton.icon( + onPressed: (_selectedIndices.isEmpty || _chosenListId == null || _isSubmitting) + ? null + : () async { + setState(() => _isSubmitting = true); + try { + final itemsToAdd = _selectedIndices.map((i) => widget.recipe.ingredients[i]).toList(); + await ref.read(shoppingListsProvider.notifier).addItems( + _chosenListId!, + itemsToAdd, + recipeId: widget.recipe.id, + recipeTitle: widget.recipe.title, + ); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: AppTheme.surfaceElevated, + content: Text('Added ${itemsToAdd.length} ingredients to your shopping list!'), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: AppTheme.danger, + content: Text('Failed to add items: $e'), + ), + ); + } + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + }, + icon: _isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black), + ) + : const Icon(Icons.check_rounded), + label: Text('Add ${_selectedIndices.length} Ingredients to List'), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/navigation/main_navigation_shell.dart b/mobile/lib/features/navigation/main_navigation_shell.dart new file mode 100644 index 0000000..c79f507 --- /dev/null +++ b/mobile/lib/features/navigation/main_navigation_shell.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import '../../core/constants/app_theme.dart'; +import '../library/recipes_screen.dart'; +import '../scanner/scanner_screen.dart'; +import '../shopping/shopping_lists_screen.dart'; + +class MainNavigationShell extends StatefulWidget { + const MainNavigationShell({super.key}); + + @override + State createState() => _MainNavigationShellState(); +} + +class _MainNavigationShellState extends State { + int _currentIndex = 0; + + final List _screens = const [ + RecipesScreen(), + ShoppingListsScreen(), + ScannerScreen(), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _currentIndex, + children: _screens, + ), + bottomNavigationBar: Container( + decoration: const BoxDecoration( + border: Border( + top: BorderSide(color: AppTheme.border, width: 1), + ), + ), + child: NavigationBar( + selectedIndex: _currentIndex, + backgroundColor: AppTheme.surface, + indicatorColor: AppTheme.primary.withOpacity(0.2), + onDestinationSelected: (idx) { + setState(() { + _currentIndex = idx; + }); + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.menu_book_outlined), + selectedIcon: Icon(Icons.menu_book, color: AppTheme.primary), + label: 'Cookbook', + ), + NavigationDestination( + icon: Icon(Icons.shopping_cart_outlined), + selectedIcon: Icon(Icons.shopping_cart, color: AppTheme.primary), + label: 'Shopping', + ), + NavigationDestination( + icon: Icon(Icons.document_scanner_outlined), + selectedIcon: Icon(Icons.document_scanner, color: AppTheme.primary), + label: 'Scanner', + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/shopping/shopping_lists_screen.dart b/mobile/lib/features/shopping/shopping_lists_screen.dart new file mode 100644 index 0000000..c086615 --- /dev/null +++ b/mobile/lib/features/shopping/shopping_lists_screen.dart @@ -0,0 +1,491 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/constants/app_theme.dart'; +import '../../core/models/shopping_list.dart'; +import '../../core/providers.dart'; + +class ShoppingListsScreen extends ConsumerStatefulWidget { + const ShoppingListsScreen({super.key}); + + @override + ConsumerState createState() => _ShoppingListsScreenState(); +} + +class _ShoppingListsScreenState extends ConsumerState { + final TextEditingController _itemInputController = TextEditingController(); + final FocusNode _itemFocusNode = FocusNode(); + bool _isAdding = false; + + @override + void dispose() { + _itemInputController.dispose(); + _itemFocusNode.dispose(); + super.dispose(); + } + + Future _addItem(String listId) async { + final text = _itemInputController.text.trim(); + if (text.isEmpty) return; + + setState(() => _isAdding = true); + try { + await ref.read(shoppingListsProvider.notifier).addItems(listId, [text]); + _itemInputController.clear(); + HapticFeedback.lightImpact(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: AppTheme.danger, + content: Text('Failed to add item: $e'), + ), + ); + } + } finally { + if (mounted) { + setState(() => _isAdding = false); + } + } + } + + Future _showCreateListDialog() async { + final nameController = TextEditingController(); + final created = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surfaceElevated, + title: const Text('Create New Shopping List'), + content: TextField( + controller: nameController, + autofocus: true, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + hintText: 'e.g. Costco, Farmers Market', + labelText: 'List Name', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel', style: TextStyle(color: AppTheme.textMuted)), + ), + ElevatedButton( + onPressed: () { + if (nameController.text.trim().isNotEmpty) { + Navigator.pop(ctx, true); + } + }, + child: const Text('Create'), + ), + ], + ), + ); + + if (created == true && nameController.text.trim().isNotEmpty) { + try { + await ref.read(shoppingListsProvider.notifier).createList(nameController.text.trim()); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: AppTheme.danger, + content: Text('Failed to create list: $e'), + ), + ); + } + } + } + } + + Future _confirmDeleteList(ShoppingList list) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surfaceElevated, + title: const Text('Delete Shopping List?'), + content: Text('Are you sure you want to delete "${list.name}" and all its items?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel', style: TextStyle(color: AppTheme.textMuted)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.danger), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Delete', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (confirmed == true) { + try { + await ref.read(shoppingListsProvider.notifier).deleteList(list.id); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: AppTheme.danger, + content: Text('Failed to delete list: $e'), + ), + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + final listsAsync = ref.watch(shoppingListsProvider); + + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Row( + children: [ + Icon(Icons.shopping_cart_outlined, color: AppTheme.primary, size: 24), + SizedBox(width: 8), + Text('Shopping Lists', style: TextStyle(fontWeight: FontWeight.w700)), + ], + ), + actions: [ + IconButton( + tooltip: 'New List', + icon: const Icon(Icons.playlist_add_rounded), + onPressed: _showCreateListDialog, + ), + IconButton( + tooltip: 'Refresh', + icon: const Icon(Icons.refresh_rounded), + onPressed: () => ref.read(shoppingListsProvider.notifier).loadLists(), + ), + ], + ), + body: listsAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: AppTheme.primary), + ), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline_rounded, size: 48, color: AppTheme.danger), + const SizedBox(height: 12), + Text('Failed to load shopping lists: $err', textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.read(shoppingListsProvider.notifier).loadLists(), + child: const Text('Retry'), + ), + ], + ), + ), + ), + data: (lists) { + if (lists.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.shopping_bag_outlined, size: 64, color: AppTheme.textMuted), + const SizedBox(height: 16), + const Text('No shopping lists yet', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: _showCreateListDialog, + icon: const Icon(Icons.add), + label: const Text('Create Shopping List'), + ), + ], + ), + ); + } + + final notifier = ref.read(shoppingListsProvider.notifier); + final activeListId = notifier.activeListId ?? lists.first.id; + final activeList = lists.firstWhere( + (l) => l.id == activeListId, + orElse: () => lists.first, + ); + + final uncheckedItems = activeList.items.where((i) => !i.checked).toList(); + final checkedItems = activeList.items.where((i) => i.checked).toList(); + + return Column( + children: [ + // List Tabs / Selector + Container( + height: 54, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: lists.length + 1, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (ctx, idx) { + if (idx == lists.length) { + return ActionChip( + avatar: const Icon(Icons.add, size: 16, color: AppTheme.secondary), + label: const Text('New List', style: TextStyle(color: AppTheme.secondary)), + backgroundColor: AppTheme.surfaceElevated, + side: const BorderSide(color: AppTheme.border), + onPressed: _showCreateListDialog, + ); + } + + final list = lists[idx]; + final isSelected = list.id == activeList.id; + return ChoiceChip( + selected: isSelected, + label: Text( + '${list.name} (${list.uncheckedCount})', + style: TextStyle( + color: isSelected ? Colors.black : Colors.white70, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + ), + ), + selectedColor: AppTheme.primary, + backgroundColor: AppTheme.surfaceElevated, + side: BorderSide(color: isSelected ? AppTheme.primary : AppTheme.border), + onSelected: (_) => notifier.setActiveListId(list.id), + ); + }, + ), + ), + + // Active List Toolbar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + Text( + '${activeList.items.length} items (${activeList.uncheckedCount} left)', + style: const TextStyle(fontSize: 13, color: AppTheme.textMuted), + ), + const Spacer(), + if (checkedItems.isNotEmpty) + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: AppTheme.secondary, + visualDensity: VisualDensity.compact, + ), + icon: const Icon(Icons.cleaning_services_rounded, size: 16), + label: const Text('Clear Done', style: TextStyle(fontSize: 12)), + onPressed: () { + notifier.clearList(activeList.id, onlyChecked: true); + }, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 18, color: AppTheme.textMuted), + tooltip: 'List Options', + onSelected: (val) { + if (val == 'clear_all') { + notifier.clearList(activeList.id, onlyChecked: false); + } else if (val == 'delete_list') { + _confirmDeleteList(activeList); + } + }, + itemBuilder: (ctx) => [ + const PopupMenuItem( + value: 'clear_all', + child: Text('Clear All Items'), + ), + const PopupMenuItem( + value: 'delete_list', + child: Text('Delete List', style: TextStyle(color: AppTheme.danger)), + ), + ], + ), + ], + ), + ), + + // Quick Add Bar + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppTheme.border), + ), + child: TextField( + controller: _itemInputController, + focusNode: _itemFocusNode, + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + hintText: 'Add grocery item (e.g. 2 ripe avocados)...', + hintStyle: const TextStyle(color: AppTheme.textMuted, fontSize: 14), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + suffixIcon: _isAdding + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary), + ), + ) + : IconButton( + icon: const Icon(Icons.add_circle, color: AppTheme.primary), + onPressed: () => _addItem(activeList.id), + ), + ), + onSubmitted: (_) => _addItem(activeList.id), + ), + ), + ), + ], + ), + ), + + const Divider(height: 1, color: AppTheme.border), + + // Item List + Expanded( + child: activeList.items.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.checklist_rounded, size: 52, color: AppTheme.textMuted), + const SizedBox(height: 12), + const Text('No items on this list yet.', style: TextStyle(color: AppTheme.textMuted)), + const SizedBox(height: 6), + const Text( + 'Type an item above or add ingredients from any recipe!', + style: TextStyle(fontSize: 12, color: AppTheme.textMuted), + textAlign: TextAlign.center, + ), + ], + ), + ) + : ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + // Unchecked Items + ...uncheckedItems.map((item) => _buildItemTile(context, activeList, item, false)), + + // Checked Items + if (checkedItems.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Text( + 'COMPLETED (${checkedItems.length})', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: AppTheme.textMuted, + ), + ), + const Spacer(), + InkWell( + onTap: () => notifier.clearList(activeList.id, onlyChecked: true), + child: const Text( + 'Clear all', + style: TextStyle(fontSize: 12, color: AppTheme.secondary), + ), + ), + ], + ), + ), + ...checkedItems.map((item) => _buildItemTile(context, activeList, item, true)), + ], + ], + ), + ), + ], + ); + }, + ), + ); + } + + Widget _buildItemTile(BuildContext context, ShoppingList list, ShoppingListItem item, bool isChecked) { + final notifier = ref.read(shoppingListsProvider.notifier); + + return Dismissible( + key: Key(item.id), + direction: DismissDirection.endToStart, + background: Container( + color: AppTheme.danger.withOpacity(0.8), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete_outline, color: Colors.white), + ), + onDismissed: (_) { + notifier.deleteItems(list.id, [item.id]); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: AppTheme.surfaceElevated, + content: Text('Removed "${item.rawText}"'), + duration: const Duration(seconds: 2), + ), + ); + }, + child: Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: AppTheme.border.withOpacity(0.5))), + ), + child: ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + leading: Transform.scale( + scale: 1.15, + child: Checkbox( + value: item.checked, + activeColor: AppTheme.primary, + checkColor: Colors.black, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + side: BorderSide(color: isChecked ? AppTheme.primary : AppTheme.textMuted, width: 1.5), + onChanged: (bool? val) { + HapticFeedback.selectionClick(); + notifier.toggleItemCheck(list.id, item.id, val ?? false); + }, + ), + ), + title: Text( + item.rawText, + style: TextStyle( + fontSize: 15, + fontWeight: isChecked ? FontWeight.w400 : FontWeight.w600, + color: isChecked ? AppTheme.textMuted : Colors.white, + decoration: isChecked ? TextDecoration.lineThrough : null, + decorationColor: AppTheme.textMuted, + ), + ), + subtitle: item.recipeTitle != null + ? Row( + children: [ + const Icon(Icons.restaurant_menu, size: 11, color: AppTheme.secondary), + const SizedBox(width: 4), + Expanded( + child: Text( + 'Recipe: ${item.recipeTitle}', + style: const TextStyle(fontSize: 11, color: AppTheme.secondary), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ) + : null, + trailing: IconButton( + icon: const Icon(Icons.close, size: 16, color: AppTheme.textMuted), + onPressed: () { + notifier.deleteItems(list.id, [item.id]); + }, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 089767e..e64f236 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/constants/app_theme.dart'; import 'core/providers.dart'; import 'features/auth/login_screen.dart'; -import 'features/library/recipes_screen.dart'; +import 'features/navigation/main_navigation_shell.dart'; import 'features/share_receiver/share_receiver_service.dart'; void main() { @@ -49,7 +49,7 @@ class _ForkFolioAppState extends ConsumerState { title: 'ForkFolio', debugShowCheckedModeBanner: false, theme: AppTheme.darkTheme, - home: authState.isAuthenticated ? const RecipesScreen() : const LoginScreen(), + home: authState.isAuthenticated ? const MainNavigationShell() : const LoginScreen(), ); } }