Build a real-time operations dashboard with Flutter and Firebase
Every fleet, factory, and field-service team eventually asks for the same screen: a live board that shows what every unit is doing right now. It looks like a weekend project. It is not — the difference between a demo dashboard and one that survives production is almost entirely in the data model and the read path.
This tutorial builds the real thing: a Flutter dashboard on Firestore, with the schema, rules, and cost discipline we use in client work.
Architecture
The dashboard never talks to devices directly. Telemetry flows through a gateway that owns validation and normalization, and Firestore carries the current state of each unit:
device → MQTT → bridge (validate, normalize) → Firestore → Flutter dashboard
Firestore is the right tool for the dashboard because the data is small and changes often: one document per unit, rewritten as readings arrive. History does not live here — it belongs in a warehouse (we cover that in the MQTT → BigQuery tutorial). Keeping history out of the live collection is what keeps the read path cheap.
1. Model the data
One document per unit, under the owning organization. The document is the unit's present tense — no arrays of past readings, no growing fields:
class VehicleReading {
const VehicleReading({
required this.vehicleId,
required this.lat,
required this.lng,
required this.speedKph,
required this.updatedAt,
});
final String vehicleId;
final double lat;
final double lng;
final double speedKph;
final DateTime updatedAt;
factory VehicleReading.fromDoc(DocumentSnapshot<Map<String, dynamic>> doc) {
final data = doc.data()!;
return VehicleReading(
vehicleId: doc.id,
lat: (data['lat'] as num).toDouble(),
lng: (data['lng'] as num).toDouble(),
speedKph: (data['speedKph'] as num).toDouble(),
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
);
}
}
Two details matter more than they look. First, num.toDouble(): Firestore returns integers for whole numbers, and a
double cast will crash on the one device that reports 0 instead of 0.0. Second, the timestamp stays a Firestore
Timestamp in the database and is converted at the edge — timezone bugs start where storage formats get creative.
2. Stream it into the UI
Firestore's snapshots() gives you a live query. Wrap it once in a repository method and keep StreamBuilder
dumb:
Stream<List<VehicleReading>> watchFleet(String orgId) {
return FirebaseFirestore.instance
.collection('orgs')
.doc(orgId)
.collection('vehicles')
.orderBy('updatedAt', descending: true)
.limit(200)
.snapshots()
.map((snap) => snap.docs.map(VehicleReading.fromDoc).toList());
}
StreamBuilder<List<VehicleReading>>(
stream: watchFleet(orgId),
builder: (context, snapshot) {
if (snapshot.hasError) {
return ErrorTile(message: 'Telemetry unavailable');
}
final readings = snapshot.data;
if (readings == null) {
return const DashboardSkeleton();
}
return FleetGrid(readings: readings);
},
)
The limit(200) is not decoration — it is the cost model. Firestore bills per document read and re-reads the whole
result set when anything in the query changes. A fleet screen showing 200 units costs 200 reads per update cycle, so
you want the query as small as the screen actually needs. If a dashboard shows aggregated numbers rather than every
unit, aggregate server-side into a rollup document and watch that instead.
3. Make the write path deliberate
The bridge writes updates; the app never does:
await db.collection('orgs').doc(orgId).collection('vehicles').doc(deviceId).set(
{
lat: reading.lat,
lng: reading.lng,
speedKph: reading.speedKph,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
serverTimestamp() keeps clocks honest — device clocks drift, and a dashboard sorted by a drifting clock shows a
different order to every user. merge: true means the bridge can add fields without rewriting documents it does not
own.
4. Lock it down
Rules do the tenant isolation. The org id travels in a custom claim, so a query can only ever reach the caller's own data:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /orgs/{orgId}/vehicles/{vehicleId} {
allow read: if request.auth != null
&& request.auth.token.orgId == orgId;
// Telemetry arrives through the bridge; clients never write.
allow write: if false;
}
}
}
Write rules matter as much as read rules. A dashboard only needs read; allowing client writes "for testing" is how
telemetry tables end up full of fabricated readings.
5. Test the failure modes
The live path is the easy part. Before calling it production:
- Kill the network. Firestore's offline cache keeps the last snapshot visible; your skeleton and error states still need to be right when the cache is empty.
- Send a malformed document. One unit writing
speedKph: "n/a"must not take down the grid — validate at the bridge, and defensively infromDoc. - Watch the read counter. Leave the dashboard open for an hour and check usage. If reads scale with time rather than with change, something is polling that should be streaming.
What to take away
The dashboard is a view of present state, and every design decision follows from that: one document per unit,
history in the warehouse, aggregates in rollups, writes through one bridge, tenant isolation in rules. Get those
right and the Flutter layer is straightforward — StreamBuilder, a CustomPaint map, and a few thousand widgets
that never see a network call.