The last project joins everything. It searches recipes online, shows details, lets you save favourites that work offline and has a bottom navigation bar. It also introduces dependency injection with Hilt, the standard tool for wiring a larger app together, so the code stays organised as it grows.
Concepts in this project
- Layers: UI (screens), ViewModels (state and logic), repository (one place for data), and data sources (Retrofit for the network, Room for the database).
- Dependency injection: instead of a class creating the things it needs, they are handed in. Hilt creates and shares one Retrofit, one database and one repository for the whole app.
- `@HiltViewModel` and `hiltViewModel()`: ViewModels get their dependencies automatically.
- SavedStateHandle: gives the ViewModel the navigation arguments, such as the recipe ID.
- Bottom navigation:
NavigationBarswitches between Search and Favourites while keeping each screen's state. - Data source: TheMealDB, a free recipe API with a public test key. Check its current terms and whether the test key still works before building.
Create the project
- New Empty Activity project
Recipes, packagecom.example.recipes. - Add the Hilt and KSP plugins, then the Hilt, Retrofit, Room, Coil, serialization and navigation libraries. Sync.
- Add
android:name=".RecipeApp"to the<application>tag and the INTERNET permission to the manifest. - Create the files in the structure below.
`Gradle additions for project 10`
// plugins block of app/build.gradle.kts
id("com.google.devtools.ksp") version "2.0.21-1.0.28"
id("com.google.dagger.hilt.android") version "2.52"
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
// dependencies block
implementation("com.google.dagger:hilt-android:2.52")
ksp("com.google.dagger:hilt-android-compiler:2.52")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.compose.material:material-icons-extended") // Favorite icons live here
Recipes/app/src/main/java/com/example/recipes/
RecipeApp.kt @HiltAndroidApp application class
MainActivity.kt @AndroidEntryPoint, shows RecipeNav
Models.kt API models + Recipe + favourite entity
MealApi.kt Retrofit interface
FavouriteDao.kt Room DAO + database
AppModule.kt Hilt module: provides Retrofit, Room, DAO
RecipeRepository.kt one place to get recipes and favourites
RecipeViewModels.kt Search, Detail and Favourites ViewModels
RecipeNav.kt NavHost, bottom bar, screens
The code
`app/src/main/java/com/example/recipes/RecipeApp.kt`
package com.example.recipes
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
// This one annotation makes Hilt build its dependency graph when the app starts.
// Register the class in AndroidManifest.xml: <application android:name=".RecipeApp" ...>
@HiltAndroidApp
class RecipeApp : Application()
`app/src/main/java/com/example/recipes/MainActivity.kt`
package com.example.recipes
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { MaterialTheme { RecipeNav() } }
}
}
`app/src/main/java/com/example/recipes/Models.kt`
package com.example.recipes
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
// What the API sends. TheMealDB uses strIngredient1..strIngredient20, so we read each meal as a JsonObject.
@Serializable
data class MealsResponse(val meals: List<JsonObject>? = null)
// What the app uses everywhere else.
data class Recipe(
val id: String,
val name: String,
val thumb: String,
val category: String,
val area: String,
val instructions: String,
val ingredients: List<String>
)
private fun JsonObject.str(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
fun JsonObject.toRecipe(): Recipe = Recipe(
id = str("idMeal") ?: "",
name = str("strMeal") ?: "Unnamed recipe",
thumb = str("strMealThumb") ?: "",
category = str("strCategory") ?: "",
area = str("strArea") ?: "",
instructions = str("strInstructions") ?: "",
ingredients = (1..20).mapNotNull { i ->
val ingredient = str("strIngredient$i") ?: return@mapNotNull null
listOfNotNull(str("strMeasure$i"), ingredient).joinToString(" ")
}
)
// A small copy of a recipe, saved on the phone when the user taps the heart.
@Entity(tableName = "favourites")
data class Favourite(@PrimaryKey val id: String, val name: String, val thumb: String, val category: String)
The API returns each ingredient as separate numbered fields (strIngredient1, strIngredient2...). The mapper reads the JSON as a generic object and collects the non-empty ones into a clean list, so the rest of the app sees a tidy Recipe. This is a common job at the edge of an app: shield your code from messy outside data.
`app/src/main/java/com/example/recipes/MealApi.kt`
package com.example.recipes
import retrofit2.http.GET
import retrofit2.http.Query
interface MealApi {
@GET("search.php")
suspend fun search(@Query("s") text: String): MealsResponse
@GET("lookup.php")
suspend fun lookup(@Query("i") id: String): MealsResponse
}
`app/src/main/java/com/example/recipes/FavouriteDao.kt`
package com.example.recipes
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface FavouriteDao {
@Query("SELECT * FROM favourites ORDER BY name")
fun observeAll(): Flow<List<Favourite>>
@Query("SELECT EXISTS(SELECT 1 FROM favourites WHERE id = :id)")
fun observeIsFavourite(id: String): Flow<Boolean>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun add(favourite: Favourite)
@Query("DELETE FROM favourites WHERE id = :id")
suspend fun remove(id: String)
}
@Database(entities = [Favourite::class], version = 1, exportSchema = false)
abstract class AppDb : RoomDatabase() {
abstract fun favouriteDao(): FavouriteDao
}
`app/src/main/java/com/example/recipes/AppModule.kt`
package com.example.recipes
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Singleton
// A module tells Hilt how to build things it cannot build by itself (Retrofit, Room).
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides @Singleton
fun mealApi(): MealApi = Retrofit.Builder()
.baseUrl("https://www.themealdb.com/api/json/v1/1/")
.addConverterFactory(Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType()))
.build()
.create(MealApi::class.java)
@Provides @Singleton
fun database(@ApplicationContext context: Context): AppDb =
Room.databaseBuilder(context, AppDb::class.java, "recipes.db").build()
@Provides
fun favouriteDao(db: AppDb): FavouriteDao = db.favouriteDao()
}
`app/src/main/java/com/example/recipes/RecipeRepository.kt`
package com.example.recipes
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipeRepository @Inject constructor(
private val api: MealApi,
private val dao: FavouriteDao
) {
suspend fun search(text: String): List<Recipe> =
api.search(text.trim()).meals.orEmpty().map { it.toRecipe() }
suspend fun recipe(id: String): Recipe? = api.lookup(id).meals?.firstOrNull()?.toRecipe()
val favourites: Flow<List<Favourite>> = dao.observeAll()
fun isFavourite(id: String): Flow<Boolean> = dao.observeIsFavourite(id)
suspend fun toggleFavourite(recipe: Recipe, currentlyFavourite: Boolean) {
if (currentlyFavourite) dao.remove(recipe.id)
else dao.add(Favourite(recipe.id, recipe.name, recipe.thumb, recipe.category))
}
}
`app/src/main/java/com/example/recipes/RecipeViewModels.kt`
package com.example.recipes
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SearchState(val loading: Boolean = false, val results: List<Recipe> = emptyList(), val message: String? = "Search for a dish, such as chicken or pasta.")
@HiltViewModel
class SearchViewModel @Inject constructor(private val repo: RecipeRepository) : ViewModel() {
private val _state = MutableStateFlow(SearchState())
val state = _state.asStateFlow()
fun search(text: String) {
if (text.isBlank()) return
_state.value = SearchState(loading = true, message = null)
viewModelScope.launch {
_state.value = try {
val list = repo.search(text)
SearchState(results = list, message = if (list.isEmpty()) "No recipes found for \"$text\"." else null)
} catch (e: Exception) {
SearchState(message = "Could not reach the recipe service. Check your connection.")
}
}
}
}
data class DetailState(val loading: Boolean = true, val recipe: Recipe? = null, val error: String? = null)
@HiltViewModel
class DetailViewModel @Inject constructor(private val repo: RecipeRepository, saved: SavedStateHandle) : ViewModel() {
private val id: String = checkNotNull(saved["id"]) // filled in from the navigation route "detail/{id}"
private val _state = MutableStateFlow(DetailState())
val state = _state.asStateFlow()
val isFavourite = repo.isFavourite(id).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
init {
viewModelScope.launch {
_state.value = try {
repo.recipe(id)?.let { DetailState(false, it) } ?: DetailState(false, error = "This recipe was not found.")
} catch (e: Exception) {
DetailState(false, error = "Could not load this recipe.")
}
}
}
fun toggleFavourite() {
val recipe = _state.value.recipe ?: return
viewModelScope.launch { repo.toggleFavourite(recipe, isFavourite.value) }
}
}
@HiltViewModel
class FavouritesViewModel @Inject constructor(repo: RecipeRepository) : ViewModel() {
val favourites = repo.favourites.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}
`app/src/main/java/com/example/recipes/RecipeNav.kt`
package com.example.recipes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import coil.compose.AsyncImage
@Composable
fun RecipeNav() {
val nav = rememberNavController()
val route = nav.currentBackStackEntryAsState().value?.destination?.route
Scaffold(bottomBar = {
if (route == "search" || route == "favourites") NavigationBar {
NavigationBarItem(route == "search", { nav.navigate("search") { launchSingleTop = true } }, { Icon(Icons.Default.Search, null) }, label = { Text("Search") })
NavigationBarItem(route == "favourites", { nav.navigate("favourites") { launchSingleTop = true } }, { Icon(Icons.Default.Favorite, null) }, label = { Text("Saved") })
}
}) { padding ->
NavHost(nav, startDestination = "search", modifier = Modifier.padding(padding)) {
composable("search") { SearchScreen(onOpen = { nav.navigate("detail/$it") }) }
composable("favourites") { FavouritesScreen(onOpen = { nav.navigate("detail/$it") }) }
composable("detail/{id}") { DetailScreen(onBack = { nav.popBackStack() }) }
}
}
}
@Composable
private fun RecipeRow(name: String, subtitle: String, thumb: String, onClick: () -> Unit) {
Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
AsyncImage(thumb, null, Modifier.size(72.dp).clip(MaterialTheme.shapes.medium), contentScale = ContentScale.Crop)
Spacer(Modifier.width(12.dp))
Column { Text(name, style = MaterialTheme.typography.titleMedium); Text(subtitle, style = MaterialTheme.typography.bodySmall) }
}
}
@Composable
private fun SearchScreen(vm: SearchViewModel = hiltViewModel(), onOpen: (String) -> Unit) {
val state by vm.state.collectAsStateWithLifecycle()
var text by remember { mutableStateOf("") }
Column(Modifier.fillMaxSize().systemBarsPadding().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(text, { text = it }, label = { Text("Find a recipe") }, singleLine = true, modifier = Modifier.weight(1f))
Spacer(Modifier.width(8.dp))
Button(onClick = { vm.search(text) }) { Text("Go") }
}
Spacer(Modifier.height(8.dp))
if (state.loading) CircularProgressIndicator()
state.message?.let { Text(it) }
LazyColumn {
items(state.results, key = { it.id }) { r -> RecipeRow(r.name, "${r.category} · ${r.area}", r.thumb) { onOpen(r.id) } }
}
}
}
@Composable
private fun FavouritesScreen(vm: FavouritesViewModel = hiltViewModel(), onOpen: (String) -> Unit) {
val favourites by vm.favourites.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize().systemBarsPadding().padding(16.dp)) {
Text("Saved recipes", style = MaterialTheme.typography.headlineSmall)
if (favourites.isEmpty()) Text("Tap the heart on a recipe to save it here.", Modifier.padding(top = 12.dp))
LazyColumn { items(favourites, key = { it.id }) { f -> RecipeRow(f.name, f.category, f.thumb) { onOpen(f.id) } } }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DetailScreen(vm: DetailViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by vm.state.collectAsStateWithLifecycle()
val favourite by vm.isFavourite.collectAsStateWithLifecycle()
Scaffold(topBar = {
TopAppBar(
title = { Text(state.recipe?.name ?: "Recipe") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
actions = {
if (state.recipe != null) IconButton(onClick = vm::toggleFavourite) {
Icon(if (favourite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, if (favourite) "Remove from saved" else "Save recipe")
}
}
)
}) { padding ->
val r = state.recipe
when {
state.loading -> Box(Modifier.padding(padding).fillMaxSize(), Alignment.Center) { CircularProgressIndicator() }
r == null -> Text(state.error ?: "Nothing to show.", Modifier.padding(padding).padding(16.dp))
else -> LazyColumn(Modifier.padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item { AsyncImage(r.thumb, null, Modifier.fillMaxWidth().height(220.dp).clip(MaterialTheme.shapes.medium), contentScale = ContentScale.Crop) }
item { Text("${r.category} · ${r.area}", style = MaterialTheme.typography.labelLarge) }
item { Text("Ingredients", style = MaterialTheme.typography.titleMedium) }
items(r.ingredients) { Text("• $it") }
item { Text("Method", style = MaterialTheme.typography.titleMedium) }
item { Text(r.instructions) }
}
}
}
}
Expected output
The app opens on the Search tab with a search box. Search for chicken and a list of recipes with pictures appears. Tap a recipe to see a large picture, the ingredient list with measures and the instructions. Tap the heart to save it. Open the Favourites tab: the recipe is listed and it still opens with no internet, because its details were saved in Room. Tap the heart again to remove it. The bottom bar keeps your search when you switch tabs.
Your task
- Build the app, search, open a recipe and save it as a favourite.
- Turn on airplane mode and open Favourites. Does it still work?
- Add a Retry button for failed searches.
- Add a filter by category using the API's category list, and show it as chips.
How to check it worked
- Search works and favourites persist.
- Favourites open offline.
- You can explain what Hilt creates for you and why it helps.