Food Delivery App
Two apps in one repo: the customer app (browse, order, track) and the courier app (accept, navigate, mark delivered). This project owns the location + foreground services + maps + WebSocket skill set.
User & courier journeys
CUSTOMER COURIER
─────── ───────
Discover ─→ Restaurant ─→ Menu ─→ Cart Shift start ─→ Go online
│ │
▼ ▼
Checkout ─→ Payment ─→ Order accepted ─→ Pickup
│ │
▼ ▼
Live tracking ──► Drop-off ─→ Tip
Features (by milestone)
M1 — Multi-app repo
- Shared
:core:*modules for both customer and courier apps - Separate
:app-customerand:app-couriertargets - Gradle convention plugin for multi-app config
- Shared
:core:mapswrapping Google Maps Compose SDK
M2 — Restaurant discovery
- Location permission request with rationale
- Nearby restaurants query (geo-hashed query on backend)
- Restaurant list + filter (cuisine, rating, delivery time)
- Menu with categories, item customization (dietary, spice level)
- Offline cache of favorites + last-ordered restaurant
M3 — Checkout + payment
- Address autocomplete via Places API
- Delivery time slot picker
- Payment sheet (Google Pay + card entry)
- Tip picker with presets + custom
- Order confirmation with estimated delivery time
M4 — Live tracking (critical path)
- Foreground Service in courier app tracking location (runtime permission + notification)
- Location updates published to backend via WebSocket
- Customer app subscribes to order channel, receives courier location
- Google Maps Compose renders courier marker + route polyline
- ETA recomputed on each location update
class LocationForegroundService : LifecycleService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Delivering order")
.setSmallIcon(R.drawable.ic_delivery)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
startForeground(NOTIF_ID, notification, FOREGROUND_SERVICE_TYPE_LOCATION)
lifecycleScope.launch {
locationClient.updates(intervalMillis = 5_000)
.collect { location -> webSocket.send(LocationUpdate(orderId, location)) }
}
return START_STICKY
}
}
M5 — Courier app specifics
- Accept/reject within timeout (shared countdown state)
- Turn-by-turn navigation (Google Navigation SDK or Intent to Maps)
- Mark pickup / delivered with signature capture (Canvas)
- Daily earnings summary with local charts
M6 — Production
- Battery impact: Doze + App Standby handling; WorkManager fallback for position reporting if FG service killed
- Foreground service type declaration (
dataSync,location) - Android 14+ restrictions on starting FG from background — design around it
- Macrobenchmark for map rendering jank
- Play Data Safety: Precise location declared + user-revocable
Architecture highlights
Location client wrapped in Flow
class LocationClient @Inject constructor(
@ApplicationContext private val context: Context,
private val fused: FusedLocationProviderClient
) {
@SuppressLint("MissingPermission")
fun updates(intervalMillis: Long): Flow<Location> = callbackFlow {
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, intervalMillis)
.setMinUpdateIntervalMillis(intervalMillis / 2)
.build()
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { trySend(it) }
}
}
fused.requestLocationUpdates(request, callback, Looper.getMainLooper())
awaitClose { fused.removeLocationUpdates(callback) }
}.flowOn(Dispatchers.Default)
}
ETA reducer
class EtaReducer @Inject constructor(
private val directions: DirectionsApi
) {
suspend fun compute(courier: LatLng, destination: LatLng): Duration {
val route = directions.route(origin = courier, destination = destination)
return route.durationSeconds.seconds
}
}
@Composable
fun TrackingScreen(viewModel: TrackingViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
GoogleMap(
cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(state.destination, 15f)
}
) {
Marker(state = MarkerState(state.courier), icon = courierIcon())
Marker(state = MarkerState(state.destination), icon = homeIcon())
Polyline(points = state.route, color = MaterialTheme.colorScheme.primary)
}
Text("ETA: ${state.etaMinutes} min")
}
Stretch goals
🏎️
Offline maps
Download map tiles for the city. Courier app works even in dead zones.
🔋
Adaptive location
Reduce update frequency when stationary. Save 30-50% battery per shift.
📡
BLE beacon pickup
Restaurant BLE beacon auto-marks pickup when courier enters.
🎨
Android Auto
Courier app integration with Android Auto head unit for voice-guided delivery.
Testing strategy
- MockLocation for FusedLocationProvider in instrumentation tests
- MockWebServer simulates WebSocket stream of location updates
- Compose tests for map camera follow and polyline updates
- Battery tests via Battery Historian — ensure < 5% drain per 30-min delivery
Privacy & permissions
- Precise location: only while delivering (runtime permission just-in-time)
- Background location: required only on courier app, with rationale screen
- Play Data Safety: precise location collected, purpose "Delivery tracking", user-revocable, encrypted in transit, deletable
- Retention: location data purged 30 days after delivery
Next
Continue to Project 4 — Fitness Tracker for sensors, Wear OS, and Health Connect.