Flutter Simple Recipe App using Spoonacular API

Suluhu Code
9 min readDec 19, 2019

--

The following is a simple tutorial for creating a Flutter app using spoonacular API. This is so as to learn how to use APIs in flutter. spoonacular is a site rich in recipes, and has an API with great documentation.

This tutorial uses the free plan, and it has limited requests. You can watch the creation process on YouTube or follow below.

Let’s Get Started!

Create a new Flutter app on your Visual Studio Code, Android Studio or the editor of your choice.

Go to pubspec.yaml, and add the following dependencies:

http: ^0.12.0+2 — http Allows us to send requests to API to get data

webview_flutter: ^0.3.18+1 — webview allows us to view web pages within our app.

Don’t forget to run to get the packages installed in your app.

Go to sponacular’s api site, https://spoonacular.com/food-api. Create an account, and go to console. Go to profile on the side menu, and copy your API key. Keep it somewhere, we’ll use it.

On the lib folder, create a folder/package called model. Then, create a file called meal_model.dart and add the following code:

class Meal {final int id;
final String title, imgURL;
Meal({this.id, this.title, this.imgURL});//This class has an ID which allows us to get the Recipes and other info such as title and Image URL/*Factory Constructor Meal.fromMap parses the decoded JSON data to get the values of the meal, and returns the Meal Object*/factory Meal.fromMap(Map<String, dynamic> map) {
//Meal object
return Meal(
id: map['id'],
title: map['title'],
imgURL: 'https://spoonacular.com/recipeImages/' + map['image'],
);
}
}

Still on the model folder, create another file called meal_plan_model.dart and import meal_model.dart.

//import meal_model.dart
import 'meal_model.dart';
class MealPlan {
//MealPlan class has a list of meals and nutritional info about the meal plan
final List<Meal> meals;
final double calories, carbs, fat, protein;
MealPlan({
this.meals, this.calories, this.carbs, this.fat, this.protein
});
/*
The factory constructor iterates over the list of meals and our decoded mealplan
data and creates a list of meals.
Then, we return MealPlan object with all the information
*/
factory MealPlan.fromMap(Map<String, dynamic> map) {
List<Meal> meals = [];
map['meals'].forEach((mealMap) => meals.add(Meal.fromMap(mealMap)));
//MealPlan object with information we want
return MealPlan(
meals: meals,
calories: map['nutrients']['calories'],
carbs: map['nutrients']['carbohydrates'],
fat: map['nutrients']['fat'],
protein: map['nutrients']['protein'],
);
}
}

Lastly on the model folder, create a file called recipe_model.dart and add the following:

/* This class is responsible for getting and displaying meals 
in our webview
*/
class Recipe {
final String spoonacularSourceUrl;
//has Equipment, Ingredients, Steps, e.t.cRecipe({this.spoonacularSourceUrl,});//The spoonacularSourceURL displays the meals recipe in our webviewfactory Recipe.fromMap(Map<String, dynamic> map) {
return Recipe(
spoonacularSourceUrl: map['spoonacularSourceUrl'],
);
}
}

On the llib folder, create a folder/package called services, and create a file called services.dart.

//This file will handle all our API calls to the 
//Spoonacular API
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:spoonacular_app/model/meal_plan_model.dart';
import 'package:spoonacular_app/model/recipe_model.dart';
class ApiService {
//The API service will be a singleton, therefore create a private constructor
//ApiService._instantiate(), and a static instance variable
ApiService._instantiate();
static final ApiService instance = ApiService._instantiate();
//Add base URL for the spoonacular API, endpoint and API Key as a constant
final String _baseURL = "api.spoonacular.com";
static const String API_KEY ="1f9d617ba13041859ea773423b0e6291";
//We create async function to generate meal plan which takes in
//timeFrame, targetCalories, diet and apiKey
//If diet is none, we set the diet into an empty string//timeFrame parameter sets our meals into 3 meals, which are daily meals.
//that's why it's set to day
Future<MealPlan> generateMealPlan({int targetCalories, String diet}) async {
//check if diet is null
if (diet == 'None') diet = '';
Map<String, String> parameters = {
'timeFrame': 'day', //to get 3 meals
'targetCalories': targetCalories.toString(),
'diet': diet,
'apiKey': API_KEY,
};
//The Uri consists of the base url, the endpoint we are going to use. It has also
//parameters
Uri uri = Uri.https(
_baseURL,
'/recipes/mealplans/generate',
parameters,
);
//Our header specifies that we want the request to return a json object
Map<String, String> headers = {
HttpHeaders.contentTypeHeader: 'application/json',
};
/*
Our try catch uses http.get to retrieve response.
It then decodes the body of the response into a map,
and converts the map into a mealPlan object
by using the facory constructor MealPlan.fromMap
*/
try {
//http.get to retrieve the response
var response = await http.get(uri, headers: headers);
//decode the body of the response into a map
Map<String, dynamic> data = json.decode(response.body);
//convert the map into a MealPlan Object using the factory constructor,
//MealPlan.fromMap
MealPlan mealPlan = MealPlan.fromMap(data);
return mealPlan;
} catch (err) {
//If our response has error, we throw an error message
throw err.toString();
}
}
//the fetchRecipe takes in the id of the recipe we want to get the info for
//We also specify in the parameters that we don't want to include the nutritional
//information
//We also parse in our API key
Future<Recipe> fetchRecipe(String id) async {
Map<String, String> parameters = {
'includeNutrition': 'false',
'apiKey': API_KEY,
};
//we call in our recipe id in the Uri, and parse in our parameters
Uri uri = Uri.https(
_baseURL,
'/recipes/$id/information',
parameters,
);
//And also specify that we want our header to return a json object
Map<String, String> headers = {
HttpHeaders.contentTypeHeader: 'application/json',
};
//finally, we put our response in a try catch block
try{
var response = await http.get(uri, headers: headers);
Map<String, dynamic> data = json.decode(response.body);
Recipe recipe = Recipe.fromMap(data);
return recipe;
}catch (err) {
throw err.toString();
}
}}

Go to main.dart file and edit it to have the following:

import 'package:flutter/material.dart';
import 'package:spoonacular_app/screens/search_screen.dart';
void main() => runApp(MyApp());class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Recipe App',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primaryColor: Colors.orange[500],
),
home: SearchScreen(),
);
}
}

Create a folder called screens and in it, create search_screen.dart. Add the following

import 'package:flutter/material.dart';
import 'package:spoonacular_app/model/meal_plan_model.dart';
import 'package:spoonacular_app/services/api_services.dart';
import 'meals_screen.dart';class SearchScreen extends StatefulWidget {
@override
_SearchScreenState createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
/*
Our state has three parameters.
diets - list of diet that the spoonacular api let's us filter by,
targetCalories - desired number of calories we want our mealplan to reach
diet - our selected diet
*/
List<String> _diets = [
//List of diets that lets spoonacular filter
'None',
'Gluten Free',
'Ketogenic',
'Lacto-Vegetarian',
'Ovo-Vegetarian',
'Vegan',
'Pescetarian',
'Paleo',
'Primal',
'Whole30',
];
double _targetCalories = 2250;
String _diet = 'None';
@override//This method generates a MealPlan by parsing our parameters into the
//ApiService.instance.generateMealPlan.
//It then pushes the Meal Screen onto the stack with Navigator.push
void _searchMealPlan() async {
MealPlan mealPlan = await ApiService.instance.generateMealPlan(
targetCalories: _targetCalories.toInt(),
diet: _diet,
);
Navigator.push(context, MaterialPageRoute(
builder: (_) => MealsScreen(mealPlan: mealPlan),
));
}
Widget build(BuildContext context) {
/*
Our build method returns Scaffold Container, which has a decoration
image using a Network Image. The image loads and is the background of
the page
*/
return Scaffold(
body: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage(
'https://images.unsplash.com/photo-1482049016688-2d3e1b311543?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=353&q=80'),
fit: BoxFit.cover,
),
),
//Center widget and a container as a child, and a column widget
child: Center(
child: Container(
margin: EdgeInsets.symmetric(horizontal: 30),
padding: EdgeInsets.symmetric(horizontal: 30),
height: MediaQuery.of(context).size.height * 0.55,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(15),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
//Text widget for our app's title
Text(
'My Daily Meal Planner',
style: TextStyle(fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 2),
),
//space
SizedBox(height: 20),
//A RichText to style the target calories
RichText(
text: TextSpan(
style: Theme.of(context).textTheme.body1.copyWith(fontSize: 25),
children: [
TextSpan(
text: _targetCalories.truncate().toString(),
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold
)
),
TextSpan(
text: 'cal',
style: TextStyle(
fontWeight: FontWeight.w600
)
),
]
),
),
//Orange slider that sets our target calories
SliderTheme(
data: SliderTheme.of(context).copyWith(
thumbColor: Theme.of(context).primaryColor,
inactiveTrackColor: Colors.lightBlue[100],
trackHeight: 6,
),
child: Slider(
min: 0,
max: 4500,
value: _targetCalories,
onChanged: (value) => setState(() {
_targetCalories = value.round().toDouble();
}
),
),
),
//Simple drop down to select the type of diet
Padding(
padding: EdgeInsets.symmetric(horizontal: 30),
child: DropdownButtonFormField(
items: _diets.map((String priority) {
return DropdownMenuItem(
value: priority,
child: Text(
priority,
style: TextStyle(
color: Colors.black,
fontSize: 18
),
),
);
}).toList(),
decoration: InputDecoration(
labelText: 'Diet',
labelStyle: TextStyle(fontSize: 18),
),
onChanged: (value) {
setState(() {
_diet = value;
});
},
value: _diet,
),
),
//Space
SizedBox(height: 30),
//FlatButton where onPressed() triggers a function called _searchMealPlan
FlatButton(
padding: EdgeInsets.symmetric(horizontal: 60, vertical: 8),
color: Theme.of(context).primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
child: Text(
'Search', style: TextStyle(
color: Colors.black,
fontSize: 22,
fontWeight: FontWeight.w600,
),
),
//_searchMealPlan function is above the build method
onPressed: _searchMealPlan,
),
],
),
),
),
),
);
}
}

In screens folder, create a file named meals_screen.dart

import 'package:flutter/material.dart';
import 'package:spoonacular_app/model/meal_model.dart';
import 'package:spoonacular_app/model/meal_plan_model.dart';
import 'package:spoonacular_app/model/recipe_model.dart';
import 'package:spoonacular_app/screens/recipe_screen.dart';
import 'package:spoonacular_app/services/api_services.dart';
class MealsScreen extends StatefulWidget {
//It returns a final mealPlan variable
final MealPlan mealPlan;
MealsScreen({this.mealPlan});
@override
_MealsScreenState createState() => _MealsScreenState();
}
class _MealsScreenState extends State<MealsScreen> {/*
Returns aContainer with Curved edges and a BoxShadow.
The child is a column widget that returns nutrient information in Rows
*/
_buildTotalNutrientsCard() {
return Container(
height: 140,
margin: EdgeInsets.all(20),
padding: EdgeInsets.symmetric(horizontal: 15, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
boxShadow: [
BoxShadow(
color: Colors.black12, offset: Offset(0, 2), blurRadius: 6)
]),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Total Nutrients',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
'Calories: ${widget.mealPlan.calories.toString()} cal',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
Text(
'Protein: ${widget.mealPlan.protein.toString()} g',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
],
),
SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
'Fat: ${widget.mealPlan.fat.toString()} g',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
Text(
'Carb: ${widget.mealPlan.carbs.toString()} cal',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
);
}
//This method below takes in parameters meal and index
_buildMealCard(Meal meal, int index) {
//We define a String variable mealType, that equals method called mealType
String mealType = _mealType(index);
//We return stack widget with center alignment
return GestureDetector(
//We wrap our stack with gesture detector to navigate to webview page
/*
The async onTap function will fetch the recipe by id using the
fetchRecipe method.
It will then navigate to RecipeScreen, while parsing in our mealType and recipe
*/
onTap: () async {
Recipe recipe =
await ApiService.instance.fetchRecipe(meal.id.toString());
Navigator.push(context,
MaterialPageRoute(builder: (_) => RecipeScreen(
mealType: mealType,
recipe: recipe,
)));
},
child: Stack(
alignment: Alignment.center,
children: <Widget>[
//First widget is a container that loads decoration image
Container(
height: 220,
width: double.infinity,
margin: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
padding: EdgeInsets.symmetric(horizontal: 15, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
image: DecorationImage(
image: NetworkImage(meal.imgURL),
fit: BoxFit.cover,
),
borderRadius: BorderRadius.circular(15),
boxShadow: [
BoxShadow(
color: Colors.black12, offset: Offset(0, 2), blurRadius: 6)
]),
),
//Second widget is a Container that has 2 text widgets
Container(
margin: EdgeInsets.all(60),
padding: EdgeInsets.all(10),
color: Colors.white70,
child: Column(
children: <Widget>[
Text(
//mealtype
mealType,
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
letterSpacing: 1.5
),
),
Text(
//mealtitle
meal.title,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600
),
overflow: TextOverflow.ellipsis,
)
],
),
)
]
),
);
}
/*
mealType returns Breakfast, Lunch or Dinner, depending on the index value
*/
_mealType(int index) {
switch (index) {
case 0:
return 'Breakfast';
case 1:
return 'Lunch';
case 2:
return 'Dinner';
default:
return 'Breakfast';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
//has an appBar
appBar: AppBar(title: Text('Your Meal Plan')),
//and body as a ListView builder
body: ListView.builder(
/*
We set itemCount to 1 + no. of meals, which based on our API call,
the no. of meals should always be 3
*/
itemCount: 1 + widget.mealPlan.meals.length,
itemBuilder: (BuildContext context, int index) {
/*
If index is 0, we return a method called _buildTotalNutrientsCard()
*/
if (index == 0) {
return _buildTotalNutrientsCard();
}
/*
Otherwise, we return a buildMealCard method that takes in the meal,
and index - 1
*/
Meal meal = widget.mealPlan.meals[index - 1];
return _buildMealCard(meal, index - 1);
}),
);
}
}

FINALLY, on screens folder, create a file named recipe_screen.dart. This will have the web view.

import 'package:flutter/material.dart';
import 'package:spoonacular_app/model/recipe_model.dart';
import 'package:webview_flutter/webview_flutter.dart';
class RecipeScreen extends StatefulWidget {
//This stateful widget page takes in String mealType and Recipe recipe
final String mealType;
final Recipe recipe;
RecipeScreen({this.mealType, this.recipe});@override
_RecipeScreenState createState() => _RecipeScreenState();
}
class _RecipeScreenState extends State<RecipeScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
//AppBar is widget.mealType
appBar: AppBar(
title: Text(widget.mealType),
),
/**
* Body is a Webview. Ensure you have imported webview flutter.
*
* initialUrl- spoonacularSourceUrl of our parsed in recipe
* javascriptMode - set to unrestricted so as JS can load in the webview
*/
body: WebView(
initialUrl: widget.recipe.spoonacularSourceUrl,
//JS unrestricted, so that JS can execute in the webview
javascriptMode: JavascriptMode.unrestricted,
),
);
}
}

That’s it! Run your app. The following are the screenshots

Thank you for following through! Please go through the spoonacular api to learn more on how to implement it.

Finally, please subscribe to my YouTube channel for more videos.

» Resources

Source Code: https://github.com/suluhutex/flutter-spoonaculat

http package: https://pub.dev/packages/http

webview_flutter package: https://pub.dev/packages/webview_flutter

Spoonacular API: https://spoonacular.com/food-api

Spoonacular API Documentation: https://spoonacular.com/food-api/docs

--

--

Suluhu Code

I am passionate with code. Teaching has been a new way of learning for me.