Food delivery apps are a good test of state management: browse restaurants, build a cart, place an order, then watch the order change over time. This project builds that flow. The restaurants and dishes are sample data in code, the checkout is a demo, and the order tracking is simulated by a Flow that changes status every few seconds.
The map uses Maps Compose, which needs your own Google Maps API key. Without a key the rest of the app works and the map area stays blank. In a real product, the courier position comes from your server over a WebSocket, Firestore or push updates, and a payment provider handles money.
Concepts in this project
- Sample data object:
FoodDataholds restaurants and dishes. Replace it with a Retrofit call later without changing the screens. - Cart as a map:
Map<Int, Int>from dish id to quantity. Adding builds a new map, so state updates correctly. - Order status as an enum:
Placed,Preparing,OnTheWay,Delivered, each with a label for the screen. - A Flow that simulates a server:
OrderTracker.track()emits each status with a delay. A real app would listen to a server instead. - Maps Compose:
GoogleMapwithMarkers and a camera position. The courier marker moves when the status changes. - Pure helpers:
courierPositionandtotalCentsare plain functions with unit tests.
Create the project
- New Empty Activity project
Food, packagecom.example.food. - Get a Maps API key from Google Cloud, restrict it to your app, and put it in the manifest using
local.propertiesand the Secrets Gradle Plugin so it stays out of git. - Add the Maps Compose libraries from the Project 15 block and sync.
- Create the files below.
`Gradle dependencies for projects 11 to 21 (use the block for this project)`
// ---- Project 11 Calculator: no extra libraries. Test dependency:
testImplementation("junit:junit:4.13.2")
// ---- Project 12 E-commerce
plugins { id("com.google.devtools.ksp"); kotlin("plugin.serialization") }
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("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// ---- Project 13 Chat (Firebase)
// project-level plugins: id("com.google.gms.google-services")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-messaging")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
// Also put google-services.json (from your Firebase console) in the app folder.
// ---- Project 14 Fitness
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// No library for the sensor: it is part of Android.
// ---- Project 15 Food delivery
implementation("com.google.maps.android:maps-compose:6.2.1")
implementation("com.google.android.gms:play-services-maps:19.0.0")
// Manifest, inside <application>:
// <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_KEY"/>
// Keep the key out of public git: read it from local.properties with the Secrets Gradle Plugin.
// ---- Project 16 Music player
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 17 QR scanner
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:barcode-scanning:17.3.0")
// ---- Project 18 Banking
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// ---- Project 19 AI chatbot
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.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
// ---- Project 20 Video streaming
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 21 Job portal
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")
Food/app/src/main/java/com/example/food/
FoodData.kt restaurants, dishes, OrderStatus
OrderTracker.kt simulated status updates + courier position
FoodViewModel.kt cart and order state
FoodScreens.kt list, menu, tracking map
MainActivity.kt
app/src/test/.../FoodTest.kt
The code
`app/src/main/java/com/example/food/FoodData.kt`
package com.example.food
data class Dish(val id: Int, val name: String, val priceCents: Long, val restaurantId: Int)
data class Restaurant(
val id: Int,
val name: String,
val cuisine: String,
val lat: Double,
val lng: Double,
)
// Sample data for the project. A real app loads this from your server.
object FoodData {
val restaurants = listOf(
Restaurant(1, "Spice Corner", "Indian", 28.6139, 77.2090),
Restaurant(2, "Pasta Place", "Italian", 28.6200, 77.2150),
Restaurant(3, "Green Bowl", "Healthy", 28.6100, 77.2000),
)
val dishes = listOf(
Dish(1, "Paneer Tikka", 24900, 1),
Dish(2, "Dal Makhani", 21900, 1),
Dish(3, "Margherita Pizza", 29900, 2),
Dish(4, "Penne Arrabbiata", 26900, 2),
Dish(5, "Quinoa Salad", 22900, 3),
Dish(6, "Fruit Smoothie", 14900, 3),
)
fun dishesOf(restaurantId: Int) = dishes.filter { it.restaurantId == restaurantId }
}
enum class OrderStatus(val label: String) {
Placed("Order placed"),
Preparing("Preparing your food"),
OnTheWay("On the way"),
Delivered("Delivered"),
}
`app/src/main/java/com/example/food/OrderTracker.kt`
package com.example.food
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
// Pretends to be the server pushing order updates.
// A real app listens to a WebSocket, Firestore document or push messages instead.
class OrderTracker {
fun track(): Flow<OrderStatus> = flow {
for (status in OrderStatus.entries) {
emit(status)
delay(3000)
}
}
}
// The courier moves in a straight line from the restaurant to the customer.
fun courierPosition(from: Pair<Double, Double>, to: Pair<Double, Double>, fraction: Float): Pair<Double, Double> {
val f = fraction.coerceIn(0f, 1f)
return Pair(from.first + (to.first - from.first) * f, from.second + (to.second - from.second) * f)
}
`app/src/main/java/com/example/food/FoodViewModel.kt`
package com.example.food
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class FoodState(
val cart: Map<Int, Int> = emptyMap(), // dish id to quantity
val status: OrderStatus? = null,
) {
fun totalCents(): Long = cart.entries.sumOf { (id, qty) ->
(FoodData.dishes.firstOrNull { it.id == id }?.priceCents ?: 0L) * qty
}
}
class FoodViewModel(private val tracker: OrderTracker = OrderTracker()) : ViewModel() {
private val _state = MutableStateFlow(FoodState())
val state: StateFlow<FoodState> = _state.asStateFlow()
fun add(dish: Dish) {
_state.update { it.copy(cart = it.cart + (dish.id to ((it.cart[dish.id] ?: 0) + 1))) }
}
fun remove(dish: Dish) {
_state.update {
val q = (it.cart[dish.id] ?: 0) - 1
it.copy(cart = if (q <= 0) it.cart - dish.id else it.cart + (dish.id to q))
}
}
// Demo checkout: no payment is taken.
fun placeOrder() {
if (_state.value.cart.isEmpty()) return
viewModelScope.launch {
tracker.track().collect { status ->
_state.update { it.copy(status = status, cart = if (status == OrderStatus.Placed) emptyMap() else it.cart) }
}
}
}
}
fun Long.rupees(): String {
return "₹" + (this / 100) + "." + (this % 100).toString().padStart(2, '0')
}
`app/src/main/java/com/example/food/FoodScreens.kt`
package com.example.food
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.unit.dp
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.MarkerState
import com.google.maps.android.compose.rememberCameraPositionState
@Composable
fun FoodApp(vm: FoodViewModel) {
val state by vm.state.collectAsState()
var restaurant by remember { mutableStateOf<Restaurant?>(null) }
when {
state.status != null -> TrackingScreen(state.status!!, restaurant ?: FoodData.restaurants.first())
restaurant == null -> RestaurantList { restaurant = it }
else -> MenuScreen(restaurant!!, state, vm, onBack = { restaurant = null })
}
}
@Composable
private fun RestaurantList(onPick: (Restaurant) -> Unit) {
LazyColumn(Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item { Text("Restaurants near you", style = MaterialTheme.typography.headlineSmall) }
items(FoodData.restaurants, key = { it.id }) { r ->
Card(Modifier.fillMaxWidth().clickable { onPick(r) }) {
Column(Modifier.padding(16.dp)) {
Text(r.name, style = MaterialTheme.typography.titleMedium)
Text(r.cuisine)
}
}
}
}
}
@Composable
private fun MenuScreen(r: Restaurant, state: FoodState, vm: FoodViewModel, onBack: () -> Unit) {
Column(Modifier.fillMaxSize().padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = onBack) { Text("Back") }
Text(r.name, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(start = 12.dp))
}
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(FoodData.dishesOf(r.id), key = { it.id }) { dish ->
Card {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(dish.name)
Text(dish.priceCents.rupees())
}
OutlinedButton(onClick = { vm.remove(dish) }) { Text("-") }
Text((state.cart[dish.id] ?: 0).toString(), Modifier.padding(horizontal = 8.dp))
OutlinedButton(onClick = { vm.add(dish) }) { Text("+") }
}
}
}
}
Text("Total: " + state.totalCents().rupees(), Modifier.padding(vertical = 8.dp))
Button(onClick = vm::placeOrder, enabled = state.cart.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
Text("Place demo order")
}
}
}
@Composable
private fun TrackingScreen(status: OrderStatus, r: Restaurant) {
val home = Pair(r.lat + 0.01, r.lng + 0.01)
val fraction = when (status) {
OrderStatus.Placed, OrderStatus.Preparing -> 0f
OrderStatus.OnTheWay -> 0.5f
OrderStatus.Delivered -> 1f
}
val courier = courierPosition(Pair(r.lat, r.lng), home, fraction)
val camera = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(LatLng(r.lat, r.lng), 14f)
}
Column(Modifier.fillMaxSize()) {
Text(status.label, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(16.dp))
LinearProgressIndicator(
progress = { (status.ordinal + 1) / OrderStatus.entries.size.toFloat() },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
)
// Needs a Google Maps API key in the manifest. Without one the map stays blank.
GoogleMap(modifier = Modifier.fillMaxWidth().height(360.dp).padding(top = 16.dp), cameraPositionState = camera) {
Marker(state = MarkerState(LatLng(r.lat, r.lng)), title = r.name)
Marker(state = MarkerState(LatLng(home.first, home.second)), title = "You")
Marker(state = MarkerState(LatLng(courier.first, courier.second)), title = "Courier")
}
}
}
`app/src/main/java/com/example/food/MainActivity.kt`
package com.example.food
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
FoodApp(viewModel<FoodViewModel>())
}
}
}
}
`app/src/test/java/com/example/food/FoodTest.kt`
package com.example.food
import org.junit.Assert.assertEquals
import org.junit.Test
class FoodTest {
@Test
fun totalAddsDishPricesTimesQuantity() {
val state = FoodState(cart = mapOf(1 to 2, 3 to 1))
assertEquals(24900L * 2 + 29900L, state.totalCents())
}
@Test
fun courierMovesHalfWay() {
val p = courierPosition(Pair(0.0, 0.0), Pair(2.0, 4.0), 0.5f)
assertEquals(1.0, p.first, 0.0001)
assertEquals(2.0, p.second, 0.0001)
}
@Test
fun rupeeFormat() {
assertEquals("₹249.00", 24900L.rupees())
}
}
How it works
- The app shows one of three screens: the restaurant list, a menu for the chosen restaurant, or tracking once an order has a status.
addandremoveupdate the cart map. The total is calculated from the map and the dish prices.placeOrdercollects the tracker flow. The first status, Placed, also empties the cart.- On the tracking screen, a progress bar shows how far through the statuses the order is, and the courier marker sits at the start, middle or end of the straight line to your home.
Expected output
The first screen lists three restaurants. Tap Spice Corner to see Paneer Tikka and Dal Makhani with prices in rupees. Press + on Paneer Tikka twice: the total reads ₹498.00. Press Place demo order. The tracking screen shows "Order placed", then every three seconds "Preparing your food", "On the way" and "Delivered", with the progress bar filling. The map shows the restaurant, your home and a courier marker that moves to the halfway point and then to your home. With no valid Maps key the map is blank but the status text still changes.
Your task
- Build the app and place an order.
- Load restaurants from a Retrofit call to your own API.
- Replace the simulated tracker with a Firestore document listener.
- Add a notification when the status becomes On the way.
How to check it worked
- The cart total is correct for each combination of dishes.
- The status moves through all four steps.
- Your Maps key is not committed to a public repository.