Civiqapp architecture

Architecture

A conventional layered Flutter app, with two deliberate hard rules: services own every byte of I/O, and the API client owns every request's authentication.

Layered architecture: UI layer (pages, widgets), business-logic layer (services, data), foundation (API client, database, models/theme/utility), and the backend server
Dependencies point strictly downward. Nothing in widgets/ touches HTTP or SQL directly.

The layers and what each one is allowed to do

The top layer is thin by design. pages/ holds full-screen compositions (the home page, the community forum, news listings) that register any page-scoped providers and arrange widgets. widgets/ holds everything visual, from the representative card down to the attestation gate. Widgets acquire services with context.read or context.watch and never construct them; that keeps every widget testable by pumping it with fake providers.

The middle layer is where behavior lives. Each service wraps one domain — representatives, accounts, contact delivery, community, reporting, boundaries, attestation — and exposes a small async API. Services are the only code that combines the API client with the database; that pairing is what makes flows like "check the SHA, fetch if stale, upsert in a transaction, then query" possible to reason about in one file.

The foundation is shared plumbing. CiviqServerApi is a singleton HTTP client that also owns the on-disk response cache, ETag handling, striped request locks, and the 401/403 recovery loop (described with attestation). DatabaseService owns the single SQLite database and its schema. models/, theme/, and utility/ are passive: pure data classes with fromMap/toMap, the Material 3 theme system, and helper functions with no widget dependencies.

State management: Provider, and why services are never rebuilt

All shared state flows through Provider. Most services are plain Provider registrations; the ones whose changes must repaint UI (AccountService, CommunityService, DistrictBoundaryService) are ChangeNotifiers. Two wiring decisions in main.dart are worth understanding because they look odd until you know the history:

RepresentativesService is registered through a ProxyProvider whose update callback returns the previous instance (previous ?? RepresentativesService(db)). Rebuilding this service would silently discard its in-memory query and bill caches, the loaded dataset SHAs, and — worse — the targets of every registered imageUpdates/billUpdates listener. The proxy shape exists purely to express "depends on the database, but is created exactly once".

There is deliberately no root LocationNotifier. HomePage creates its own, because every consumer (the map, the search field, the representatives list) sits under HomePage anyway, and a root instance was previously shadowed by the page-level one for all of them. Keeping the page self-contained also lets widget tests pump HomePage directly.

Cross-cutting conventions

A few rules are enforced everywhere and explain patterns you will see across files. Stateful widgets check if (!mounted) return; after every await before touching context. Fire-and-forget side effects go through scheduleMicrotask so they cannot block a frame. Mutual exclusion uses the synchronized package's Lock rather than ad-hoc booleans; single-flight coalescing (one in-flight future shared by concurrent callers) appears independently in token refresh, attestation recovery, sync retries, and boundary loading because it is the house answer to thundering-herd bugs. All SQLite writes go through DatabaseService.transaction() with parameterized queries; server data is never interpolated into SQL.

Identity is a subtle convention worth knowing before reading any flow: rep.id is the local SQLite autoincrement key and is used for joins, images, and marker sorting, while rep.uuId is the last seven characters of the bioguide/openstates identifier and is the key for bill caching, batch image resolution, and community anchors. The contact flow is the exception — it sends the full external identifier, not the short form.

Environments and configuration

The base URL comes from assets/configs/<ENV>.json, selected at build time (--dart-define=ENV=…, defaulting to dev in debug and release in release builds). Debug builds may override it with CIVIQ_BASE_URL for physical-device testing; release builds ignore the override, require HTTPS, and are double-guarded — both AppDebugConfig and the API client independently zero out debug escape hatches under kReleaseMode. An offline_mode config flag turns the app into a cache-only reader: GETs serve from disk (falling back to bundled seed assets), POSTs throw, and the attestation gate passes without a ceremony.

The INTERNAL_TESTING define (non-release only) enables the in-app log console — a ring buffer of print output toggled by holding three fingers for five seconds — and also bypasses attestation, which is what makes simulator-based QA possible. See Reporting & debug.