Project 15: Food Delivery App with Order Tracking and Maps

Android Development with Kotlin: 21 Real Projects · More Real-World Projects · 17 min read · Updated

Order status as a Flow

  1. The user builds a cart from one restaurant's menu.
  2. Place order starts the status flow and clears the cart.
  3. The flow emits a new status every few seconds. A real app gets these from a server.
  4. The tracking screen shows the text, the progress bar and moves the courier on the map.

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: FoodData holds 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: GoogleMap with Markers and a camera position. The courier marker moves when the status changes.
  • Pure helpers: courierPosition and totalCents are plain functions with unit tests.

Create the project

  1. New Empty Activity project Food, package com.example.food.
  2. Get a Maps API key from Google Cloud, restrict it to your app, and put it in the manifest using local.properties and the Secrets Gradle Plugin so it stays out of git.
  3. Add the Maps Compose libraries from the Project 15 block and sync.
  4. 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.
  • add and remove update the cart map. The total is calculated from the map and the dish prices.
  • placeOrder collects 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

  1. Build the app and place an order.
  2. Load restaurants from a Retrofit call to your own API.
  3. Replace the simulated tracker with a Firestore document listener.
  4. 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.

Frequently asked questions

Where does the courier position come from in a real app?

From the courier's phone, sent to your server, which pushes it to the customer through a WebSocket, Firestore or push messages.

How do I keep the Maps key out of git?

Put it in local.properties, which is not committed, and read it with the Secrets Gradle Plugin into the manifest. Also restrict the key to your app in Google Cloud.

Why model status as an enum?

It limits the values to known ones, gives each a label, and lets the compiler check every when expression.