Open In App

Build a Recipe App using MVVM Architecture with Kotlin in Android

Last Updated : 09 Apr, 2025
Comments
Improve
Suggest changes
Like Article
Like
Report

In this article, we will make a recipe app that displays a list of Italian recipes using the retrofit library and MVVM architecture. Model — View — ViewModel (MVVM) is the industry-recognized software architecture pattern that overcomes all drawbacks of MVP and MVC design patterns. MVVM suggests separating the data presentation logic(Views or UI) from the core business logic part of the application.

We will fetch data from The Meal DB website.

Build-a-Recipe-App-using-MVVM-Architecture-with-Kotlin-in-Android


Layers of MVVM:

  • Model: This layer is responsible for the abstraction of the data sources. Model and ViewModel work together to get and save the data.
  • View: The purpose of this layer is to inform the ViewModel about the user’s action. This layer observes the ViewModel and does not contain any kind of application logic.
  • ViewModel: It exposes those data streams which are relevant to the View. Moreover, it serves as a link between the Model and the View.
MVVM Architecture

Step by Step Implementation

Step 1: Create a New Project in Android Studio

To create a new project in Android Studio, please refer to How to Create/Start a New Project in Android Studio.

Note that select Kotlin as the programming language.

File and Folder Structure :

recipe-app-dir


Step 2: Add Required Dependencies

Navigate to Gradle Scripts > build.gradle.kts (Module :app) and add the following dependencies under the dependencies {} scope.

Kotlin
// retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")

// view model and livedata
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.7")

// glide (image loading)
implementation ("com.github.bumptech.glide:glide:4.16.0")


Step 3: Allow Internet permission

To allow permission for internet, navigate to app > manifests > AndroidManifest.xml and add the following code under the <manifest/> tag.

XML
<uses-permission android:name="android.permission.INTERNET"/>


Step 4: Working with Data classes

Create 4 data classes for response from api. We will be using the TheMealDB api for this project. Check out the docs here. Navigate to app > kotlin+java > {package-name}, right click on the folder and select, New > Package, set the name as models and press enter. Now right click on the models folder and select, New > Kotlin Class/File, then select Data Class and set the names as Meal, MealDetail, RecipeDetailResponse, and RecipeResponse and add the following code to the files.

Meal.kt
package com.example.recipeapp.models

data class Meal(
    val idMeal: String,
    val strMeal: String,
    val strMealThumb: String
)
MealDetail.kt
package com.example.recipeapp.models

data class MealDetail(
    val dateModified: Any,
    val idMeal: String,
    val strArea: String?,
    val strCategory: String?,
    val strCreativeCommonsConfirmed: Any?,
    val strDrinkAlternate: Any?,
    val strImageSource: Any?,
    val strIngredient1: String?,
    val strIngredient10: String?,
    val strIngredient11: String?,
    val strIngredient12: String?,
    val strIngredient13: String?,
    val strIngredient14: String?,
    val strIngredient15: String?,
    val strIngredient16: String?,
    val strIngredient17: String?,
    val strIngredient18: String?,
    val strIngredient19: String?,
    val strIngredient2: String?,
    val strIngredient20: String?,
    val strIngredient3: String?,
    val strIngredient4: String?,
    val strIngredient5: String?,
    val strIngredient6: String?,
    val strIngredient7: String?,
    val strIngredient8: String?,
    val strIngredient9: String?,
    val strInstructions: String?,
    val strMeal: String?,
    val strMealThumb: String?,
    val strMeasure1: String?,
    val strMeasure10: String?,
    val strMeasure11: String?,
    val strMeasure12: String?,
    val strMeasure13: String?,
    val strMeasure14: String?,
    val strMeasure15: String?,
    val strMeasure16: String?,
    val strMeasure17: String?,
    val strMeasure18: String?,
    val strMeasure19: String?,
    val strMeasure2: String?,
    val strMeasure20: String?,
    val strMeasure3: String?,
    val strMeasure4: String?,
    val strMeasure5: String?,
    val strMeasure6: String?,
    val strMeasure7: String?,
    val strMeasure8: String?,
    val strMeasure9: String?,
    val strSource: String?,
    val strTags: Any?,
    val strYoutube: String?
)
RecipeDetailResponse.kt
package com.example.recipeapp.models

data class RecipeDetailResponse(
    val meals: List<MealDetail>
)
RecipeResponse.kt
package com.example.recipeapp.models

data class RecipeResponse(
    val meals: List<Meal>
)


Step 5: Working with Network calls

Now, we will making network api calls using Retrofit. To do this, create a similar package like models and name it network. Now, create two kotlin files with the names ApiClient and ApiService which are an object and an interface respectively. Add the following code to those files.

ApiClient.kt
package com.example.recipeapp.network

import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

// API base URL
private const val BASE_URL = "https://www.themealdb.com/api/json/v1/1/"

// Retrofit setup
object ApiClient {
    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .client(OkHttpClient.Builder().build())
        .build()

    val apiService: ApiService = retrofit.create(ApiService::class.java)
}
ApiService.kt
package com.example.recipeapp.network

import com.example.recipeapp.models.RecipeDetailResponse
import com.example.recipeapp.models.RecipeResponse
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query

interface ApiService {
    @GET("filter.php?a=Italian")
    fun getRecipes(): Call<RecipeResponse>

    @GET("lookup.php")
    fun getRecipeDetails(@Query("i") mealId: String): Call<RecipeDetailResponse>
}


Step 6: Working with MainActivity layouts

Navigate to app > res > layout > activity_main.xml. Now, create a layout named list_item.xml for the each item in the recyclerview. Also, create a drawable file to set as the background for each list item. Navigate to the res > drawable folder and create a new file name card_bg.xml

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white"
    tools:context=".ui.home.MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginTop="32dp"
        tools:listitem="@layout/list_item"/>

</LinearLayout>
list_item.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="180dp"
    android:layout_height="wrap_content"
    android:layout_margin="8dp"
    android:focusable="true"
    app:cardCornerRadius="28dp"
    app:cardElevation="4dp"
    android:clickable="true">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:gravity="center_horizontal"
        android:background="@drawable/card_bg">

        <androidx.cardview.widget.CardView
            android:layout_width="150dp"
            android:layout_height="150dp"
            android:layout_margin="16dp"
            app:cardCornerRadius="24dp"
            app:cardElevation="5dp"
            >

            <ImageView
                android:id="@+id/mealImage"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:src="@color/white"
                android:scaleType="centerCrop"/>

        </androidx.cardview.widget.CardView>

        <TextView
            android:id="@+id/mealName"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="18sp"
            android:textColor="@color/white"
            android:text="Meal Name"
            android:textStyle="bold"
            android:textAlignment="center"
            android:layout_marginBottom="16dp"
            android:layout_marginStart="16dp"
            android:layout_marginEnd="16dp"
            android:layout_gravity="center_horizontal" />

    </LinearLayout>
</androidx.cardview.widget.CardView>
card_bg.xml
<?xml version="1.0" encoding="utf-8"?>
<shape 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">

    <gradient android:startColor="#1B1B1B" android:endColor="#515151" android:angle="270" />

</shape>


Design UI:

recipe-app-main


Step 7: Working with Home Screen (MainActivity)

Navigate to app > kotlin+java > {package-name}, and create a package inside this folder named ui and create another package inside the ui folder with the name home. Inside this folder drag the MainActivity.kt file, and create two new Kotlin files with the name RecipeAdapter.kt and RecipeViewModel.kt. Now, add the following codes into their respective files.

MainActivity.kt
package com.example.recipeapp.ui.home

import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.recipeapp.R

class MainActivity : AppCompatActivity() {

    private lateinit var recyclerView: RecyclerView
    private val recipeViewModel: RecipeViewModel by viewModels()

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

        recyclerView = findViewById(R.id.recyclerView)

        // Observe data from the ViewModel
        recipeViewModel.recipes.observe(this) { meals ->
            val adapter = RecipeAdapter(this@MainActivity, meals)
            recyclerView.layoutManager = GridLayoutManager(this@MainActivity, 2)
            recyclerView.adapter = adapter
        }

        // Fetch data from the API
        recipeViewModel.fetchRecipes()
    }
}
RecipeAdapter.kt
package com.example.recipeapp.ui.home

import android.content.Context
import android.content.Intent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.recipeapp.R
import com.example.recipeapp.ui.details.RecipeDetailActivity
import com.example.recipeapp.models.Meal

class RecipeAdapter(
    private val context: Context,
    private val meals: List<Meal>
) : RecyclerView.Adapter<RecipeAdapter.ViewHolder>() {
    class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val imageView: ImageView = itemView.findViewById(R.id.mealImage)
        val textView: TextView = itemView.findViewById(R.id.mealName)
    }

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

        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = meals[position]
        Glide.with(context).load(item.strMealThumb).into(holder.imageView)
        holder.textView.text = item.strMeal

        holder.itemView.setOnClickListener {
            val intent = Intent(context, RecipeDetailActivity::class.java)
            intent.putExtra("MEAL_ID", item.idMeal)
            context.startActivity(intent)
        }
    }

    override fun getItemCount(): Int = meals.size
}
RecipeViewModel.kt
package com.example.recipeapp.ui.home

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.example.recipeapp.models.Meal
import com.example.recipeapp.models.RecipeResponse
import com.example.recipeapp.network.ApiClient
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response

class RecipeViewModel(application: Application) : AndroidViewModel(application) {

    private val _recipes = MutableLiveData<List<Meal>>()
    val recipes: LiveData<List<Meal>> get() = _recipes

    // Fetch the list of recipes
    fun fetchRecipes() {
        ApiClient.apiService.getRecipes().enqueue(object : Callback<RecipeResponse> {
            override fun onResponse(call: Call<RecipeResponse>, response: Response<RecipeResponse>) {
                if (response.isSuccessful) {
                    _recipes.value = response.body()?.meals ?: emptyList()
                }
            }

            override fun onFailure(call: Call<RecipeResponse>, t: Throwable) {
                // Handle failure
            }
        })
    }
}


Step 8: Working with Details Screen

Navigate to the ui folder and create a package inside it with the name details and create a new activity with the name RecipeDetailActivity.kt. Now, create another kotlin class to setup the view model of the recipe details screen with the name RecipeDetailViewModel.kt. Then, add the following codes to their respective files.

RecipeDetailActivity.kt
package com.example.recipeapp.ui.details

import android.os.Bundle
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.bumptech.glide.Glide
import com.example.recipeapp.R
import com.example.recipeapp.models.MealDetail

class RecipeDetailActivity : AppCompatActivity() {

    private lateinit var recipeImage: ImageView
    private lateinit var recipeName: TextView
    private lateinit var recipeInstructions: TextView
    private lateinit var recipeIngredients: TextView
    private val recipeDetailViewModel: RecipeDetailViewModel by viewModels()

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

        recipeImage = findViewById(R.id.recipeImage)
        recipeName = findViewById(R.id.recipeName)
        recipeInstructions = findViewById(R.id.recipeInstructions)
        recipeIngredients = findViewById(R.id.recipeIngredients)

        val mealId = intent.getStringExtra("MEAL_ID") ?: return
        recipeDetailViewModel.fetchRecipeDetails(mealId)

        recipeDetailViewModel.recipeDetails.observe(this, { meal ->
            meal?.let {
                recipeName.text = it.strMeal
                recipeInstructions.text = it.strInstructions
                recipeIngredients.text = getIngredients(it)
                Glide.with(this).load(it.strMealThumb).into(recipeImage)
            }
        })
    }

    private fun getIngredients(meal: MealDetail): String {
        val ingredients = mutableListOf<String>()
        for (i in 1..20) {
            val ingredient = meal.javaClass.getDeclaredField("strIngredient$i").apply { isAccessible = true }.get(meal) as? String
            val measure = meal.javaClass.getDeclaredField("strMeasure$i").apply { isAccessible = true }.get(meal) as? String

            if (!ingredient.isNullOrEmpty()) {
                ingredients.add("$ingredient - $measure")
            }
        }
        return ingredients.joinToString("\n")
    }
}
RecipeDetailViewModel.kt
package com.example.recipeapp.ui.details

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.example.recipeapp.network.ApiClient
import com.example.recipeapp.models.MealDetail
import com.example.recipeapp.models.RecipeDetailResponse
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response

class RecipeDetailViewModel(application: Application) : AndroidViewModel(application) {

    private val _recipeDetails = MutableLiveData<MealDetail>()
    val recipeDetails: LiveData<MealDetail> get() = _recipeDetails

    fun fetchRecipeDetails(mealId: String) {
        ApiClient.apiService.getRecipeDetails(mealId).enqueue(object : Callback<RecipeDetailResponse> {
            override fun onResponse(call: Call<RecipeDetailResponse>, response: Response<RecipeDetailResponse>) {
                if (response.isSuccessful) {
                    _recipeDetails.value = response.body()?.meals?.firstOrNull() // This is MealDetail now
                }
            }

            override fun onFailure(call: Call<RecipeDetailResponse>, t: Throwable) {
                // Handle failure
            }
        })
    }
}
activity_recipe_detail.xml
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white"
    tools:context=".ui.details.RecipeDetailActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:layout_marginTop="32dp"
        android:padding="16dp">

        <androidx.cardview.widget.CardView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="16dp"
            app:cardCornerRadius="24dp"
            app:cardElevation="5dp">

            <ImageView
                android:id="@+id/recipeImage"
                android:layout_width="match_parent"
                android:layout_height="250dp"
                android:contentDescription="Recipe Image"
                android:scaleType="centerCrop" />

        </androidx.cardview.widget.CardView>

        <TextView
            android:id="@+id/recipeName"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="24sp"
            android:gravity="center"
            android:textStyle="bold"
            android:text="Recipe Name" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="18sp"
            android:layout_marginTop="16dp"
            android:textStyle="bold"
            android:text="Instructions" />

        <TextView
            android:id="@+id/recipeInstructions"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="16sp"
            android:layout_marginTop="16dp"
            android:text="Instructions" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="18sp"
            android:layout_marginTop="16dp"
            android:textStyle="bold"
            android:text="Ingredients" />

        <TextView
            android:id="@+id/recipeIngredients"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="16sp"
            android:layout_marginTop="16dp"
            android:text="Ingredients" />

    </LinearLayout>

</ScrollView>


Design UI:

recipe-app-details

Refer to the following github repo to get the entire code: Recipe_Android_Application


Output:




Next Article

Similar Reads