After months of building, I just released Zesto — a production-ready,
multi-city food delivery platform inspired by Zomato and Swiggy.
It consists of 4 Flutter apps backed by a Firebase backend with 23
Cloud Functions. In this article I'll walk through the key architecture
decisions I made along the way.
What I Built
Customer App — OTP login, restaurant discovery, cart, Razorpay payments, live driver tracking
Driver App — automatic job assignment, step-by-step delivery slider, background GPS
Restaurant App — live order dashboard, menu management, earnings
Admin Panel — Flutter Web, multi-city, restaurant and driver management
Demo: https://youtube.com/shorts/rL2M5O5Tk50
Architecture Overview
All 4 apps share a single Firebase project. Each app is independent
with its own package name and Firebase registration, but they all
read and write to the same Firestore collections.
Customer App ─────┐
Driver App ─────┤──── Firebase (Firestore + Auth + FCM + Functions)
Restaurant App ────┤
Admin Panel ─────┘
This approach kept things simple — no separate backends, no API
servers to manage. Just Firestore listeners and Cloud Functions.
State Management: MobX
I chose MobX over BLoC or Riverpod for one reason — the reaction
system. When the driver's order status changes in Firestore, I need
the UI to react at multiple levels simultaneously:
The map needs to update polylines
The slider needs to show the next step
The navigation stack needs to know whether to push a new screen
MobX reactions handle this cleanly:
dart_navigationReaction = reaction(
(_) => _driverStore.activeJob,
(order) {
if (order != null && !_isOnActiveDelivery && mounted) {
_isOnActiveDelivery = true;
Navigator.push(context, _deliveryRoute());
}
},
);
One thing I learned the hard way — reactions fire on every observable
change, not just null → non-null transitions. This caused the driver
app to push a new ActiveDeliveryScreen on every status update, stacking
screens on top of each other. The fix was a simple _isOnActiveDelivery
guard flag.
Order Status Flow
The biggest consistency challenge was keeping order statuses in sync
across 4 apps and 23 Cloud Functions. Early in development I had
camelCase statuses in Flutter (arrivedAtRestaurant) and snake_case
in Firestore (arrived_at_restaurant). This caused Firestore queries
to silently return empty results.
The fix was standardizing everything to snake_case and adding a safe
parser that never throws:
dartenum OrderStatus {
placed, confirmed, preparing, ready, accepted,
arrived_at_restaurant, picked_up, on_the_way,
arrived_at_customer, delivered, cancelled, refunded;
static OrderStatus fromString(String? value) {
return OrderStatus.values.firstWhere(
(e) => e.name == value,
orElse: () => OrderStatus.placed,
);
}
}
The full status progression looks like this:
placed → confirmed → preparing → ready → accepted
→ arrived_at_restaurant → picked_up → on_the_way
→ arrived_at_customer → delivered
Driver Assignment Algorithm
When a restaurant marks an order ready, the app calls the assignDriver
Cloud Function. Here's how it finds the nearest available driver:
Query zone_drivers/{cityId}/active/ — drivers who are online
Filter out drivers with updated_at older than 10 minutes (stale)
Calculate distance from each driver to the restaurant using
Haversine formula on GeoPoint coordinates
Assign the nearest driver
Delete the driver from the active pool (prevents double assignment)
Return success: false if no drivers are available
The trickiest bug here was a race condition — the driver app was calling
goOnline() before init() had finished reading the driver's real
cityId from Firestore. So the driver registered under a hardcoded
default city and was invisible to the assignment function.
The fix was a simple initialization guard:
dart@action
Future goOnline(String driverUid) async {
if (!_isInitialized) {
int retries = 0;
while (!_isInitialized && retries < 20) {
await Future.delayed(const Duration(milliseconds: 250));
retries++;
}
}
// now safe to use real cityId
}
Security: Why Client-Side Validation Is Not Enough
The most important backend fix was server-side price validation.
The original placeOrder function trusted the grand_total sent
by the client — meaning anyone could intercept the API call and
order ₹1000 worth of food for ₹1.
The fix was recalculating everything server-side:
js// Never trust client prices
for (const item of items) {
const menuItemSnap = await db
.collection('restaurants').doc(restaurant_id)
.collection('categories').doc(item.category_id)
.collection('items').doc(item.item_id)
.get();
const menuItem = menuItemSnap.data();
calculatedSubtotal += menuItem.price * item.quantity;
}
// Use calculatedSubtotal — never data.grand_total
I also locked down Firestore rules to prevent drivers from updating
their own wallet_balance and customers from setting
payment_status: paid without a real transaction.
The Toughest Bug
The driver delivery screen freezing after accepting an order took the
longest to debug. The symptoms:
Driver accepts order ✅
Screen appears frozen on "Processing..." ❌
Only fixed by clearing the app from memory and reopening
The root cause was actually three separate bugs compounding:
_updatePolylines() — an async method calling setState — was
being called directly inside an Observer build method. Flutter
suppresses secondary rebuilds to prevent infinite loops, causing
the UI to freeze.
The accepted status had no case in the delivery screen's switch
statement, so it defaulted to "Processing..."
The NewOrderOverlay dialog was never popped after acceptance,
leaving an invisible backdrop blocking all touches.
Each fix was simple in isolation — moving polylines to a MobX
reaction, adding the accepted case, and calling
Navigator.pop() on acceptance. But finding all three together
took a full investigation pass.
What I Would Do Differently
Shared package for models — each app has its own copy of
OrderModel and OrderStatus. A shared packages/core library
would have prevented the status mismatch bugs entirely.
Webhook-based payment confirmation — currently Razorpay payment
success triggers order creation from the client. If the app crashes
between payment and order creation, the customer loses money with no
order. A proper webhook reconciliation flow would be more robust.
iOS from day one — the driver app's foreground service needs
separate configuration for iOS. Building Android-first and adding
iOS later meant reworking the background location architecture.
The Result
A fully working food delivery platform that I've listed as source
code for other developers to use as a starting point.
🛒 Source code: https://morningstar47jb.gumroad.com/l/tnevss
Includes full SETUP.md, demo seeder, AppConfig for rebranding,
and all 23 Cloud Functions.
Happy to answer any questions about the architecture in the comments.
Top comments (0)