diff --git a/lib/data/dummy_data.dart b/lib/data/dummy_data.dart new file mode 100644 index 0000000..0d4876d --- /dev/null +++ b/lib/data/dummy_data.dart @@ -0,0 +1,409 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_meals/models/category.dart'; +import 'package:flutter_meals/models/meal.dart'; + +// Constants in Dart should be written in lowerCamelcase. +const availableCategories = [ + Category( + id: 'c1', + title: 'Italian', + color: Colors.purple, + ), + Category( + id: 'c2', + title: 'Quick & Easy', + color: Colors.red, + ), + Category( + id: 'c3', + title: 'Hamburgers', + color: Colors.orange, + ), + Category( + id: 'c4', + title: 'German', + color: Colors.amber, + ), + Category( + id: 'c5', + title: 'Light & Lovely', + color: Colors.blue, + ), + Category( + id: 'c6', + title: 'Exotic', + color: Colors.green, + ), + Category( + id: 'c7', + title: 'Breakfast', + color: Colors.lightBlue, + ), + Category( + id: 'c8', + title: 'Asian', + color: Colors.lightGreen, + ), + Category( + id: 'c9', + title: 'French', + color: Colors.pink, + ), + Category( + id: 'c10', + title: 'Summer', + color: Colors.teal, + ), +]; + +const dummyMeals = [ + Meal( + id: 'm1', + categories: [ + 'c1', + 'c2', + ], + title: 'Spaghetti with Tomato Sauce', + affordability: Affordability.affordable, + complexity: Complexity.simple, + imageUrl: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/20/Spaghetti_Bolognese_mit_Parmesan_oder_Grana_Padano.jpg/800px-Spaghetti_Bolognese_mit_Parmesan_oder_Grana_Padano.jpg', + duration: 20, + ingredients: [ + '4 Tomatoes', + '1 Tablespoon of Olive Oil', + '1 Onion', + '250g Spaghetti', + 'Spices', + 'Cheese (optional)' + ], + steps: [ + 'Cut the tomatoes and the onion into small pieces.', + 'Boil some water - add salt to it once it boils.', + 'Put the spaghetti into the boiling water - they should be done in about 10 to 12 minutes.', + 'In the meantime, heaten up some olive oil and add the cut onion.', + 'After 2 minutes, add the tomato pieces, salt, pepper and your other spices.', + 'The sauce will be done once the spaghetti are.', + 'Feel free to add some cheese on top of the finished dish.' + ], + isGlutenFree: false, + isVegan: true, + isVegetarian: true, + isLactoseFree: true, + ), + Meal( + id: 'm2', + categories: [ + 'c2', + ], + title: 'Toast Hawaii', + affordability: Affordability.affordable, + complexity: Complexity.simple, + imageUrl: + 'https://cdn.pixabay.com/photo/2018/07/11/21/51/toast-3532016_1280.jpg', + duration: 10, + ingredients: [ + '1 Slice White Bread', + '1 Slice Ham', + '1 Slice Pineapple', + '1-2 Slices of Cheese', + 'Butter' + ], + steps: [ + 'Butter one side of the white bread', + 'Layer ham, the pineapple and cheese on the white bread', + 'Bake the toast for round about 10 minutes in the oven at 200°C' + ], + isGlutenFree: false, + isVegan: false, + isVegetarian: false, + isLactoseFree: false, + ), + Meal( + id: 'm3', + categories: [ + 'c2', + 'c3', + ], + title: 'Classic Hamburger', + affordability: Affordability.pricey, + complexity: Complexity.simple, + imageUrl: + 'https://cdn.pixabay.com/photo/2014/10/23/18/05/burger-500054_1280.jpg', + duration: 45, + ingredients: [ + '300g Cattle Hack', + '1 Tomato', + '1 Cucumber', + '1 Onion', + 'Ketchup', + '2 Burger Buns' + ], + steps: [ + 'Form 2 patties', + 'Fry the patties for c. 4 minutes on each side', + 'Quickly fry the buns for c. 1 minute on each side', + 'Bruch buns with ketchup', + 'Serve burger with tomato, cucumber and onion' + ], + isGlutenFree: false, + isVegan: false, + isVegetarian: false, + isLactoseFree: true, + ), + Meal( + id: 'm4', + categories: [ + 'c4', + ], + title: 'Wiener Schnitzel', + affordability: Affordability.luxurious, + complexity: Complexity.challenging, + imageUrl: + 'https://cdn.pixabay.com/photo/2018/03/31/19/29/schnitzel-3279045_1280.jpg', + duration: 60, + ingredients: [ + '8 Veal Cutlets', + '4 Eggs', + '200g Bread Crumbs', + '100g Flour', + '300ml Butter', + '100g Vegetable Oil', + 'Salt', + 'Lemon Slices' + ], + steps: [ + 'Tenderize the veal to about 2–4mm, and salt on both sides.', + 'On a flat plate, stir the eggs briefly with a fork.', + 'Lightly coat the cutlets in flour then dip into the egg, and finally, coat in breadcrumbs.', + 'Heat the butter and oil in a large pan (allow the fat to get very hot) and fry the schnitzels until golden brown on both sides.', + 'Make sure to toss the pan regularly so that the schnitzels are surrounded by oil and the crumbing becomes ‘fluffy’.', + 'Remove, and drain on kitchen paper. Fry the parsley in the remaining oil and drain.', + 'Place the schnitzels on awarmed plate and serve garnishedwith parsley and slices of lemon.' + ], + isGlutenFree: false, + isVegan: false, + isVegetarian: false, + isLactoseFree: false, + ), + Meal( + id: 'm5', + categories: [ + 'c2' + 'c5', + 'c10', + ], + title: 'Salad with Smoked Salmon', + affordability: Affordability.luxurious, + complexity: Complexity.simple, + imageUrl: + 'https://cdn.pixabay.com/photo/2016/10/25/13/29/smoked-salmon-salad-1768890_1280.jpg', + duration: 15, + ingredients: [ + 'Arugula', + 'Lamb\'s Lettuce', + 'Parsley', + 'Fennel', + '200g Smoked Salmon', + 'Mustard', + 'Balsamic Vinegar', + 'Olive Oil', + 'Salt and Pepper' + ], + steps: [ + 'Wash and cut salad and herbs', + 'Dice the salmon', + 'Process mustard, vinegar and olive oil into a dessing', + 'Prepare the salad', + 'Add salmon cubes and dressing' + ], + isGlutenFree: true, + isVegan: false, + isVegetarian: true, + isLactoseFree: true, + ), + Meal( + id: 'm6', + categories: [ + 'c6', + 'c10', + ], + title: 'Delicious Orange Mousse', + affordability: Affordability.affordable, + complexity: Complexity.hard, + imageUrl: + 'https://cdn.pixabay.com/photo/2017/05/01/05/18/pastry-2274750_1280.jpg', + duration: 240, + ingredients: [ + '4 Sheets of Gelatine', + '150ml Orange Juice', + '80g Sugar', + '300g Yoghurt', + '200g Cream', + 'Orange Peel', + ], + steps: [ + 'Dissolve gelatine in pot', + 'Add orange juice and sugar', + 'Take pot off the stove', + 'Add 2 tablespoons of yoghurt', + 'Stir gelatin under remaining yoghurt', + 'Cool everything down in the refrigerator', + 'Whip the cream and lift it under die orange mass', + 'Cool down again for at least 4 hours', + 'Serve with orange peel', + ], + isGlutenFree: true, + isVegan: false, + isVegetarian: true, + isLactoseFree: false, + ), + Meal( + id: 'm7', + categories: [ + 'c7', + ], + title: 'Pancakes', + affordability: Affordability.affordable, + complexity: Complexity.simple, + imageUrl: + 'https://cdn.pixabay.com/photo/2018/07/10/21/23/pancake-3529653_1280.jpg', + duration: 20, + ingredients: [ + '1 1/2 Cups all-purpose Flour', + '3 1/2 Teaspoons Baking Powder', + '1 Teaspoon Salt', + '1 Tablespoon White Sugar', + '1 1/4 cups Milk', + '1 Egg', + '3 Tablespoons Butter, melted', + ], + steps: [ + 'In a large bowl, sift together the flour, baking powder, salt and sugar.', + 'Make a well in the center and pour in the milk, egg and melted butter; mix until smooth.', + 'Heat a lightly oiled griddle or frying pan over medium high heat.', + 'Pour or scoop the batter onto the griddle, using approximately 1/4 cup for each pancake. Brown on both sides and serve hot.' + ], + isGlutenFree: true, + isVegan: false, + isVegetarian: true, + isLactoseFree: false, + ), + Meal( + id: 'm8', + categories: [ + 'c8', + ], + title: 'Creamy Indian Chicken Curry', + affordability: Affordability.pricey, + complexity: Complexity.challenging, + imageUrl: + 'https://cdn.pixabay.com/photo/2018/06/18/16/05/indian-food-3482749_1280.jpg', + duration: 35, + ingredients: [ + '4 Chicken Breasts', + '1 Onion', + '2 Cloves of Garlic', + '1 Piece of Ginger', + '4 Tablespoons Almonds', + '1 Teaspoon Cayenne Pepper', + '500ml Coconut Milk', + ], + steps: [ + 'Slice and fry the chicken breast', + 'Process onion, garlic and ginger into paste and sauté everything', + 'Add spices and stir fry', + 'Add chicken breast + 250ml of water and cook everything for 10 minutes', + 'Add coconut milk', + 'Serve with rice' + ], + isGlutenFree: true, + isVegan: false, + isVegetarian: false, + isLactoseFree: true, + ), + Meal( + id: 'm9', + categories: [ + 'c9', + ], + title: 'Chocolate Souffle', + affordability: Affordability.affordable, + complexity: Complexity.hard, + imageUrl: + 'https://cdn.pixabay.com/photo/2014/08/07/21/07/souffle-412785_1280.jpg', + duration: 45, + ingredients: [ + '1 Teaspoon melted Butter', + '2 Tablespoons white Sugar', + '2 Ounces 70% dark Chocolate, broken into pieces', + '1 Tablespoon Butter', + '1 Tablespoon all-purpose Flour', + '4 1/3 tablespoons cold Milk', + '1 Pinch Salt', + '1 Pinch Cayenne Pepper', + '1 Large Egg Yolk', + '2 Large Egg Whites', + '1 Pinch Cream of Tartar', + '1 Tablespoon white Sugar', + ], + steps: [ + 'Preheat oven to 190°C. Line a rimmed baking sheet with parchment paper.', + 'Brush bottom and sides of 2 ramekins lightly with 1 teaspoon melted butter; cover bottom and sides right up to the rim.', + 'Add 1 tablespoon white sugar to ramekins. Rotate ramekins until sugar coats all surfaces.', + 'Place chocolate pieces in a metal mixing bowl.', + 'Place bowl over a pan of about 3 cups hot water over low heat.', + 'Melt 1 tablespoon butter in a skillet over medium heat. Sprinkle in flour. Whisk until flour is incorporated into butter and mixture thickens.', + 'Whisk in cold milk until mixture becomes smooth and thickens. Transfer mixture to bowl with melted chocolate.', + 'Add salt and cayenne pepper. Mix together thoroughly. Add egg yolk and mix to combine.', + 'Leave bowl above the hot (not simmering) water to keep chocolate warm while you whip the egg whites.', + 'Place 2 egg whites in a mixing bowl; add cream of tartar. Whisk until mixture begins to thicken and a drizzle from the whisk stays on the surface about 1 second before disappearing into the mix.', + 'Add 1/3 of sugar and whisk in. Whisk in a bit more sugar about 15 seconds.', + 'whisk in the rest of the sugar. Continue whisking until mixture is about as thick as shaving cream and holds soft peaks, 3 to 5 minutes.', + 'Transfer a little less than half of egg whites to chocolate.', + 'Mix until egg whites are thoroughly incorporated into the chocolate.', + 'Add the rest of the egg whites; gently fold into the chocolate with a spatula, lifting from the bottom and folding over.', + 'Stop mixing after the egg white disappears. Divide mixture between 2 prepared ramekins. Place ramekins on prepared baking sheet.', + 'Bake in preheated oven until scuffles are puffed and have risen above the top of the rims, 12 to 15 minutes.', + ], + isGlutenFree: true, + isVegan: false, + isVegetarian: true, + isLactoseFree: false, + ), + Meal( + id: 'm10', + categories: [ + 'c2', + 'c5', + 'c10', + ], + title: 'Asparagus Salad with Cherry Tomatoes', + affordability: Affordability.luxurious, + complexity: Complexity.simple, + imageUrl: + 'https://cdn.pixabay.com/photo/2018/04/09/18/26/asparagus-3304997_1280.jpg', + duration: 30, + ingredients: [ + 'White and Green Asparagus', + '30g Pine Nuts', + '300g Cherry Tomatoes', + 'Salad', + 'Salt, Pepper and Olive Oil' + ], + steps: [ + 'Wash, peel and cut the asparagus', + 'Cook in salted water', + 'Salt and pepper the asparagus', + 'Roast the pine nuts', + 'Halve the tomatoes', + 'Mix with asparagus, salad and dressing', + 'Serve with Baguette' + ], + isGlutenFree: true, + isVegan: true, + isVegetarian: true, + isLactoseFree: true, + ), +]; diff --git a/lib/models/category.dart b/lib/models/category.dart new file mode 100644 index 0000000..f753eda --- /dev/null +++ b/lib/models/category.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class Category { + const Category({ + required this.id, + required this.title, + this.color = Colors.orange, + }); + + final String id; + final String title; + final Color color; +} diff --git a/lib/models/meal.dart b/lib/models/meal.dart new file mode 100644 index 0000000..3d91c6b --- /dev/null +++ b/lib/models/meal.dart @@ -0,0 +1,43 @@ +enum Complexity { + simple, + challenging, + hard, +} + +enum Affordability { + affordable, + pricey, + luxurious, +} + +class Meal { + const Meal({ + required this.id, + required this.categories, + required this.title, + required this.imageUrl, + required this.ingredients, + required this.steps, + required this.duration, + required this.complexity, + required this.affordability, + required this.isGlutenFree, + required this.isLactoseFree, + required this.isVegan, + required this.isVegetarian, + }); + + final String id; + final List categories; + final String title; + final String imageUrl; + final List ingredients; + final List steps; + final int duration; + final Complexity complexity; + final Affordability affordability; + final bool isGlutenFree; + final bool isLactoseFree; + final bool isVegan; + final bool isVegetarian; +} diff --git a/lib/providers/favorites_provider.dart b/lib/providers/favorites_provider.dart new file mode 100644 index 0000000..bf47802 --- /dev/null +++ b/lib/providers/favorites_provider.dart @@ -0,0 +1,23 @@ +import 'package:flutter_meals/models/meal.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class FavoriteMealsNotifier extends StateNotifier> { + FavoriteMealsNotifier() : super([]); + + bool toggleMealFavoriteStatus(Meal meal) { + final mealIsFavorite = state.contains(meal); + + if (mealIsFavorite) { + state = state.where((m) => m.id != meal.id).toList(); + return false; + } else { + state = [...state, meal]; + return true; + } + } +} + +final favoriteMealsProvider = +StateNotifierProvider>((ref) { + return FavoriteMealsNotifier(); +}); diff --git a/lib/providers/filters_provider.dart b/lib/providers/filters_provider.dart new file mode 100644 index 0000000..fec8efe --- /dev/null +++ b/lib/providers/filters_provider.dart @@ -0,0 +1,33 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +enum Filter { + glutenFree, + lactoseFree, + vegetarian, + vegan, +} + +class FiltersNotifier extends StateNotifier> { + FiltersNotifier() + : super({ + Filter.glutenFree: false, + Filter.lactoseFree: false, + Filter.vegetarian: false, + Filter.vegan: false + }); + + void setFilters(Map chosenFilters) { + state = chosenFilters; + } + + void setFilter(Filter filter, bool isActive) { + state = { + ...state, + filter: isActive, + }; + } +} + +final filtersProvider = +StateNotifierProvider>( + (ref) => FiltersNotifier()); diff --git a/lib/providers/meals_provider.dart b/lib/providers/meals_provider.dart new file mode 100644 index 0000000..9e9bcd8 --- /dev/null +++ b/lib/providers/meals_provider.dart @@ -0,0 +1,6 @@ +import 'package:flutter_meals/data/dummy_data.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final mealsProvider = Provider((ref) { + return dummyMeals; +}); diff --git a/lib/screens/categories.dart b/lib/screens/categories.dart new file mode 100644 index 0000000..648ab7f --- /dev/null +++ b/lib/screens/categories.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_meals/data/dummy_data.dart'; +import 'package:flutter_meals/models/meal.dart'; +import 'package:flutter_meals/widgets/category_grid_item.dart'; +import 'package:flutter_meals/screens/meals.dart'; +import 'package:flutter_meals/models/category.dart'; + +class CategoriesScreen extends StatelessWidget { + const CategoriesScreen({ + super.key, + required this.availableMeals, + }); + + final List availableMeals; + + void _selectCategory(BuildContext context, Category category) { + final filteredMeals = availableMeals + .where((meal) => meal.categories.contains(category.id)) + .toList(); + + Navigator.of(context).push( + MaterialPageRoute( + builder: (ctx) => MealsScreen( + title: category.title, + meals: filteredMeals, + ), + ), + ); // Navigator.push(context, route) + } + + @override + Widget build(BuildContext context) { + return GridView( + padding: const EdgeInsets.all(24), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 3 / 2, + crossAxisSpacing: 20, + mainAxisSpacing: 20, + ), + children: [ + // availableCategories.map((category) => CategoryGridItem(category: category)).toList() + for (final category in availableCategories) + CategoryGridItem( + category: category, + onSelectCategory: () { + _selectCategory(context, category); + }, + ) + ], + ); + } +} diff --git a/lib/screens/filters.dart b/lib/screens/filters.dart new file mode 100644 index 0000000..eda4c00 --- /dev/null +++ b/lib/screens/filters.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:flutter_meals/providers/filters_provider.dart'; + +// import 'package:flutter_meals/screens/tabs.dart'; +// import 'package:flutter_meals/widgets/main_drawer.dart'; + +class FiltersScreen extends ConsumerStatefulWidget { + const FiltersScreen({super.key}); + + @override + ConsumerState createState() { + return _FiltersScreenState(); + } +} + +class _FiltersScreenState extends ConsumerState { + var _glutenFreeFilterSet = false; + var _lactoseFreeFilterSet = false; + var _vegetarianFilterSet = false; + var _veganFilterSet = false; + + @override + void initState() { + super.initState(); + final activeFilters = ref.read(filtersProvider); + + _glutenFreeFilterSet = activeFilters[Filter.glutenFree]!; + _lactoseFreeFilterSet = activeFilters[Filter.lactoseFree]!; + _vegetarianFilterSet = activeFilters[Filter.vegetarian]!; + _veganFilterSet = activeFilters[Filter.vegan]!; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Your Filters'), + ), + // drawer: MainDrawer( + // onSelectScreen: (identifier) { + // Navigator.of(context).pop(); + // if (identifier == 'meals') { + // Navigator.of(context).pushReplacement( + // MaterialPageRoute( + // builder: (ctx) => const TabsScreen(), + // ), + // ); + // } + // }, + // ), + body: WillPopScope( + onWillPop: () async { + ref.read(filtersProvider.notifier).setFilters({ + Filter.glutenFree: _glutenFreeFilterSet, + Filter.lactoseFree: _lactoseFreeFilterSet, + Filter.vegetarian: _vegetarianFilterSet, + Filter.vegan: _veganFilterSet, + }); + return true; + }, + child: Column( + children: [ + SwitchListTile( + value: _glutenFreeFilterSet, + onChanged: (isChecked) { + setState(() { + _glutenFreeFilterSet = isChecked; + }); + }, + title: Text( + 'Gluten-free', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + subtitle: Text( + 'Only include gluten-free meals.', + style: Theme.of(context).textTheme.labelMedium!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + activeColor: Theme.of(context).colorScheme.tertiary, + contentPadding: const EdgeInsets.only(left: 34, right: 22), + ), + SwitchListTile( + value: _lactoseFreeFilterSet, + onChanged: (isChecked) { + setState(() { + _lactoseFreeFilterSet = isChecked; + }); + }, + title: Text( + 'Lactose-free', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + subtitle: Text( + 'Only include lactose-free meals.', + style: Theme.of(context).textTheme.labelMedium!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + activeColor: Theme.of(context).colorScheme.tertiary, + contentPadding: const EdgeInsets.only(left: 34, right: 22), + ), + SwitchListTile( + value: _vegetarianFilterSet, + onChanged: (isChecked) { + setState(() { + _vegetarianFilterSet = isChecked; + }); + }, + title: Text( + 'Vegetarian', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + subtitle: Text( + 'Only include vegetarian meals.', + style: Theme.of(context).textTheme.labelMedium!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + activeColor: Theme.of(context).colorScheme.tertiary, + contentPadding: const EdgeInsets.only(left: 34, right: 22), + ), + SwitchListTile( + value: _veganFilterSet, + onChanged: (isChecked) { + setState(() { + _veganFilterSet = isChecked; + }); + }, + title: Text( + 'Vegan', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + subtitle: Text( + 'Only include vegan meals.', + style: Theme.of(context).textTheme.labelMedium!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + activeColor: Theme.of(context).colorScheme.tertiary, + contentPadding: const EdgeInsets.only(left: 34, right: 22), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/meal_details.dart b/lib/screens/meal_details.dart new file mode 100644 index 0000000..1a4b50b --- /dev/null +++ b/lib/screens/meal_details.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:flutter_meals/models/meal.dart'; +import 'package:flutter_meals/providers/favorites_provider.dart'; + +class MealDetailsScreen extends ConsumerWidget { + const MealDetailsScreen({ + super.key, + required this.meal, + }); + + final Meal meal; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: Text(meal.title), actions: [ + IconButton( + onPressed: () { + final wasAdded = ref + .read(favoriteMealsProvider.notifier) + .toggleMealFavoriteStatus(meal); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + wasAdded ? 'Meal added as a favorite.' : 'Meal removed.'), + ), + ); + }, + icon: const Icon(Icons.star), + ) + ]), + body: SingleChildScrollView( + child: Column( + children: [ + Image.network( + meal.imageUrl, + height: 300, + width: double.infinity, + fit: BoxFit.cover, + ), + const SizedBox(height: 14), + Text( + 'Ingredients', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 14), + for (final ingredient in meal.ingredients) + Text( + ingredient, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + const SizedBox(height: 24), + Text( + 'Steps', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 14), + for (final step in meal.steps) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text( + step, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + ), + ], + ), + )); + } +} diff --git a/lib/screens/meals.dart b/lib/screens/meals.dart new file mode 100644 index 0000000..a1eb152 --- /dev/null +++ b/lib/screens/meals.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_meals/models/meal.dart'; +import 'package:flutter_meals/screens/meal_details.dart'; +import 'package:flutter_meals/widgets/meal_item.dart'; + +class MealsScreen extends StatelessWidget { + const MealsScreen({ + super.key, + this.title, + required this.meals, + }); + + final String? title; + final List meals; + + void selectMeal(BuildContext context, Meal meal) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (ctx) => MealDetailsScreen( + meal: meal, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + Widget content = Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Uh oh ... nothing here!', + style: Theme.of(context).textTheme.headlineLarge!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + const SizedBox(height: 16), + Text( + 'Try selecting a different category!', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + ], + ), + ); + + if (meals.isNotEmpty) { + content = ListView.builder( + itemCount: meals.length, + itemBuilder: (ctx, index) => MealItem( + meal: meals[index], + onSelectMeal: (meal) { + selectMeal(context, meal); + }, + ), + ); + } + + if (title == null) { + return content; + } + + return Scaffold( + appBar: AppBar( + title: Text(title!), + ), + body: content, + ); + } +} diff --git a/lib/screens/tabs.dart b/lib/screens/tabs.dart new file mode 100644 index 0000000..ec22b8d --- /dev/null +++ b/lib/screens/tabs.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:flutter_meals/screens/categories.dart'; +import 'package:flutter_meals/screens/filters.dart'; +import 'package:flutter_meals/screens/meals.dart'; +import 'package:flutter_meals/widgets/main_drawer.dart'; +import 'package:flutter_meals/providers/meals_provider.dart'; +import 'package:flutter_meals/providers/favorites_provider.dart'; +import 'package:flutter_meals/providers/filters_provider.dart'; + +const kInitialFilters = { + Filter.glutenFree: false, + Filter.lactoseFree: false, + Filter.vegetarian: false, + Filter.vegan: false +}; + +class TabsScreen extends ConsumerStatefulWidget { + const TabsScreen({super.key}); + + @override + ConsumerState createState() { + return _TabsScreenState(); + } +} + +class _TabsScreenState extends ConsumerState { + int _selectedPageIndex = 0; + + void _selectPage(int index) { + setState(() { + _selectedPageIndex = index; + }); + } + + void _setScreen(String identifier) async { + Navigator.of(context).pop(); + if (identifier == 'filters') { + await Navigator.of(context).push>( + MaterialPageRoute( + builder: (ctx) => const FiltersScreen(), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final meals = ref.watch(mealsProvider); + final activeFilters = ref.watch(filtersProvider); + final availableMeals = meals.where((meal) { + if (activeFilters[Filter.glutenFree]! && !meal.isGlutenFree) { + return false; + } + if (activeFilters[Filter.lactoseFree]! && !meal.isLactoseFree) { + return false; + } + if (activeFilters[Filter.vegetarian]! && !meal.isVegetarian) { + return false; + } + if (activeFilters[Filter.vegan]! && !meal.isVegan) { + return false; + } + return true; + }).toList(); + + Widget activePage = CategoriesScreen( + availableMeals: availableMeals, + ); + var activePageTitle = 'Categories'; + + if (_selectedPageIndex == 1) { + final favoritesMeals = ref.watch(favoriteMealsProvider); + + activePage = MealsScreen( + meals: favoritesMeals, + ); + activePageTitle = 'Your Favorites'; + } + + return Scaffold( + appBar: AppBar( + title: Text(activePageTitle), + ), + drawer: MainDrawer( + onSelectScreen: _setScreen, + ), + body: activePage, + bottomNavigationBar: BottomNavigationBar( + onTap: _selectPage, + currentIndex: _selectedPageIndex, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.set_meal), + label: 'Categories', + ), + BottomNavigationBarItem( + icon: Icon(Icons.star), + label: 'Favorites', + ), + ], + ), + ); + } +} diff --git a/lib/widgets/category_grid_item.dart b/lib/widgets/category_grid_item.dart new file mode 100644 index 0000000..bde8a73 --- /dev/null +++ b/lib/widgets/category_grid_item.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_meals/models/category.dart'; + +class CategoryGridItem extends StatelessWidget { + const CategoryGridItem({ + super.key, + required this.category, + required this.onSelectCategory, + }); + + final Category category; + final void Function() onSelectCategory; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onSelectCategory, + splashColor: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + colors: [ + category.color.withOpacity(0.55), + category.color.withOpacity(0.9), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + )), + child: Text( + category.title, + style: Theme.of(context).textTheme.titleLarge!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/main_drawer.dart b/lib/widgets/main_drawer.dart new file mode 100644 index 0000000..0646a91 --- /dev/null +++ b/lib/widgets/main_drawer.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +class MainDrawer extends StatelessWidget { + const MainDrawer({super.key, required this.onSelectScreen}); + + final void Function(String identifier) onSelectScreen; + + @override + Widget build(BuildContext context) { + return Drawer( + child: Column( + children: [ + DrawerHeader( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primaryContainer, + Theme.of(context) + .colorScheme + .primaryContainer + .withOpacity(0.8), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + children: [ + Icon( + Icons.fastfood, + size: 48, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 18), + Text( + 'Cooking Up!', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ListTile( + leading: Icon( + Icons.restaurant, + size: 26, + color: Theme.of(context).colorScheme.onBackground, + ), + title: Text( + 'Meals', + style: Theme.of(context).textTheme.titleSmall!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + fontSize: 24, + ), + ), + onTap: () { + onSelectScreen('meals'); + }, + ), + ListTile( + leading: Icon( + Icons.settings, + size: 26, + color: Theme.of(context).colorScheme.onBackground, + ), + title: Text( + 'Filters', + style: Theme.of(context).textTheme.titleSmall!.copyWith( + color: Theme.of(context).colorScheme.onBackground, + fontSize: 24, + ), + ), + onTap: () { + onSelectScreen('filters'); + }, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/meal_item.dart b/lib/widgets/meal_item.dart new file mode 100644 index 0000000..4d526c5 --- /dev/null +++ b/lib/widgets/meal_item.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:transparent_image/transparent_image.dart'; + +import 'package:flutter_meals/widgets/meal_item_trait.dart'; +import 'package:flutter_meals/models/meal.dart'; + +class MealItem extends StatelessWidget { + const MealItem({ + super.key, + required this.meal, + required this.onSelectMeal, + }); + + final Meal meal; + final void Function(Meal meal) onSelectMeal; + + String get complexityText { + return meal.complexity.name[0].toUpperCase() + + meal.complexity.name.substring(1); + } + + String get affordabilityText { + return meal.affordability.name[0].toUpperCase() + + meal.affordability.name.substring(1); + } + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.all(8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + clipBehavior: Clip.hardEdge, + elevation: 2, + child: InkWell( + onTap: () { + onSelectMeal(meal); + }, + child: Stack( + children: [ + FadeInImage( + placeholder: MemoryImage(kTransparentImage), + image: NetworkImage(meal.imageUrl), + fit: BoxFit.cover, + height: 200, + width: double.infinity, + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + color: Colors.black54, + padding: + const EdgeInsets.symmetric(vertical: 6, horizontal: 44), + child: Column( + children: [ + Text( + meal.title, + maxLines: 2, + textAlign: TextAlign.center, + softWrap: true, + overflow: TextOverflow.ellipsis, // Very long text ... + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MealItemTrait( + icon: Icons.schedule, + label: '${meal.duration} min', + ), + const SizedBox(width: 12), + MealItemTrait( + icon: Icons.work, + label: complexityText, + ), + const SizedBox(width: 12), + MealItemTrait( + icon: Icons.attach_money, + label: affordabilityText, + ) + ], + ), + ], + ), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/widgets/meal_item_trait.dart b/lib/widgets/meal_item_trait.dart new file mode 100644 index 0000000..3fc9ff0 --- /dev/null +++ b/lib/widgets/meal_item_trait.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +class MealItemTrait extends StatelessWidget { + const MealItemTrait({ + super.key, + required this.icon, + required this.label, + }); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Row(children: [ + Icon( + icon, + size: 17, + color: Colors.white, + ), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + color: Colors.white, + ), + ), + ]); + } +}