Tribar Studio
← All tutorials
IntermediateFlutterFirebaseRealtime

Build a real-time operations dashboard with Flutter and Firebase

Engineering Team··45 min

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 in fromDoc.
  • 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.

Let's build the impossible together.

Tell us about your vision. We'll architect the full stack — from device to dashboard — and respond within 24 hours with a plan, timeline, and estimate. No BS, no sales pitch — just geometry.

Start a Project

No commitment required. Free initial consultation.