How to Create a Food App in Jetpack Compose – A Complete Step-by-Step Guide
Jetpack Compose has revolutionized the way Android apps are built. With its intuitive and declarative UI paradigm, building beautiful and performant apps is easier than ever. One of the most popular app categories is food delivery or recipe apps. If you're looking to build a Food App using Jetpack Compose, you're in the right place!
In this comprehensive tutorial, we’ll go step-by-step through the process of building a modern food app using Jetpack Compose, including API integration, modern UI design, navigation, and state management.
🚀 Why Build a Food App in Jetpack Compose?
Before diving into code, let’s look at why Jetpack Compose is the best choice for building a food app:
- 🧩 Composable UI: Easily reuse UI elements like cards, buttons, and image sections.
- 🌈 Material Design 3 Support: Use modern design principles with ease.
- ⚡ Faster Development: Less boilerplate code compared to XML.
- 🔄 State Handling: Efficiently handle UI state using ViewModel & State Hoisting.
- 📱 Perfect for Beginners & Pros: Learn real-world app building the modern Android way.
📱 Key Features of Our Food App
Here’s what we’re going to build:
- Food List Screen with Images
- Food Detail Screen with Ingredients & Description
- Add to Cart Functionality
- Cart Screen with Item Count and Total
- Search Bar to Filter Foods
- Modern UI with LazyColumn, Cards, and Images
- Retrofit API Integration (Optional for dynamic data)
🧰 Tools and Tech Stack
- Jetpack Compose
- Kotlin
- ViewModel
- Retrofit (Optional API)
- Coil (Image Loading)
- Material 3 Design
- Navigation-Compose
implementation("io.coil-kt:coil-compose:2.6.0")
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// Jetpack Compose
implementation("androidx.navigation:navigation-compose:2.8.9")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
Food App
@Composable
fun FoodApp() {
val navController = rememberNavController()
val viewModel: MealViewModel = viewModel()
NavHost(navController, startDestination = "foodList") {
composable("foodList") {
FoodListScreen(navController,viewModel)
}
// detail screen with mealId parameter
composable("detailScreen/{mealId}") { backStackEntry ->
val mealId = backStackEntry.arguments?.getString("mealId") ?: ""
FoodDetailScreen(navController, mealId, viewModel)
}
// category screen with category name parameter
composable("categoryScreen/{categoryName}") { backStackEntry ->
val categoryName = backStackEntry.arguments?.getString("categoryName") ?: ""
FoodCategoryScreen(navController, categoryName, viewModel)
}
composable ("foodCartScreen"){
FoodCartScreen(viewModel)
}
}
}
What is this code doing?
In Jetpack Compose, navigation allows users to move between different screens or "composables" within the app.
NavController
- navController is the controller that manages the navigation. Think of it as the "GPS" of your app, helping to decide where the user should go when they tap a button or link.
- rememberNavController() ensures that the navigation state is preserved when recomposing (important for maintaining state when the screen changes).
NavHost
- NavHost is the container where we define all the screens (composables) in our app. The startDestination = "foodList" specifies the first screen (or route) that will be displayed when the app starts.
- Think of it as setting up a map with multiple routes (screens) that you can navigate between.
Defining Screens
Food List Screen
- This is the screen that lists all available meals (food items).
- FoodListScreen is a composable that displays a list of meals, and the navController lets us navigate to other screens.
Food detail Screen
- This screen shows the details of a specific meal when the user clicks on it from the Food List.
- The mealId parameter is passed in the URL (route) as {mealId}.
- For example, if the user clicks on a meal with mealId = "123", the URL would look like "detailScreen/123".
- backStackEntry gives us access to the arguments in the route. We use backStackEntry.arguments?.getString("mealId") to get the mealId.
- Once we have the mealId, we call the FoodDetailScreen to show the details of that specific meal.
Food Category Screen
- This screen shows all meals that belong to a specific category (like "Vegetarian", "Non-Vegetarian", etc.).
- Similar to the previous screen, the categoryName is passed as part of the URL route (e.g., "categoryScreen/Vegetarian").
- We retrieve categoryName from the backStackEntry and use it to filter meals in that category.
Food Cart Screen
- This is the screen that shows the cart, i.e., the list of meals that the user has added.
- The FoodCartScreen composable will use the viewModel to get the cart items and display them.
Parameters in Routes
- In the code above, notice how we are using dynamic routes to pass data from one screen to another.
- For example, "detailScreen/{mealId}" contains a dynamic part mealId, which means we can pass the actual mealId when navigating to the screen.
- "categoryScreen/{categoryName}" is another dynamic route where the category name is passed in the URL.
How to create a Tea☕ Shop App using Jetpack Compose?
MainActivity
package com.codingbihar.foodapp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.codingbihar.foodapp.ui.theme.FoodAppTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
FoodAppTheme {
/*Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}*/
FoodApp()
}
}
}
}
Food List Screen
package com.codingbihar.foodapp
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.rememberAsyncImagePainter
@Composable
fun FoodListScreen(navController: NavController, viewModel: MealViewModel) {
Column(
Modifier.fillMaxSize()
.systemBarsPadding()
) {
var query by remember { mutableStateOf("") }
val meals = viewModel.meals
val categories = viewModel.categories
val isLoading = viewModel.isLoading
SearchBar(
query = query,
onQueryChange = {
query = it
if (it.isNotBlank()) {
viewModel.searchMeals(it)
} else {
// viewModel.loadAllMeals()
}
},
onClearClick = {
query = ""
viewModel.loadAllMeals()
}
)
Spacer(modifier = Modifier.height(28.dp))
Text("What's on your mind?", style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.ExtraBold)
LazyRow(
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(categories) { category ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable {
navController.navigate("categoryScreen/${category.strCategory}")
}
) {
Image(
painter = rememberAsyncImagePainter(category.strCategoryThumb),
contentDescription = category.strCategory,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.border(3.dp, MaterialTheme.colorScheme.primary, CircleShape)
)
Text(
text = category.strCategory,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
Spacer(modifier = Modifier.height(28.dp))
Text("Top restaurant chains in India at your Door!",
fontWeight = FontWeight.ExtraBold,
style = MaterialTheme.typography.headlineMedium)
// 🔄 Show Food Items or Loading
if (isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
if (meals.isEmpty() && query.isNotBlank()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("No results found for \"$query\"")
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(meals) { meal ->
MealCard(
meal = meal,
navController = navController,
viewModel = viewModel
)
}
}
}
}
}
}
Meal Card Common Component
package com.codingbihar.foodapp
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.rememberAsyncImagePainter
@Composable
fun MealCard(meal: Meal, navController: NavController, viewModel: MealViewModel) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable {
navController.navigate("detailScreen/${meal.idMeal}")
}
) {
Column(modifier = Modifier.padding(8.dp)) {
val inCart = viewModel.cartItems.containsKey(meal.idMeal)
Image(
painter = rememberAsyncImagePainter(meal.strMealThumb),
contentDescription = meal.strMeal,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.fillMaxWidth()
.height(120.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(text = meal.strMeal,
maxLines = 1,
style = MaterialTheme.typography.titleSmall)
Text(
text = "₹${viewModel.getPriceForMeal(meal.idMeal)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Row (Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center){
IconButton(onClick = {navController.navigate("foodCartScreen")}) {
Icon(painterResource(R.drawable.gocart),
contentDescription = "")
}
IconButton(onClick = {viewModel.addToCart(meal) }) {
Icon(Icons.Default.Add,
tint = if (inCart){Color.LightGray}
else {
Color.Blue},
contentDescription = "",
modifier = Modifier.size(45.dp))
}
}
}
}
}
Food Search Box
package com.codingbihar.foodapp
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
onClearClick: () -> Unit
) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
Box(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(24.dp)
)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f),
shape = RoundedCornerShape(24.dp)
)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
BasicTextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
textStyle = _root_ide_package_.androidx.compose.material3.LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface,
fontSize = 16.sp
),
modifier = Modifier.weight(1f),
decorationBox = { innerTextField ->
if (query.isEmpty()) {
Text(
text = "Search food...",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// keyboardController?.hide()
}
innerTextField()
}
)
if (query.isNotEmpty()) {
IconButton(onClick = {
onClearClick()
focusManager.clearFocus()
keyboardController?.hide() // Hide the keyboard on clear
}
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
Food Detail Screen
package com.codingbihar.foodapp
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.rememberAsyncImagePainter
@Composable
fun FoodDetailScreen(
navController: NavController,
mealId: String,
viewModel: MealViewModel
) {
val isLoading = viewModel.isLoading
val meal = viewModel.selectedMeal
LaunchedEffect(mealId) {
viewModel.loadMealDetail(mealId)
}
if (isLoading || meal == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
val inCart = viewModel.cartItems.containsKey(meal.idMeal)
val price = viewModel.getPriceForMeal(meal.idMeal)
// Wrap whole screen in Box to place button at bottom
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 70.dp) // Space for button
.verticalScroll(rememberScrollState())
) {
Image(
painter = rememberAsyncImagePainter(meal.strMealThumb),
contentDescription = meal.strMeal,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(meal.strMeal, style = MaterialTheme.typography.titleLarge)
Text("Price: ₹${price.toInt()}", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(meal.strInstructions ?: "")
}
// Fixed bottom button
Button(
onClick = {
if (inCart) {
navController.navigate("foodCartScreen")
} else {
viewModel.addToCart(meal)
}
},
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(top = 8.dp)
) {
Text(text = if (inCart) "Go to Cart" else "Add to Cart")
}
}
}
}
Food Category Screen
package com.codingbihar.foodapp
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@Composable
fun FoodCategoryScreen(
navController: NavController,
categoryName: String,
viewModel: MealViewModel) {
val meals = viewModel.selectedCategoryMeals
val isLoading = viewModel.isLoading
// Load meals for selected category
LaunchedEffect(categoryName) {
viewModel.loadMealsByCategory(categoryName)
}
Column (Modifier.fillMaxSize()){
Box(
modifier = Modifier
.fillMaxSize()
) {
when {
isLoading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
meals.isEmpty() -> {
Text(
text = "No meals found in $categoryName",
modifier = Modifier.align(Alignment.Center)
)
}
else -> {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(meals) { meal ->
MealCard(
meal = meal,
navController = navController,
viewModel = viewModel
)
}
}
}
}
}
}
}
Food Cart Screen
package com.codingbihar.foodapp
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.rememberAsyncImagePainter
@Composable
fun FoodCartScreen(viewModel: MealViewModel) {
val cartItems = viewModel.cartItems.values.toList()
Box(modifier = Modifier.fillMaxSize()) {
if (cartItems.isEmpty()) {
// Center message when cart is empty
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Your cart is empty.")
}
} else {
// Scrollable cart items
LazyColumn(
modifier = Modifier
.systemBarsPadding()
.fillMaxSize()
// Add bottom padding to avoid overlap with payment bar
) {
items(cartItems) { cartItem ->
CartItemRow(
cartItem = cartItem,
onIncrease = { viewModel.increaseQuantity(cartItem.meal.idMeal) },
onDecrease = { viewModel.decreaseQuantity(cartItem.meal.idMeal) }
)
HorizontalDivider()
}
}
// Fixed bottom bar
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.background(MaterialTheme.colorScheme.surface)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Total: ₹${"%.2f".format(viewModel.getTotalPrice())}",
style = MaterialTheme.typography.titleMedium
)
Button(onClick = { /* handle payment */ }) {
Text("Pay Now")
}
}
}
}
}
@Composable
fun CartItemRow(
cartItem: CartItem,
onIncrease: () -> Unit,
onDecrease: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceAround
) {
Image(
painter = rememberAsyncImagePainter(cartItem.meal.strMealThumb),
contentDescription = cartItem.meal.strMeal,
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.padding(end = 8.dp)
)
Column {
Text(text = cartItem.meal.strMeal, style = MaterialTheme.typography.titleMedium)
Text(
text = "₹${("%.2f".format(cartItem.price))} x ${cartItem.quantity} = ₹${"%.2f".format(cartItem.price * cartItem.quantity)}",
style = MaterialTheme.typography.bodyMedium
)
}
Button(onClick = onDecrease, contentPadding = PaddingValues(4.dp)) {
Text("-", fontSize = 30.sp)
}
Text(
text = cartItem.quantity.toString(),
fontWeight = FontWeight.ExtraBold,
fontSize = 34.sp,
modifier = Modifier.padding(horizontal = 8.dp)
)
Button(onClick = onIncrease, contentPadding = PaddingValues(4.dp)) {
Text("+", fontSize = 30.sp)
}
}
}
Retrofit API
package com.codingbihar.foodapp
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
interface MealApiService {
// Search meals by name
@GET("search.php")
suspend fun searchMeals(@Query("s") query: String): MealResponse
// Get meals by category
@GET("filter.php")
suspend fun getMealsByCategory(@Query("c") category: String): MealResponse
// Get all categories
@GET("categories.php")
suspend fun getCategories(): CategoryResponse
// Get meal detail by id
@GET("lookup.php")
suspend fun getMealDetail(@Query("i") mealId: String): MealResponse
// Get all meals (search all with empty query as fallback)
@GET("search.php?s=")
suspend fun getAllMeals(): MealResponse
}
object MealApi {
private const val BASE_URL = "https://www.themealdb.com/api/json/v1/1/"
val apiService: MealApiService by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(MealApiService::class.java)
}
}
Food View Model
package com.codingbihar.foodapp
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import kotlin.collections.set
// STEP 3: ViewModel
class MealViewModel : ViewModel() {
var meals by mutableStateOf<List<Meal>>(emptyList())
var categories by mutableStateOf<List<Category>>(emptyList())
var selectedCategoryMeals by mutableStateOf<List<Meal>>(emptyList())
var selectedMeal by mutableStateOf<Meal?>(null)
var isLoading by mutableStateOf(false)
// Random price store
private val mealPrices = mutableMapOf<String, Double>()
// Cart Items
var cartItems = mutableStateMapOf<String, CartItem>()
private set
init {
loadCategories()
loadAllMeals()
}
// Generate or get fixed price for a meal
fun getPriceForMeal(mealId: String): Double {
return mealPrices.getOrPut(mealId) {
(100..500).random().toDouble()
}
}
// Get all meals (initial load)
fun loadAllMeals() {
viewModelScope.launch {
isLoading = true
try {
val response = MealApi.apiService.getAllMeals()
meals = response.meals
} catch (e: Exception) {
e.printStackTrace()
}
isLoading = false
}
}
// Search meals
fun searchMeals(query: String) {
viewModelScope.launch {
isLoading = true
try {
val response = MealApi.apiService.searchMeals(query)
meals = response.meals
} catch (e: Exception) {
e.printStackTrace()
}
isLoading = false
}
}
fun loadCategories() {
viewModelScope.launch {
try {
val response = MealApi.apiService.getCategories()
categories = response.categories
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// Load meals by category
fun loadMealsByCategory(category: String) {
viewModelScope.launch {
isLoading = true
try {
val response = MealApi.apiService.getMealsByCategory(category)
selectedCategoryMeals = response.meals
} catch (e: Exception) {
e.printStackTrace()
}
isLoading = false
}
}
// Load meal detail
fun loadMealDetail(mealId: String) {
viewModelScope.launch {
isLoading = true
try {
val response = MealApi.apiService.getMealDetail(mealId)
selectedMeal = response.meals.firstOrNull()
} catch (e: Exception) {
e.printStackTrace()
}
isLoading = false
}
}
// Cart Operations
fun addToCart(meal: Meal) {
val price = getPriceForMeal(meal.idMeal)
val current = cartItems[meal.idMeal]
if (current != null) {
cartItems[meal.idMeal] = current.copy(quantity = current.quantity + 1)
} else {
cartItems[meal.idMeal] = CartItem(meal, 1, price)
}
}
fun increaseQuantity(mealId: String) {
val item = cartItems[mealId]
if (item != null) {
cartItems[mealId] = item.copy(quantity = item.quantity + 1)
}
}
fun decreaseQuantity(mealId: String) {
val item = cartItems[mealId]
if (item != null && item.quantity > 1) {
cartItems[mealId] = item.copy(quantity = item.quantity - 1)
} else if (item != null && item.quantity == 1) {
cartItems.remove(mealId)
}
}
fun getTotalPrice(): Double {
return cartItems.values.sumOf { it.price * it.quantity }
}
}
Data Model
// Meal.kt
data class MealResponse(val meals: List<Meal>)
data class Meal(
val idMeal: String,
val strMeal: String,
val strMealThumb: String,
val strCategory: String?,
val strInstructions: String?
)
// Category.kt
data class CategoryResponse(val categories: List<Category>)
data class Category(
val idCategory: String,
val strCategory: String,
val strCategoryThumb: String,
val strCategoryDescription: String
)
data class CartItem(
val meal: Meal,
var quantity: Int,
val price: Double
)