Scale Sense — Mobile App (React Native)¶
Tech stack¶
- Framework: React Native (Expo managed workflow)
- Navigation: React Navigation v6 (bottom tabs + stack)
- State: Zustand (server state + BLE state, kept separate)
- Data fetching: React Query (REST API, shared with dashboard via api-client)
- BLE: react-native-ble-plx or expo-bluetooth
- File sharing: expo-sharing + expo-file-system
- Push notifications: expo-notifications
- Auth: expo-auth-session + Cognito
- Types: shared from
packages/api-client
Architecture decision: Option A¶
Phone app (React Native) = maintenance UX, BLE interface, alerts.
Web dashboard (React) = reports, settings, device management.
Shared: packages/api-client — TypeScript types + API client + WebSocket manager.
Do NOT attempt React Native Web for dashboard — separate codebases.
State separation¶
// Server state (React Query — fetched from REST API)
// Cached, background-refreshed, shared with dashboard logic
// BLE state (Zustand — in-memory only, never persisted)
type BLEStore = {
nearby: BLEDevice[] // discovered scales
connected: BLEDevice | null // currently connected scale
live_weight: number | null // streaming lbs value
maintenance: MaintenanceSession | null
scanning: boolean
startScan: () => void
stopScan: () => void
connect: (device_id: string) => Promise<void>
disconnect: () => void
sendCommand: (cmd: string) => Promise<void>
}
// BLE state cleared on disconnect — never leaks between sessions
BLE flow¶
Advertising (always running on ESP32)¶
ESP32 advertises with service UUID 4FAFC201-... and device name SCALE-0042.
Phone scans passively — no pairing required.
Sort nearby list by RSSI (strongest signal first = physically closest).
Connect sequence¶
- Phone taps scale in nearby list
react-native-ble-plxconnects to peripheral- Phone reads device info characteristic (
BEB5483E-...-26AA) → gets JSON:{ id, beer, level, bat, fw, tare } - Phone writes
"identify"to command characteristic (BEB5483E-...-26A9) → scale flashes LED 3× - App shows identify confirm modal:
- Scale details (beer, level, battery, firmware)
- "Yes, this is the right scale" → enter maintenance flow
- "Flash again" → re-send identify command
- "Wrong scale" → disconnect
- On confirm: enter maintenance mode (auto-triggered by BLE connect on ESP32 side)
Live weight stream¶
Subscribe to weight notify characteristic (BEB5483E-...-26A8).
ESP32 notifies at 5Hz (every 200ms) during maintenance mode.
Parse JSON: { lbs: 87.3 }
Update ble_store.live_weight → displays in maintenance UI.
Command write¶
async function sendBLECommand(cmd: string) {
await bleDevice.writeCharacteristicWithResponseForService(
SERVICE_UUID,
COMMAND_CHAR_UUID,
Buffer.from(cmd).toString('base64') // base64 encode
)
}
// Commands:
// "identify"
// "tare_empty"
// "tare_full"
// "maint_on"
// "maint_off"
// "cal:10.0" (calibrate with 10.0 lbs known weight)
Disconnect¶
BLE disconnect → ESP32 auto-exits maintenance mode. App clears BLE state, shows reconnect prompt if in maintenance flow.
Maintenance session state machine¶
type MaintenanceSession = {
tap_id: string
scale_id: string
mode: "tare" | "calibrate" | "diagnostics"
source: "physical" | "virtual" | "ble"
entered_at: Date
auto_exit_secs: number // countdown from 300
// Tare
tare_empty_done: boolean
tare_full_done: boolean
// Calibrate
known_weight: number | null
zero_raw: number | null
loaded_raw: number | null
new_scale_factor: number | null
drift_pct: number | null
// Commands
pending_command: string | null
last_response: CommandResponse | null
}
Session persists across screen navigation (Zustand). On session end: sync tare/calibration values to REST API. Auto-exit: countdown ticks every second, exit at 0.
Tare guided flow (4 steps)¶
Step 1: Enter maintenance (auto on BLE connect)
Step 2: Remove all weight → confirm → send "tare_empty" → await response
Step 3: Place full keg → select keg type → confirm → send "tare_full" → await response
Step 4: Complete → sync to API → exit maintenance
Live weight displays throughout. "Stable" indicator when reading variance < 0.1 lbs.
Calibration guided flow (4 steps)¶
Step 1: Enter maintenance (auto on BLE connect)
Step 2: Remove all weight → confirm empty (records zero baseline)
Step 3: Place known weight → enter lbs in input → confirm → send "cal:<weight>"
Step 4: View result → drift % displayed → accept or re-calibrate → sync to API
Identify confirm modal¶
Shown after every BLE connect, before any maintenance flow. Three actions: - "Yes, this is the right scale" → proceed to maintenance - "Flash again" → resend identify BLE command - "Wrong scale — go back" → disconnect, return to nearby list
Profile setting "Identify on connect" can disable this for expert users.
Screen map¶
Bottom tabs:
Taps → TapListScreen
Nearby → BLENearbyScreen
Alerts → AlertsScreen
Profile → ProfileScreen
Tap stack (from TapListScreen):
TapDetailScreen
MaintenanceScreen (tare / calibrate / diagnostics)
BeerAssignScreen
BLE stack (from BLENearbyScreen):
BLENearbyScreen
IdentifyConfirmModal
→ MaintenanceScreen (same as above)
Tap list screen¶
- Alerts section at top (only if active alerts exist)
- All taps section below
- Card states: normal / warning / critical / maintenance / offline / tare-required
- Tap card → TapDetailScreen
- ⊕ button in header → BLENearbyScreen
Tap detail screen¶
- Large % display + progress bar
- Info rows: beer, keg, pour rate, tapped, battery, last read
- Actions: Swap keg (→ maintenance), History (chart), Report (immediate)
- Alert banner if tap has active alert
- Critical state: percentage in red, "Order now" sub-label
BLE nearby screen¶
Scale list items have three visual states: - Idle: gray LED indicator - Connecting: amber pulsing LED indicator - Identified: green pulsing LED indicator + "LED flashing" tag
Sorted by RSSI (strongest first). Tare-required scales show amber subtitle.
Alerts screen¶
Filter tabs: All / Active / Resolved / Low keg / Device Alert items: left border color (red=critical, amber=warning, green=resolved, gray=offline) Per-alert actions: Swap keg, Acknowledge, History "Ack all" header button.
Profile screen¶
- User info (name, email, role badge)
- Location switcher
- Preferences:
- Push notifications toggle
- Critical alerts only toggle
- Quiet hours display
- BLE auto-connect toggle
- Identify on connect toggle
- Level display: Gallons or Percent (stored per user in Cognito custom attribute)
- App version
- Support link
- Sign out
Beer library cache¶
Stored in AsyncStorage. Search searches cache first, falls back to API if not found. Refresh on app foreground if TTL expired.
Push notifications¶
Token registration: expo-notifications → SNS endpoint ARN → stored in DEVICE#user-xxx / PUSH_TOKEN
Cognito user attribute: custom:sns_endpoint_arn
On notification tap: deep link to relevant tap or alert screen.
Notification payload includes tap_id and loc_id for routing.
Export (phone)¶
async function exportInventory(format: "pdf" | "csv") {
const { url } = await api.locations.exportInventory(locId, format)
const localUri = await FileSystem.downloadAsync(
url,
FileSystem.cacheDirectory + `inventory-${Date.now()}.${format}`
)
await Sharing.shareAsync(localUri.uri)
// → iOS share sheet / Android intent → AirDrop, email, Files, etc.
}
Key UX decisions¶
BLE + HTTPS simultaneously¶
During maintenance: BLE streams live weight, HTTPS serves beer/keg data. Phone assembles both into one maintenance view. BLE weight takes precedence over API state during active stream.
No pairing required¶
BLE uses service UUID whitelist — connects directly to Scale Sense devices. No OS-level Bluetooth pairing. Faster, no "Forget this device" issues.
Identify before every action¶
Even if user opened app from a notification about Tap 7, they must confirm the physical scale before tare/calibrate. Prevents wrong-scale mistakes in walk-in coolers with many identical-looking units.
Maintenance exits cleanly on disconnect¶
If BLE drops during maintenance (cooler door closed, phone too far): - App shows reconnect prompt with last known state - Scale auto-exits maintenance after 5-min timeout - Scale returns to normal operation (modem sleep, 60s reporting) - App can reconnect and resume if needed
Offline mode¶
Tap list shows cached data with stale indicators when no network. BLE maintenance works fully offline (direct to scale). Push notifications work via APNS/FCM regardless of app state.