SALESTRACK — PROPERTY LEAD DISPATCH
Time-boxed lead dispatch with client-server deadline enforcement.

Flutter client for property sales teams that pushes time-boxed leads to agents, enforces an accept-or-expire window, and tracks each lead from assignment to conversion. It replaces manual lead distribution with a deterministic, auditable status lifecycle.
Orders were simple on paper.
Fulfillment was fragmented.
Property sales teams receive leads from many channels such as social ads, WhatsApp, and property portals, and must contact them within minutes before the prospect goes cold. Before a system like this, leads were handed out over chat and spreadsheets, so ownership was ambiguous and response time was unmeasurable. When multiple agents chase or drop the same lead, duplicate calls damage the prospect experience and distort conversion metrics. The hardest constraint is time: a lead not answered inside its accept window must be released and re-dispatched, and that deadline has to hold even when the agent's phone is asleep or the app is backgrounded. Any timer that lives only in the UI will drift, freeze, or disagree with the server, producing leads that are accepted after the backend already reassigned them.
Leads were distributed manually over chat and spreadsheets, so ownership was ambiguous and two agents could contact the same prospect.
Acceptance deadlines lived in convention rather than code, so slow responses went undetected and could not be enforced or measured.
Without a server-committed accept step, client and backend could disagree on who owned a lead once it was reassigned after expiry.
One order. Multiple fulfillment paths.
Explore how the system splits each line item into independent operational branches while enforcing strict transactional convergence before dispatch.
Lead Acceptance (Direct Processing)
Runs when a high-priority lead is dispatched and the agent accepts within the auto-reject window.
Push Dispatch
Backend emits a data message tagged type=lead_assigned with priority=high; the client classifies it as an incoming lead.
Full-Screen Alert
Raises a max-importance notification with fullScreenIntent, starts looping lead_alert audio, and opens IncomingLeadScreen with leadId and timeout.
Countdown Reconciliation
Provider registers arrivalTime, derives remaining seconds from the wall clock, and the screen enriches fields from GET /mobile/leads/{id}.
Accept Persist
POST /mobile/leads/{id}/accept commits ownership; the provider removes the pending lead, stops the sound, and routes to /leads.
WALL-CLOCK COUNTDOWN INSTEAD OF A DECREMENTING TIMER
IncomingLeadProvider stores arrivalTime and derives remainingSeconds from DateTime.now() on read, while a single 1-second timer only schedules re-render and pruning.
A decrementing counter drifts or freezes when the OS suspends the app or backgrounds the process. Deriving from the wall clock keeps the deadline consistent across foreground, background, and screen re-entry, and makes the timer a scheduler rather than a source of truth.
DUAL FCM CHANNELS WITH A CENTRALIZED CLASSIFIER
Two Android channels are declared at startup — a normal channel and a max-importance fullScreenIntent channel — and a single isIncomingLead() classifier decides routing for foreground, background, and terminated states.
Android channel importance is immutable after creation, so urgent lead alerts require a dedicated channel that cannot be reused for routine notifications. Centralizing classification prevents three divergent interpretations of the same payload and reserves fullScreenIntent and custom sound strictly for high-priority leads.
TYPED MODEL LAYER WITH NULL-SAFE NESTED ADAPTERS
Every API response is parsed into typed Dart models (LeadModel, DashboardModel, UserProfile) with defensive fromJson casting, and the UI never indexes raw maps.
The backend returns inconsistent shapes — wrapped data, nested source/property/assignments objects, nullable timestamps. Parsing at the boundary turns runtime cast failures into controlled defaults (empty lists, fallbacks) and decouples UI rebuilds from transport-format changes.
GLOBAL SESSION INTERCEPTOR IN THE HTTP LAYER
ApiService centrally injects the Bearer token and userId and converts any 401 on a non-auth endpoint into a full logout that clears all ChangeNotifier providers.
Scattering auth handling across features leaves orphaned state and half-authenticated screens. A single interceptor guarantees that an expired token purges cached leads, dashboard, notifications, and pending alerts atomically, preventing stale data from leaking after session expiry.
Engineered for relational integrity.
The app is organized into feature modules (auth, dashboard, leads, incoming_lead, notifications, profile) over a shared core of API clients, model adapters, and services. Every HTTP call flows through a single ApiService that attaches the Bearer token, auto-injects the userId for /mobile endpoints, enforces a 30-second timeout, and normalizes errors; a 401 on any non-auth endpoint triggers a global logout that clears all providers. Responses are parsed into typed models at the boundary, with defensive casting for nested and nullable fields such as source, property, assignments, and timestamps, so malformed data degrades to defaults instead of crashing a rebuild. Notifications run through a dual-channel pipeline: one classifier decides whether a payload is an incoming lead, and a dedicated max-importance channel with fullScreenIntent and a custom sound is reserved for those alerts. Session state persists behind one storage accessor and is purged atomically on logout or token expiry.
CLIENT LAYER
- Flutter (Dart SDK ^3.6.0)
- Provider / ChangeNotifier with Selector and AutomaticKeepAliveClientMixin
- Typed fromJson model adapters (no runtime map casting)
- flutter_localizations (en/id) with custom TikTokSans theme
BUSINESS & DOMAIN
- Layered feature modules (auth, dashboard, leads, incoming_lead, notifications, profile)
- IncomingLeadProvider deadline engine with status lifecycle (assigned → contacted → interested → converted/rejected)
- Role/session model with global 401 invalidation and userId auto-injection
- Debug-gated request/response logging with diagnostic hints
DATA & STORAGE
- REST API at sales-track.net/api via package:http with 30-second timeouts
- Central ApiService with per-domain clients (leads, dashboard, profile, devices, notifications)
- Firebase Cloud Messaging plus flutter_local_notifications dual-channel pipeline
- shared_preferences session persistence with atomic clear on logout or 401
The result is a lead pipeline whose deadlines and ownership are enforced the same way on the device and on the server, instead of being inferred from chat threads. Agents see a single, prioritized incoming alert with a countdown they cannot easily ignore, and every accepted lead carries an auditable sequence of assignment and status transitions. Managers get dashboard metrics — assigned, contacted, converted, rejected, and per-channel pipeline — computed from that consistent state. Because the deadline is recomputed rather than decremented and the session is invalidated atomically, the app stays predictable through backgrounding, poor connectivity, and token expiry. This turns lead handling from a manual, unmeasurable process into a deterministic one that can scale with volume.
More systems worth exploring.
Explore another project where software, business processes, and practical engineering meet.