Civiqapp architecture

Representatives by location

The app's core loop: a coordinate goes in, a district-filtered list of representatives comes out — rendered from SQLite, with the network consulted only to ask whether anything changed.

End-to-end flow from GPS or search input through geocoding, SHA check, sync, SQLite query, client-side filtering, and rendering
The end-to-end path. The SHA check is the only mandatory network hop on a warm cache, and even its failure degrades to serving cached rows.

From coordinate to districts

Input arrives two ways: the map acquires GPS through the geolocator permission flow, or the search field resolves a typed address (debounced 500 ms). Either way the coordinate lands in LocationNotifier, which reverse-geocodes it into a structured address (state, county, city, ZIP) using platform placemarks, then asks the server to resolve districts: POST /geocodio/district returns the congressional, state-house, and state-senate district numbers. The two-step update is generation-guarded so a slow geocode for an old location can never overwrite a newer one. District resolution goes through the server — not a client-side Geocodio key — so the API key stays server-side and results benefit from server caching.

The query, and where data actually comes from

The representatives widget reacts to notifier changes with a deduplicating query key and a 175 ms debounce, then calls RepresentativesService.queryRepresentatives. From there, the service works through layers:

An in-memory LRU (50 entries) answers repeat queries instantly. Its key embeds a version token concatenated from all dataset SHAs, which is what lets a background sync invalidate every stale entry at once without tracking them individually — the key simply stops matching.

On a miss, the service checks freshness for the target state's legislator slice: GET /representatives/states/status returns a per-state SHA that is compared against a marker stored in app_metadata. Stale means one fetch (GET /representatives/states?states=…&districts=…) followed by a single transaction that upserts the rows and writes the SHA marker together — the status check itself is deliberately read-only, because writing the marker before a successful fetch once marked districts fresh while they still held stale rows. If the status check or fetch fails, the query proceeds on cached rows, and the result is not written to the LRU when it included state-level data, so a degraded partial answer cannot become the session's cached truth.

The SQL predicate filters by level and state only. District filtering is client-side, in the widget: national house seats match the user's congressional district, state house and senate seats match their respective chambers' districts, and the "In District / All Districts" toggle just re-filters the already-loaded list without touching the service. Committee membership, current terms, and office addresses are attached with batched IN (…) queries rather than per-row lookups.

National congress data: the SHA-gated sync

Flowchart of the congress dataset sync: status endpoint, SHA comparison, per-dataset transactional upsert, retry backoff
Five datasets, one status call. Only stale datasets are downloaded, each committed with its SHA in one transaction.

National data (legislators, offices, socials, committees, committee memberships) syncs at service init. One call to GET /representatives/congress/status returns five SHAs; each is compared to a stored marker, and only mismatched datasets are downloaded. Each dataset commits in its own transaction that re-checks the SHA inside the transaction before upserting — the in-memory field is promoted only after commit. There is no delta protocol; each dataset is all-or-nothing, and refresh is upsert-style against unique indexes rather than truncate-and-reload, so readers never see an empty table mid-sync. Committees and memberships are paired in one branch because membership-only changes previously failed to refresh committees when the datasets were tracked with positional booleans — the status type now uses named fields for exactly that reason.

When the server is unreachable at launch, the service serves whatever SQLite has and schedules background retries at 5 s, 15 s, 45 s, and 2 min before giving up until next launch. A successful retry bumps the syncUpdates notifier, which the widget answers by clearing its error state and re-querying. Only a device with no cached representatives at all treats sync failure as a hard init error.

Images: three layers and a fallback chain

Flowchart of the representative image pipeline: SQLite cache, batch URL resolution, bounded-concurrency downloads, per-rep fallbacks
Photos resolve in the background after every query; the UI updates via a notifier, coalesced to one rebuild per frame.

Photos never block the list. After each cache-miss query, a fire-and-forget pipeline resolves images for reps that lack one: first the local database (a representative_images row whose file still exists on disk), then a batched POST /representatives/images (up to 100 UUIDs per request), then downloads at concurrency 4 with a 10-second timeout. Reps the server has no photo for fall back per-rep: a legacy per-UUID GET, then the unitedstates.github.io congress images (national reps only), then the Wikipedia pageimages API with exponential backoff on 429s — and a Wikipedia hit reports the discovered title back via POST /representatives/photo-hint so the server can improve its own records.

Two isolation rules matter here. Third-party image hosts are fetched with a separate HTTP client so attestation and account headers can never leak off the Civiq API. And a failed download records "attempted" and falls back to the bundled avatar rather than handing Flutter a remote URL — image widgets must never start unbounded framework-managed requests during paint. Resolved images publish through the imageUpdates notifier; the widget coalesces bursts (a cold sync can fire 50+ updates) into one setState per frame.

Bills: stale-while-revalidate with status-based TTLs

Flowchart of the bill highlights read path: LRU, SQLite, negative cache, server, with TTL persistence
Four read layers. Expired rows are served immediately while a deduplicated background refresh runs.

The legislation sheet reads through queryCachedBillsHighlights: an LRU (50 entries, keyed by the rep's short UUID), then SQLite, then a 24-hour negative-cache marker (representative_bill_checks) that remembers "this rep has no bills" without a network trip, then POST /representatives/bills/highlight. Cached rows past their TTL are served immediately while a background refresh runs — the sheet listens on billUpdates and swaps in fresh data when it lands. TTLs scale with how much a status can still change: Introduced 1 h, In Committee 6 h, Passed 24 h, Vetoed/Failed 7 d, Enacted/Became Law 30 d.

Persistence replaces the rep's bill links in one transaction, reclaiming orphaned bills only if no other rep still links them (co-sponsored bills survive), and bills are keyed by local autoincrement — the server's bill_id repeats across Congresses and is stored as informational only. A launch-time sweep deletes expired bills that no rep links anymore; linked expired rows are kept on purpose, because they are what stale-while-revalidate serves.

District boundaries and the map

Boundary shading comes from GET /districts/{STATE} — a GeoJSON FeatureCollection per state, from ~114 KB (RI) to ~1.2 MB (CA). The response is cached on disk with an ETag sidecar, so the common case is a 304 and zero bytes of body; parsing (JSON decode plus polygon construction) runs on a worker isolate because doing it on the UI isolate would stall frames. Per-state failures cool down for 2 minutes before retry. Levels arrive as wire codes (cd, sldu, sldl, state); unknown levels and malformed features are skipped individually so one bad feature can never blank the map.

On the map, regions are derived from the currently filtered reps and drawn largest-first (state, congressional, state senate, state house) so small districts stay tappable on top. Colors key to seat kind, never to party — a non-partisan-charter decision enforced in the palette itself. Long-pressing cycles through the districts containing that point and reveals the matching rep in the list; office markers are geocoded on-device at concurrency 3 (iOS rate-limits aggressively) with a per-session negative cache, and newly resolved coordinates are persisted back into representative_offices so the next session skips the geocoder entirely.

Base tiles come from the server's own /tiles/{z}/{x}/{y} proxy (keeping the MapTiler key server-side); five consecutive tile errors switch to a keyless CartoCDN fallback, and tile errors are sanitized before logging so URL query strings — where tile keys live — can never leak into device logs.

Reference

ConstantValueWhy this value
Query LRU / bill LRU50 entries eachCovers a session's realistic location + filter churn
Query debounce175 msAbsorbs notifier bursts during location updates
Search debounce500 msKeyboard-speed input against a paid geocoder
Image batch / concurrency100 per request / 4 downloadsServer batch cap; polite parallelism on mobile radio
Office geocode concurrency3iOS CLGeocoder throttles; negative cache makes retries costly
Sync retry backoff5 s → 15 s → 45 s → 2 minFast recovery from blips without hammering an outage
Boundary retry cooldown2 min per stateLarge payloads; a failing state shouldn't loop
Bill TTLs1 h – 30 d by statusFreshness proportional to how much a status can still change