mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-02 07:28:25 -05:00
Implement shopping lists tab, community feed with cloning, and cook mode stopwatch with smart timers
This commit is contained in:
parent
a645f7d5fe
commit
5f8e0a65d7
8 changed files with 2002 additions and 281 deletions
|
|
@ -217,7 +217,137 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
// 7. Logout
|
||||
// 7. Public Recipes (Community Feed)
|
||||
Future<List<Recipe>> getPublicRecipes() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/recipes/public');
|
||||
final list = response.data as List<dynamic>;
|
||||
return list.map((json) => Recipe.fromJson(json as Map<String, dynamic>)).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<Recipe> cloneRecipe(String id) async {
|
||||
try {
|
||||
final response = await _dio.post('/api/recipes/clone/$id');
|
||||
return Recipe.fromJson(response.data as Map<String, dynamic>);
|
||||
} 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<List<dynamic>> getShoppingLists() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/shopping-lists');
|
||||
return response.data as List<dynamic>;
|
||||
} on DioException catch (e) {
|
||||
final err = e.error;
|
||||
if (err is ApiException) throw err;
|
||||
throw ApiException(e.message ?? 'Failed to load shopping lists');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createShoppingList(String name) async {
|
||||
try {
|
||||
final response = await _dio.post('/api/shopping-lists', data: {'name': name});
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
final err = e.error;
|
||||
if (err is ApiException) throw err;
|
||||
throw ApiException(e.message ?? 'Failed to create shopping list');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<Map<String, dynamic>> addShoppingListItems(
|
||||
String listId,
|
||||
List<String> 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<String, dynamic>;
|
||||
} 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<Map<String, dynamic>> checkShoppingListItems(
|
||||
String listId,
|
||||
List<String> 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<String, dynamic>;
|
||||
} 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<Map<String, dynamic>> deleteShoppingListItems(String listId, List<String> itemIds) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/shopping-lists/$listId/items/delete',
|
||||
data: {'itemIds': itemIds},
|
||||
);
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
final err = e.error;
|
||||
if (err is ApiException) throw err;
|
||||
throw ApiException(e.message ?? 'Failed to delete items');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> 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<String, dynamic>;
|
||||
} 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<void> logout() async {
|
||||
try {
|
||||
await _dio.post('/api/auth/logout');
|
||||
|
|
|
|||
129
mobile/lib/core/models/shopping_list.dart
Normal file
129
mobile/lib/core/models/shopping_list.dart
Normal file
|
|
@ -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<String, dynamic> 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<ShoppingListItem> 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<String, dynamic> json) {
|
||||
var rawItems = json['items'];
|
||||
List<ShoppingListItem> parsedItems = [];
|
||||
if (rawItems is List) {
|
||||
parsedItems = rawItems
|
||||
.map((item) => ShoppingListItem.fromJson(item as Map<String, dynamic>))
|
||||
.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;
|
||||
}
|
||||
|
|
@ -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<SecureStorageService>((ref) {
|
||||
|
|
@ -133,3 +134,158 @@ final recipeListProvider =
|
|||
final api = ref.watch(apiClientProvider);
|
||||
return RecipeListNotifier(api);
|
||||
});
|
||||
|
||||
// Community / Public Recipes State Notifier
|
||||
class PublicRecipesNotifier extends StateNotifier<AsyncValue<List<Recipe>>> {
|
||||
final ApiClient _api;
|
||||
|
||||
PublicRecipesNotifier(this._api) : super(const AsyncValue.loading()) {
|
||||
loadPublicRecipes();
|
||||
}
|
||||
|
||||
Future<void> 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<Recipe> 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<PublicRecipesNotifier, AsyncValue<List<Recipe>>>((ref) {
|
||||
final api = ref.watch(apiClientProvider);
|
||||
return PublicRecipesNotifier(api);
|
||||
});
|
||||
|
||||
// Shopping Lists State Notifier
|
||||
class ShoppingListsNotifier extends StateNotifier<AsyncValue<List<ShoppingList>>> {
|
||||
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<void> loadLists() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final data = await _api.getShoppingLists();
|
||||
final lists = data.map((json) => ShoppingList.fromJson(json as Map<String, dynamic>)).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<void> 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<void> 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<void> 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<void> addItems(String listId, List<String> 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<void> deleteItems(String listId, List<String> itemIds) async {
|
||||
final updatedJson = await _api.deleteShoppingListItems(listId, itemIds);
|
||||
final updatedList = ShoppingList.fromJson(updatedJson);
|
||||
_replaceList(updatedList);
|
||||
}
|
||||
|
||||
Future<void> 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<ShoppingListsNotifier, AsyncValue<List<ShoppingList>>>((ref) {
|
||||
final api = ref.watch(apiClientProvider);
|
||||
return ShoppingListsNotifier(api);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<CookModeScreen> {
|
|||
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<CookModeScreen> {
|
|||
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<CookModeScreen> {
|
|||
_isTimerRunning = false;
|
||||
_timerSecondsRemaining = 0;
|
||||
});
|
||||
HapticFeedback.vibrate();
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
|
|
@ -76,15 +95,163 @@ class _CookModeScreenState extends State<CookModeScreen> {
|
|||
});
|
||||
}
|
||||
|
||||
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<DetectedDuration> _detectDurationsInStep(String text) {
|
||||
final List<DetectedDuration> 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<CookModeScreen> {
|
|||
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<double>(
|
||||
initialValue: _scaleMultiplier,
|
||||
|
|
@ -122,13 +308,13 @@ class _CookModeScreenState extends State<CookModeScreen> {
|
|||
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<CookModeScreen> {
|
|||
),
|
||||
),
|
||||
|
||||
// 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<CookModeScreen> {
|
|||
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(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
66
mobile/lib/features/navigation/main_navigation_shell.dart
Normal file
66
mobile/lib/features/navigation/main_navigation_shell.dart
Normal file
|
|
@ -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<MainNavigationShell> createState() => _MainNavigationShellState();
|
||||
}
|
||||
|
||||
class _MainNavigationShellState extends State<MainNavigationShell> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
final List<Widget> _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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
491
mobile/lib/features/shopping/shopping_lists_screen.dart
Normal file
491
mobile/lib/features/shopping/shopping_lists_screen.dart
Normal file
|
|
@ -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<ShoppingListsScreen> createState() => _ShoppingListsScreenState();
|
||||
}
|
||||
|
||||
class _ShoppingListsScreenState extends ConsumerState<ShoppingListsScreen> {
|
||||
final TextEditingController _itemInputController = TextEditingController();
|
||||
final FocusNode _itemFocusNode = FocusNode();
|
||||
bool _isAdding = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_itemInputController.dispose();
|
||||
_itemFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _showCreateListDialog() async {
|
||||
final nameController = TextEditingController();
|
||||
final created = await showDialog<bool>(
|
||||
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<void> _confirmDeleteList(ShoppingList list) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<String>(
|
||||
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]);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ForkFolioApp> {
|
|||
title: 'ForkFolio',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.darkTheme,
|
||||
home: authState.isAuthenticated ? const RecipesScreen() : const LoginScreen(),
|
||||
home: authState.isAuthenticated ? const MainNavigationShell() : const LoginScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue