Building a Recipe App with Kotlin and Retrofit

In this tutorial, we will be building a recipe app using Kotlin and Retrofit. Kotlin is a modern programming language developed by JetBrains, which is fully compatible with Java. Retrofit is a popular HTTP client library for Android that simplifies the process of making API requests. By the end of this tutorial, you will have a fully functional recipe app that allows users to search for recipes and view their details.

building recipe app kotlin retrofit

Introduction

What is Kotlin?

Kotlin is a statically typed programming language that runs on the Java Virtual Machine (JVM). It was developed by JetBrains with the goal of improving Java productivity without sacrificing interoperability. Kotlin provides many features that help developers write clean and concise code, such as type inference, null safety, and extension functions.

What is Retrofit?

Retrofit is a type-safe HTTP client library for Android and Java. It allows you to easily make API requests and handle the response data. Retrofit uses annotations to define the structure of the API requests and responses, making it easy to work with RESTful APIs.

Why build a Recipe App?

Building a recipe app is a great way to learn how to use Kotlin and Retrofit together. It allows us to demonstrate the key features of both technologies, such as making API requests, handling response data, and displaying information to the user. Additionally, a recipe app is a practical and useful application that can be used by anyone interested in cooking.

Setting up the Project

To start building our recipe app, we need to set up the project and configure our dependencies.

Creating a new Kotlin project

First, we need to create a new Kotlin project in Android Studio. Open Android Studio and select "Start a new Android Studio project". Choose an application name and package name for your project, and select Kotlin as the programming language.

Adding Retrofit dependency

To use Retrofit in our project, we need to add its dependency to our build.gradle file. Open the build.gradle file for your app module and add the following line to the dependencies block:

implementation 'com.squareup.retrofit2:retrofit:2.9.0'

Sync your project to download the Retrofit library.

Setting up API endpoints

Next, we need to define the API endpoints for our recipe app. In this tutorial, we will be using the Spoonacular API, which provides a large database of recipes. Sign up for a free API key on the Spoonacular website and create a file called ApiConstants.kt. Add the following code to define the base URL and API key:

object ApiConstants {
    const val BASE_URL = "https://api.spoonacular.com/"
    const val API_KEY = "YOUR_API_KEY"
}

Replace "YOUR_API_KEY" with your actual API key.

Creating the User Interface

Now that our project is set up, we can start creating the user interface for our recipe app.

Designing the layout

First, we need to design the layout for our recipe app. Create a new XML file called activity_main.xml and add the following code to create a basic layout:

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <EditText
        android:id="@+id/searchEditText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Search recipes"/>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recipeRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</LinearLayout>

This layout consists of an EditText for searching recipes and a RecyclerView for displaying the search results.

Implementing RecyclerView

Next, we need to implement the RecyclerView in our MainActivity.kt file. Create a new Kotlin file called MainActivity.kt and add the following code:

class MainActivity : AppCompatActivity() {

    private lateinit var searchEditText: EditText
    private lateinit var recipeRecyclerView: RecyclerView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        searchEditText = findViewById(R.id.searchEditText)
        recipeRecyclerView = findViewById(R.id.recipeRecyclerView)

        // TODO: Set up RecyclerView adapter and layout manager

        // TODO: Add search functionality
    }
}

In the onCreate() method, we initialize the searchEditText and recipeRecyclerView variables by finding their corresponding views in the layout file.

Setting up RecyclerView adapter and layout manager

To display the search results in the RecyclerView, we need to set up the RecyclerView adapter and layout manager. Create a new Kotlin file called RecipeAdapter.kt and add the following code:

class RecipeAdapter(private val recipes: List<Recipe>) : RecyclerView.Adapter<RecipeAdapter.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_recipe, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val recipe = recipes[position]
        holder.bind(recipe)
    }

    override fun getItemCount(): Int {
        return recipes.size
    }

    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

        fun bind(recipe: Recipe) {
            // TODO: Bind recipe data to views
        }
    }
}

In the RecipeAdapter class, we define the onCreateViewHolder() and onBindViewHolder() methods, which are responsible for inflating the item_recipe layout and binding the recipe data to the views. The getItemCount() method returns the number of recipes in the list.

Next, go back to the MainActivity.kt file and add the following code to set up the RecyclerView adapter and layout manager:

val recipes = mutableListOf<Recipe>()
val adapter = RecipeAdapter(recipes)
recipeRecyclerView.adapter = adapter
recipeRecyclerView.layoutManager = LinearLayoutManager(this)

This code creates an empty list of recipes and creates an instance of the RecipeAdapter using this list. Then, it sets the adapter and layout manager for the recipeRecyclerView.

Handling user interactions

To add search functionality to our recipe app, we need to handle user interactions. Add the following code to the MainActivity.kt file:

searchEditText.setOnEditorActionListener { _, actionId, _ ->
    if (actionId == EditorInfo.IME_ACTION_SEARCH) {
        val query = searchEditText.text.toString()
        searchRecipes(query)
        true
    } else {
        false
    }
}

private fun searchRecipes(query: String) {
    // TODO: Implement searchRecipes() method
}

In this code, we set an OnEditorActionListener for the searchEditText. This listener is triggered when the user presses the search button on the keyboard. Inside the listener, we get the query text from the searchEditText and call the searchRecipes() method.

Implementing Retrofit

Now that our user interface is set up, we can start implementing Retrofit to make API requests and handle the response data.

Creating API service interface

First, we need to create an interface for our API service. Create a new Kotlin file called RecipeService.kt and add the following code:

interface RecipeService {

    @GET("recipes/complexSearch")
    suspend fun searchRecipes(
        @Query("apiKey") apiKey: String,
        @Query("query") query: String
    ): Response<RecipeSearchResponse>
}

In the RecipeService interface, we define a suspend function searchRecipes() that makes a GET request to the "recipes/complexSearch" endpoint of the Spoonacular API. The function takes two query parameters, apiKey and query, and returns a Response object containing the search results.

Making GET requests

Next, we need to create an instance of the Retrofit client and use it to make API requests. In the MainActivity.kt file, add the following code:

private val retrofit = Retrofit.Builder()
    .baseUrl(ApiConstants.BASE_URL)
    .addConverterFactory(GsonConverterFactory.create())
    .build()

private val recipeService = retrofit.create(RecipeService::class.java)

This code creates an instance of the Retrofit client using the base URL defined in the ApiConstants file. We also add a GsonConverterFactory to convert the JSON response into Kotlin objects. Finally, we create an instance of the RecipeService interface using the Retrofit client.

Handling response data

To handle the response data from the API, we need to modify the searchRecipes() method in the MainActivity.kt file. Add the following code:

private fun searchRecipes(query: String) {
    CoroutineScope(Dispatchers.IO).launch {
        try {
            val response = recipeService.searchRecipes(ApiConstants.API_KEY, query)
            if (response.isSuccessful) {
                val recipeSearchResponse = response.body()
                val recipes = recipeSearchResponse?.results
                withContext(Dispatchers.Main) {
                    adapter.recipes.clear()
                    if (recipes != null) {
                        adapter.recipes.addAll(recipes)
                    }
                    adapter.notifyDataSetChanged()
                }
            } else {
                withContext(Dispatchers.Main) {
                    Toast.makeText(this@MainActivity, "Error: ${response.message()}", Toast.LENGTH_SHORT).show()
                }
            }
        } catch (e: Exception) {
            withContext(Dispatchers.Main) {
                Toast.makeText(this@MainActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

In this code, we use CoroutineScope to launch a new coroutine on the IO dispatcher. Inside the coroutine, we make the API request using the searchRecipes() function of the RecipeService interface. If the response is successful, we extract the recipe data from the response and update the adapter's list of recipes. If the response is not successful, we display an error message to the user using a Toast.

Displaying Recipe Details

Now that we can search for recipes, let's implement the functionality to display the details of a selected recipe.

Creating RecipeDetailActivity

First, create a new activity called RecipeDetailActivity.kt and add the following code:

class RecipeDetailActivity : AppCompatActivity() {

    private lateinit var recipeTitleTextView: TextView
    private lateinit var recipeImageView: ImageView
    private lateinit var recipeSummaryTextView: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_recipe_detail)

        recipeTitleTextView = findViewById(R.id.recipeTitleTextView)
        recipeImageView = findViewById(R.id.recipeImageView)
        recipeSummaryTextView = findViewById(R.id.recipeSummaryTextView)

        val recipeId = intent.getIntExtra("recipeId", 0)
        fetchRecipeDetails(recipeId)
    }

    private fun fetchRecipeDetails(recipeId: Int) {
        // TODO: Implement fetchRecipeDetails() method
    }
}

In the onCreate() method, we initialize the views in the layout file and get the recipeId from the intent extras. We then call the fetchRecipeDetails() method to retrieve the details of the selected recipe.

Fetching recipe details

To fetch the details of a recipe, add the following code to the RecipeService.kt file:

@GET("recipes/{id}/information")
suspend fun getRecipeDetails(
    @Path("id") id: Int,
    @Query("apiKey") apiKey: String
): Response<RecipeDetailsResponse>

This code adds a new suspend function getRecipeDetails() to the RecipeService interface. It makes a GET request to the "recipes/{id}/information" endpoint of the Spoonacular API, where {id} is the id of the recipe. The function takes two parameters, id and apiKey, and returns a Response object containing the recipe details.

Back in the RecipeDetailActivity.kt file, add the following code to fetch the recipe details:

private fun fetchRecipeDetails(recipeId: Int) {
    CoroutineScope(Dispatchers.IO).launch {
        try {
            val response = recipeService.getRecipeDetails(recipeId, ApiConstants.API_KEY)
            if (response.isSuccessful) {
                val recipeDetailsResponse = response.body()
                withContext(Dispatchers.Main) {
                    recipeTitleTextView.text = recipeDetailsResponse?.title
                    recipeSummaryTextView.text = recipeDetailsResponse?.summary
                    Glide.with(this@RecipeDetailActivity)
                        .load(recipeDetailsResponse?.imageUrl)
                        .into(recipeImageView)
                }
            } else {
                withContext(Dispatchers.Main) {
                    Toast.makeText(this@RecipeDetailActivity, "Error: ${response.message()}", Toast.LENGTH_SHORT).show()
                }
            }
        } catch (e: Exception) {
            withContext(Dispatchers.Main) {
                Toast.makeText(this@RecipeDetailActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

In this code, we call the getRecipeDetails() method of the RecipeService interface to get the recipe details. If the response is successful, we update the views in the layout with the recipe title, summary, and image using the Glide library. If the response is not successful, we display an error message to the user.

Adding Search Functionality

To enhance our recipe app, let's implement search functionality that allows users to filter the search results.

Implementing search functionality

First, we need to modify the searchRecipes() method in the MainActivity.kt file to include a search query parameter. Add the following code:

private fun searchRecipes(query: String) {
    CoroutineScope(Dispatchers.IO).launch {
        try {
            val response = recipeService.searchRecipes(ApiConstants.API_KEY, query)
            if (response.isSuccessful) {
                val recipeSearchResponse = response.body()
                val recipes = recipeSearchResponse?.results
                withContext(Dispatchers.Main) {
                    adapter.recipes.clear()
                    if (recipes != null) {
                        adapter.recipes.addAll(recipes)
                    }
                    adapter.notifyDataSetChanged()
                }
            } else {
                withContext(Dispatchers.Main) {
                    Toast.makeText(this@MainActivity, "Error: ${response.message()}", Toast.LENGTH_SHORT).show()
                }
            }
        } catch (e: Exception) {
            withContext(Dispatchers.Main) {
                Toast.makeText(this@MainActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

In this code, we modify the searchRecipes() method to take a query parameter. We pass this query parameter to the searchRecipes() function of the RecipeService interface when making the API request.

Filtering search results

To implement the search functionality, we need to modify the RecipeAdapter.kt file to include a filter. Add the following code:

class RecipeAdapter(private val allRecipes: List<Recipe>) : RecyclerView.Adapter<RecipeAdapter.ViewHolder>(), Filterable {

    private var recipes: List<Recipe> = allRecipes

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        // ...
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        // ...
    }

    override fun getItemCount(): Int {
        return recipes.size
    }

    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        // ...
    }

    override fun getFilter(): Filter {
        return object : Filter() {
            override fun performFiltering(constraint: CharSequence?): FilterResults {
                val filteredList = if (constraint.isNullOrEmpty()) {
                    allRecipes
                } else {
                    allRecipes.filter { recipe ->
                        recipe.title.contains(constraint, true)
                    }
                }
                val filterResults = FilterResults()
                filterResults.values = filteredList
                return filterResults
            }

            override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
                recipes = results?.values as List<Recipe>
                notifyDataSetChanged()
            }
        }
    }
}

In this code, we add a new variable allRecipes to store the original list of recipes. We also add a filter to the RecipeAdapter class that filters the recipes based on a search query.

Updating RecyclerView

To update the RecyclerView with the filtered search results, modify the searchRecipes() method in the MainActivity.kt file. Add the following code:

private fun searchRecipes(query: String) {
    CoroutineScope(Dispatchers.IO).launch {
        try {
            val response = recipeService.searchRecipes(ApiConstants.API_KEY, query)
            if (response.isSuccessful) {
                val recipeSearchResponse = response.body()
                val recipes = recipeSearchResponse?.results
                withContext(Dispatchers.Main) {
                    adapter.allRecipes = recipes ?: emptyList()
                    adapter.filter.filter(query)
                }
            } else {
                withContext(Dispatchers.Main) {
                    Toast.makeText(this@MainActivity, "Error: ${response.message()}", Toast.LENGTH_SHORT).show()
                }
            }
        } catch (e: Exception) {
            withContext(Dispatchers.Main) {
                Toast.makeText(this@MainActivity, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

In this code, we update the allRecipes variable of the adapter with the new list of recipes. We then call the filter.filter() method of the adapter to trigger the filtering process.

Conclusion

In this tutorial, we have built a recipe app using Kotlin and Retrofit. We started by setting up the project and configuring the dependencies. We then designed the user interface and implemented RecyclerView to display the search results. Using Retrofit, we made API requests and handled the response data. We also implemented the functionality to display recipe details and added search functionality to filter the search results. By following this tutorial, you have learned how to use Kotlin and Retrofit to build a fully functional recipe app.