Wattr Operator App
Visual Specification
Screen-by-screen specification of the operator mobile app, generated from source. Screens are recreated as annotated HTML mockups from the widget code; live values shown are representative examples.
App overview
The fleet-control companion for scheme staff — many pumps, one operator.
Wattr Operator is used by scheme operators and Junior Engineers (JEs) to monitor and control the pumps in their scope: a filterable fleet dashboard, per-pump detail with live readings and start/stop, run-session history with export, per-pump schedules, automation settings, and a push-notification inbox. Where the farmer app serves one person's 1–15 pumps, this app is built for hundreds (staging tenant 41: 503 pumps).
Roles
| Role | API role code | Differences in-app |
|---|---|---|
| Operator | OPERATOR | Baseline experience |
| Junior Engineer (JE) | CLIENT_ADMIN / JE | Sees an "Operator: <name>" line on rows/detail and an operator filter group. Note: per-user scoping is not yet enforced server-side — an operator currently sees the whole tenant. |
Architecture in brief
- State: Riverpod 3 (providers per feature,
autoDisposewhere re-entry should refetch). HTTP: single Dio client, base URL hard-coded tohttps://staging-api.wattr.ai/v1(no build-time override — a known gap), Bearer token injected by interceptor, one automatic retry afterPOST /auth/refreshon 401. - Navigation: no router package. An
AuthGateswitches Splash / Login / Dashboard on auth state; everything else is imperativeNavigator.push. Push notifications deep-link through a global navigator key. - Layout convention: feature-first (
features/<name>/data·domain·presentation), shared code undercore/. - i18n: same mechanism as the farmer app — the English string is the dictionary key,
WTextlooks it up,hi.json/pa.jsonhold ~245 entries each, language persists underwattr.language. Server-rendered notification text is deliberately never re-translated. - Tokens are stored in SharedPreferences (
wattr_access_token/wattr_refresh_token/wattr_user) — not secure storage.
Design system
Material 3 seeded from brand teal, Inter throughout, full light + dark themes following the OS setting.
Source: lib/core/constants/app_colors.dart · app_text_styles.dart · app_dimensions.dart · lib/core/theme/app_theme.dart
Brand & surfaces (light)
Status colors (the 3-tier scheme)
Resolved design decision (docs/prd-vs-design-discrepancies.md §1): faults render amber, not red — red means "not running, for any reason" (shutdown, bypass, remote stop, power loss). Color is never the only signal; every state also carries an icon and a text caption.
Dark theme
Full dark palette ships and follows the OS: background #0B0F19, surface #161B26, text #F9FAFB, muted #9CA3AF, border #2A3040, input fill #1F2937. The status tints have no dark variants — the light backgrounds (#E6F4EA / #FFF4DE / #FCE8E8) are reused as-is in dark mode.
Typography
One family — Inter (variable, bundled) with Noto Sans Devanagari / Gurmukhi fallbacks for Hindi and Punjabi. Scale: display 32/700 · headline 24/700 · titleLarge 20/600 (app bar) · titleMedium 16/600 · body 16 & 14/400 · labelLarge 14/600 (buttons) · bodySmall 12/400. No serif, no monospace — numerics use Inter.
Dimensions & shared widgets
4-pt spacing grid (4/8/16/24/32/48), radii 8/12/20/pill, buttons and inputs 52 px tall, inputs filled #F9FAFB with 12 px radius and a 1.5 px teal focus border.
| Widget | Spec |
|---|---|
WText | Translating Text — English literal is the i18n key; raw Text is reserved for data (names, IDs, times) |
SectionCard | M3 Card, 16 px padding, title 16/700 + optional 12 px subtitle + optional trailing action |
PrimaryButton | Full-width 52 px, teal fill, 12 px radius; loading swaps the label for a 24 px spinner |
Sparkline | fl_chart line, 48 px tall, curved, 2 px stroke, 12% area fill, no axes/touch |
MaskedIdText | Privacy control: device icon + ••••• + last 5 digits of the IMEI — the full IMEI is never rendered |
AppScaffold | Scaffold wrapper (SafeArea + optional 16 px padding) used by list screens |
Splash, login & shell
AuthGate switches Splash → Login → Dashboard; a drawer carries account-level actions.
Source: lib/features/splash/ · lib/features/auth/ · lib/features/shell/presentation/widgets/app_drawer.dart
Behavior notes
- Splash is a plain teal
#0F6E5Cscreen with the orange bolt + "wattr" wordmark (rawText, never translated). Shown only whilerestoreSession()runs: stored token →GET /users/current; 401/403 clears tokens; network/5xx falls back optimistically to the cached user. - Login: fields disable while submitting; client validation "Please enter username and password" (no network call); server errors show in a fixed light-red banner (
#FCE8E8— does not adapt to dark mode). Network failure → "Connection failed. Check your network."; otherwise "Invalid credentials" or the server's message. Language chip (top-right) opens the same en/hi/pa sheet as the drawer. - Drawer: name (fallback email, then "—") + role label ("Junior Engineer" / "Operator"), Notification History, Notification Preferences, a Language dropdown row, and Logout (red icon) with the confirmation "Sign out? — You will need to sign in again to continue." (Cancel / Yes, sign out). Logout clears tokens but keeps the language preference.
- Auth API:
POST /auth/login(usernameOrEmail+password),GET /users/current,POST /auth/logout(best-effort),POST /auth/refresh.
Dashboard (pump list)
The home screen: summary tiles that are also filters, search, a filter/sort sheet, and an infinite-scrolling fleet list.
Source: lib/features/dashboard/ · docs/pump-list-api-contract.md
Anatomy
- App bar — "Wattr.ai Operator", hamburger (drawer), bell with an unread-count badge (
GET /notifications/unread-count). - Search + filter button — search matches tower/pump name, debounced 350 ms; the tonal filter button shows a dot badge when any filter is active and opens the filter sheet.
- Summary tiles are filters — Total / On / Off / Fault counts from
GET /pumps/status-summary(always unfiltered, whole scope). Tapping On/Off/Fault toggles that status filter; the active tile gets a 1.5 px border and 10% tint. Values show "—" while loading. Mapping: On = running.total; Fault = running.with_faults + power_issues; Off = stopped.total. - Active filter chips — plain rows (icon + label + ⓧ) for Status / Mode / Operator; search is deliberately not chipped. Sort alone doesn't count as a filter.
- Pump row — 56 px status disc (green/amber/red by severity; icon by cause: droplet running, ⚠ fault, power-off, cloud-off dormant, remote/bypass variants for idle); "A" badge = Auto Mode; dimmed disc + wifi-off badge + red "Stale" when last-seen >30 min. Caption examples: "Running · Auto", "Fault · Reverse Phase", "Off · Power Down", "No contact". Elapsed line ("Running for 2h 8m") and relative time tick every 30 s. IMEI always masked to last 5 digits. JE logins also see "Operator: <name>".
- Long-press — quick start/stop dialog ("Turn on pump? / Turn off — <name>"); blocked with a snackbar when Auto Mode is on. Confirmation polls
GET /pumps/:id/statusat 1/2/3/5/8 s.
Filter sheet
Bottom sheet with drag handle, four ChoiceChip groups: Filter by status (All/On/Off/Fault), Filter by mode (All/Manual/Schedule/Auto → triggered_by=), Filter by Operator (JE only; server endpoint not implemented yet, so empty in practice), Sort by (Most recent / Status). "Clear all filters" appears when anything is active.
States
Enums (complete)
Run states: running · idle · bt_power_down → "Off · Power Down" · dormant_7d_plus → "No contact". Faults: Dry Run, Overload, Overcurrent, Overvoltage, Undervoltage, Reverse Phase, Phase Down, Phase Imbalance, Overheat, Mechanical — all amber. Modes: Manual, Schedule, Auto, Remote, Bypass (precedence bypass > remote > schedule > auto/manual). Live-data note: 88% of rows have no trigger and read "· Manual" by fallback; Schedule/Auto triggers have not been observed on staging.
Pump detail
One pump's full state: status hero, live readings, mode control, schedule preview, phase health, and a pinned action bar.
Source: lib/features/pump_detail/
Anatomy (top to bottom)
- Stale banner (conditional) — "Last known status — connection lost. Updated 3 hr ago" when last-seen is unknown or >30 min.
- Recent alert banner (conditional) — "<Fault> detected <time> ago", amber.
- Status hero — tinted 20 px-radius banner. Titles: "Running" / "Fault · Dry Run" / "Off · Power Down" / "No contact" / "Off · Remote" etc. Each has a one-line reason, e.g. "Protection tripped — the pump stopped itself.", "Mains power is unavailable.", "Stopped from the panel bypass switch.", "Reason not reported."
- Scheduled Run card (self-hiding) — live occurrence with progress bar and "84/240 min delivered"; states: Running now / Starting soon / Paused due to power cut — will resume automatically / Resuming after power cut.
- Status card — Last Update, Electricity Availability, Pump Status, Mode with an inline Auto/Manual switch (confirmation dialog: "Switch to Auto Mode? — Auto Mode runs the pump on electricity availability and overrides manual control and schedules."), Operator (JE only).
- Live Readings — Power / Discharge / Frequency tiles (forced to 0.00 when not running; "—" when null) + Runtime.
- Schedule card — next-session preview with inline toggle, or "No upcoming schedules." + Add Schedule, or the Auto-Mode override notice, or "All schedules are paused for this pump."
- Voltage/Current Trend (self-hiding) — 48 px sparklines of today's phase averages from
GET /reports/telemetry-log. - Voltage/Current Health — per-phase bars against device-configured min/max: green in range, amber ↗ within 10% of a limit, red ⚠ out of range, "--" + "No range configured" when unset. Values are never fabricated.
- Pinned action bar — Settings + Running Times outlined buttons, then a full-width Turn On (green) / Turn Off (red) button. Confirmation includes the location: "Turn on — Tw 5 · Punjab > Sangrur > …". Disabled with "Manual control is overridden while Auto Mode is on." in Auto Mode.
Command flow
POST /commands/pump/:id/start|stop with an idempotency key → 202 + expected_ack_seconds (clamped 15–120 s, default 30). Button shows "Turning on…/off…" with a spinner; polls GET /pumps/:id/status at 1/2/3/5/8/12/18/25 s. Timeout copy is deliberately neutral: "Still working — pull down to refresh." Mode switches go through POST /commands/pump/:id/command with a SetMode CONFIGURE payload and confirm by refetching the pump (90 s cap).
No background polling on this screen — refresh is pull-to-refresh or command-driven. Data: GET /pumps/:id then best-effort GET /device-config/devices/:deviceId/config/reported.
Running times
Run-session history with range presets, a daily-runtime bar chart, a short-run anomaly flag, and CSV/Excel export.
Source: lib/features/running_times/
Behavior
- Ranges: Today / Last 7 days (default) / Last 30 days / Custom (date-range picker, max 31 days — "Choose a range of up to 31 days." on violation).
- Stats: Total Runtime / Sessions / Avg Session from
GET /reports/start-stop/summary; "—" placeholders while loading. - Anomaly flag: completed sessions shorter than 3 minutes are marked "possibly abnormal" (red icon/subtitle) and counted in the amber banner "N short sessions — possible fault cycling."
- Chart: teal bars, one per day (label
d/M) or one per session for single-day windows (labelHH:mm); bar width and label density adapt to entry count; no tooltips. - Sessions: one page of up to 1,000 rows from
GET /reports/start-stop; if more exist, footer reads "Showing the most recent runs only." Trigger pills: Manual, Schedule (teal), Auto (amber), Remote, Bypass; sessions still in progress read "Running". - Export: app-bar share menu (CSV / Excel) →
POST /reports/generate, poll/reports/jobs/:id(~30 s cap), download, hand to the native share sheet. Copy: "Preparing export…", "Nothing to export.", "Could not export the log."
Reached only from Pump Detail's action bar. No auto-refresh timer; pull-to-refresh reloads.
Schedules
Per-pump schedule list with a pause-all switch, and a bottom-sheet create/edit form. Capped at 16 slots (firmware limit).
Source: lib/features/schedules/
Behavior
- Names are always "Untitled schedule" in real data — the backend never populates
name. - Status pills: Paused (amber, when pause-all is on — wins over everything) · Active (green) · Inactive (amber). Row toggles disable while pause-all is on.
- Pause all: confirmation only when pausing ("Pause all schedules? — All schedules for this pump stop running until you resume them."); resuming is immediate. Auto-Mode pumps show an amber banner: "This pump is in Auto Mode, so schedules are overridden and will not run until Auto Mode is turned off in Settings."
- Validation (in order): "Pick at least one day." (inline, red, clears on tap) · "Set a start time." · "Enter a duration between 1 and 1440 minutes." · "End time must be after the start time." · "Duration must fit before the end time." Setting an end time triggers the hard-end confirmation: "Set an end time? — The pump will stop at this time even if the run has not finished. A run cut short is marked incomplete."
- Delete: "Delete schedule? — This removes the schedule permanently." with a red Delete button; snackbar "Schedule deleted".
- Cap: adding beyond 16 slots is blocked client-side: "You can add up to 16 schedules per pump."
- Server errors render verbatim (by design — they name the conflicting schedule): overlap 400, Auto-Mode 409, read-only tenant 403.
- API:
GET/POST /pumps/:id/schedules,PUT/DELETE …/:slotIndex,POST …/pause; day bitmask Sun=bit 0 (127 all, 62 Mon–Fri, 65 weekends);stop_timeis server-derived; every mutation returns the full slot list.
Settings
Deliberately automation-only: an Auto Mode switch and two timing fields. Language and sign-out live in the drawer, not here.
Source: lib/features/settings/ · docs/prd-vs-design-discrepancies.md §6.4
Auto Delay
Behavior
- Scope was intentionally cut for v5: no protection thresholds (Vmin/Vmax, Imin/Imax), no send intervals, no HP calculator — roughly 80% of the originally designed screen. This spec documents the shipped scope.
- Auto Mode always confirms in both directions ("Switch to Auto Mode?" / "Switch to Manual Mode?"), flips optimistically, and snackbars "Switching to Auto…/Manual…". Failure snaps back with "Something went wrong. Please try again."
- Timing fields seed once from reported config and never clobber typing; blank Max Duration means 0 (no cap); invalid input → "Enter a valid number."; success → "Settings saved."
- Firmware quirk that shapes the code: the device honours only the first command in a batched CONFIGURE envelope, so each setting is sent as its own
POST /commands/pump/:id/commandspaced 1 s apart — saving both fields takes ≥1 s by design. - Read:
GET /device-config/devices/:deviceId/config/reported. No device attached → "This pump has no device to configure."
Notifications & push
An FCM-driven inbox with category filters, a six-toggle preferences screen, and deep links into pump detail.
Source: lib/core/push/push_service.dart · lib/features/notifications/
Push behavior
- Channels (IDs shared with the farmer app backend):
critical_standard"Critical alerts" (heads-up + sound) andwarning_silent"Warnings" (silent). The payload'stag: criticalpicks the channel. - Routing: a tapped notification with
pump_idopens that pump's detail; anything else opens Notification History. Those are the only payload keys the client acts on — title/body arrive server-rendered in the recipient's language and are never re-translated. - Permission: if denied, one dialog per launch — "Turn on notifications — Notifications are off, so you won't get pump alerts. Turn them on for Wattr Operator." (Not now / Open Settings).
- Registration:
PUT /user-devices/deviceswith{fcm_token, fcm_platform}; iOS waits for the APNS token (up to 10 × 500 ms).
Notification History
Cursor-paginated inbox (GET /notifications?limit=30) with category filter chips (All / Pump / Fault / Schedule / Weather), severity dots (red / warning / info), unread styling, optimistic mark-read and mark-all-read, and grouped rows ("…N times today" via group_count). The bell badge comes from GET /notifications/unread-count and is invalidated on push arrival, read actions, and pull-to-refresh.
Notification Preferences
Six per-type toggles, all default ON, saved optimistically via PUT /notification-preferences (silent rollback on failure): Power Down · Overvoltage · Undervoltage · Overload · Dry Run · Reverse Phase. Everything else (theft, no-signal, weather, schedule) is mandatory and ignores these toggles.
API reference
Every endpoint the operator app calls. Base: https://staging-api.wattr.ai/v1 (hard-coded), Bearer JWT, one auto-retry via POST /auth/refresh.
| Endpoint | Used by | Notes |
|---|---|---|
POST /auth/login · /auth/logout · /auth/refresh · GET /users/current | Auth | Login body {usernameOrEmail, password} |
GET /pumps | Dashboard | page/page_size=25, search, status=running|idle, fault=any, triggered_by, sort=status; retries once without sort/mode on 400 |
GET /pumps/status-summary | Summary tiles | Scope from JWT; refreshed with the list |
GET /pumps/:id + GET /device-config/devices/:id/config/reported | Pump detail, Settings | Config call is best-effort |
POST /commands/pump/:id/start|stop · GET /pumps/:id/status | Control | 202 + expected_ack_seconds; Idempotency-Key header; confirm polls 1/2/3/5/8(/12/18/25) s |
POST /commands/pump/:id/command | Mode + auto timings | CONFIGURE envelope: SetMode / SetAutoDelay / SetAutoDuration — one command per request, 1 s apart |
GET/POST /pumps/:id/schedules · PUT/DELETE …/:slotIndex · POST …/pause · GET /pumps/:id/occurrences/live | Schedules | Mutations return the full slot list |
GET /reports/start-stop + /summary | Running times | ≤31-day window, one 1,000-row page |
GET /reports/telemetry-log | Trend sparklines | Today, first page only, data_tier=auto |
POST /reports/generate · GET /reports/jobs/:id + /download | Export | CSV/XLSX via job polling, then native share sheet |
GET/POST /notifications… · GET/PUT /notification-preferences | Inbox | Cursor pagination; optimistic reads |
PUT /user-devices/devices | Push token | {fcm_token, fcm_platform} |
GET /pumps/operators | JE operator filter | Not implemented server-side — client returns [] |
Shared client-side error copy: "Check your connection and try again." (no response) · "Something went wrong. Please try again." (anything else) · server error.message rendered verbatim when present.
Known gaps & notes
| Item | Detail |
|---|---|
| Base URL | Hard-coded to staging; no build-time override or environment toggle yet |
| Scoping | Per-user pump scoping is not enforced server-side — operators currently see the whole tenant |
| Operator filter | UI exists (JE), backend endpoint missing; filter also applies client-side after pagination, which can shorten a page |
| Mode fallback | 88% of live rows have no event_triggered_by and read "· Manual" by fallback; Schedule/Auto triggers unobserved on staging |
| Schedule names | Backend never populates name — every tile reads "Untitled schedule" |
| Dark-mode tints | Status backgrounds (#E6F4EA/#FFF4DE/#FCE8E8) and the login error banner have no dark variants |
| Search clear button | The ✕ suffix only appears after an incidental rebuild (onChanged doesn't setState) |
| Fixture data | FixturePumpRepository (109 seeded pumps, "Tw 1–6") is test-only — not wired into the running app |
| Token storage | SharedPreferences, not platform secure storage |
| Undesigned v5 items | Operator name on detail, role-scoped notification history, JE list shape (docs/prd-vs-design-discrepancies.md §3, §6.1–6.3) |