Building a Zomato-Like Multi-City Food Delivery App with Flutter & Firebase

posted 5 min read

Hey everyone, Surendra Kumar here —Flutter/Firebase developer from Banda, India. I've been building production apps under Gfood Delivery Private Limited, and today I want to share the architecture behind my most ambitious project yet: Zesto, a multi-city food delivery platform built entirely with Flutter and Firebase.
Zesto isn't a tutorial project. It's a real, working platform — 4 Flutter apps, a complete Firebase backend, and architecture decisions modeled after how production platforms like Zomato actually work.
Want to try it before reading? Download the customer app APK and test it yourself:
Download Zesto Customer App https://github.com/ssurekumar01111-hue/zesto-demo/releases/download/v1.0-beta/app-release.apk
I'd love your feedback and improvement ideas after reading.

The 4-App Ecosystem
Zesto consists of four interconnected Flutter applications:

Customer App — Multi-city restaurant discovery, cart, Razorpay + wallet payments, live GPS tracking, reviews
Restaurant Partner App — Order management, menu CRUD, earnings dashboard, FCM order alerts
Driver App — Order accept/reject, live delivery tracking, earnings, payout requests
Flutter Web Admin Panel — 14 screens covering cities, zones, restaurants, drivers, orders, analytics, reviews, ads, config, and audit logs

The Core Architecture Decision: City + Zone Scoping
This is the most important architectural decision in Zesto — and the one that separates it from basic food delivery tutorials.
Every single Firestore document has two fields:
dart{
"city_id": "banda_up",
"zone_id": "banda_zone_1",
// ... rest of document
}
Every query filters by city_id first:
dartFirebaseFirestore.instance
.collection('restaurants')
.where('city_id', isEqualTo: currentCityId)
.where('is_active', isEqualTo: true)
.snapshots();
Why does this matter? Without city scoping, as you add cities your queries return restaurants from everywhere. Firestore has no built-in geographic filtering — you have to design it in from day one. Retrofitting this later would require rewriting every query and migrating every document.
Zones sit inside cities and control driver assignment. When an order is placed, the Cloud Function assigns it to available drivers in the same zone first, then expands to the full city if no zone drivers are available.

Firebase Custom Claims for RBAC
Zesto has 5 user roles: customer, restaurant_owner, driver, admin, super_admin. Managing this with Firestore documents alone creates security holes — a user could theoretically write to their own document and escalate their role.
The solution is Firebase custom claims set via Cloud Functions:
javascript// functions/src/admin/setUserRole.js
exports.setUserRole = functions.https.onCall(async (data, context) => {
if (!context.auth.token.super_admin) {

throw new functions.https.HttpsError('permission-denied', 'Not authorized');

}
await admin.auth().setCustomUserClaims(data.uid, {

role: data.role,
city_id: data.cityId,

});
});
On the Flutter side, the role is read from the ID token directly:
dartfinal token = await FirebaseAuth.instance.currentUser?.getIdTokenResult();
final role = token?.claims?['role'] as String?;
This means even if someone modifies their Firestore document, the custom claim on the token is what gates access. Firestore rules enforce this:
javascript// firestore.rules
match /orders/{orderId} {
allow read: if request.auth.token.role == 'admin'

|| request.auth.token.role == 'restaurant_owner'
|| request.auth.uid == resource.data.customer_id;

}

MobX for State Management
Zesto uses MobX across all 4 apps. Here's why I chose it over Riverpod or GetX for this project:
MobX's reactive observables map perfectly to Firestore streams. When a Firestore stream emits a new value, MobX automatically rebuilds only the widgets that consume that observable — no manual setState, no context.watch boilerplate.
dart// core/stores/order_store.dart
class OrderStore = OrderStore with $OrderStore;

abstract class _OrderStore with Store {
@observable
ObservableList activeOrders = ObservableList();

@observable
OrderStatus currentStatus = OrderStatus.placed;

@action
void updateOrderStatus(String orderId, OrderStatus status) {

final idx = activeOrders.indexWhere((o) => o.id == orderId);
if (idx != -1) {
  activeOrders[idx] = activeOrders[idx].copyWith(status: status);
}
currentStatus = status;

}
}
The store subscribes to a Firestore stream once and updates observables. Every widget that reads activeOrders rebuilds automatically. Clean, testable, no rebuild storms.

Dual Payment Path: Razorpay + In-App Wallet
Zesto supports two payment methods and they interact with each other in interesting ways.
Razorpay handles card/UPI payments. The flow is standard — create an order via Cloud Functions, open Razorpay checkout, handle the webhook callback.
The wallet is more interesting. Each customer has a wallet_balance field on their user document. When they pay via wallet:
javascript// functions/src/payments/walletPayment.js
exports.processWalletPayment = functions.https.onCall(async (data, context) => {
const uid = context.auth.uid;

return admin.firestore().runTransaction(async (transaction) => {

const userRef = admin.firestore().collection('users').doc(uid);
const userDoc = await transaction.get(userRef);

const currentBalance = userDoc.data().wallet_balance;
if (currentBalance < data.amount) {
  throw new functions.https.HttpsError('failed-precondition', 'Insufficient balance');
}

transaction.update(userRef, {
  wallet_balance: admin.firestore.FieldValue.increment(-data.amount)
});

transaction.set(admin.firestore().collection('wallet_txns').doc(), {
  uid,
  amount: -data.amount,
  type: 'debit',
  order_id: data.orderId,
  created_at: admin.firestore.FieldValue.serverTimestamp(),
});

});
});
The Firestore transaction ensures the balance check and deduction happen atomically — no race condition where two simultaneous orders drain the wallet below zero.

Cloud Functions: Domain-Split Architecture
Instead of one massive index.js, Zesto's Cloud Functions are split by domain:
functions/src/
├── orders/
│ ├── placeOrder.js # Create order, notify restaurant
│ ├── assignDriver.js # Zone-based driver assignment
│ └── onOrderDelivered.js # Earnings calculation, wallet credit
├── payments/
│ ├── razorpayWebhook.js # Payment confirmation
│ └── walletPayment.js # Wallet debit/credit
└── admin/

├── approveRestaurant.js
└── setUserRole.js

Each function is independently deployable. When I fix a bug in assignDriver.js, I deploy only that function — not the entire backend.

The Driver Assignment Problem
When an order is placed, the platform needs to assign a driver. Here's the simplified logic:
javascriptexports.assignDriver = functions.firestore
.document('orders/{orderId}')
.onCreate(async (snap, context) => {

const order = snap.data();

// 1. Find online drivers in the same zone
const driversSnap = await admin.firestore()
  .collection('drivers')
  .where('city_id', isEqualTo: order.city_id)
  .where('zone_id', isEqualTo: order.zone_id)
  .where('is_online', isEqualTo: true)
  .where('current_order_id', isEqualTo: null)
  .limit(5)
  .get();

if (driversSnap.empty) {
  // Expand to full city
  // ... same query without zone_id filter
}

// 2. Send FCM notification to available drivers
// First driver to accept wins (handled by transaction in Driver app)
const tokens = driversSnap.docs.map(d => d.data().fcm_token);
await admin.messaging().sendMulticast({
  tokens,
  data: { type: 'NEW_ORDER', order_id: context.params.orderId }
});

});
On the driver side, accepting an order uses a Firestore transaction with three guards to prevent race conditions — only one driver can claim the order even if multiple tap Accept simultaneously.

What's Coming
Zesto is launching soon on Gumroad and Codester as commercial source code — complete, ready-to-customize. It includes all 4 Flutter apps, the complete Cloud Functions backend, Firestore security rules, and developer documentation.
Try the customer app right now:
https://github.com/ssurekumar01111-hue/zesto-demo/releases/download/v1.0-beta/app-release.apk
I'd genuinely love your feedback — what works well, what feels off, what you'd build differently. Drop a comment or reach me at Emails are not allowed.

Surendra Kumar — Flutter/Firebase developer, Banda, India
Portfolio: portfolio.gfood.in | GitHub: ssurekumar01111-hue

1 Comment

0 votes

More Posts

How I Built a Complete On-Demand Home Services App (ServeNow) with Flutter & Firebase

MorningStar47 - Apr 27

Building a Resilient Real-Time WebSocket Stream in Flutter (RxDart + Clean Architecture + BLoC)

Lordhacker756verified - Apr 27

From Spaghetti to Structure: Why I Migrated My Production Flutter App to Clean Architecture

Lordhacker756verified - Mar 31

Local MongoDB like database in flutter

Somen Das - May 5, 2025

Flutter Performance Optimization 2026 (Make Your App 10x Faster + Best Practices)

techwithsam - Mar 22
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!