Skip to content

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

  1. Phone taps scale in nearby list
  2. react-native-ble-plx connects to peripheral
  3. Phone reads device info characteristic (BEB5483E-...-26AA) → gets JSON: { id, beer, level, bat, fw, tare }
  4. Phone writes "identify" to command characteristic (BEB5483E-...-26A9) → scale flashes LED 3×
  5. App shows identify confirm modal:
  6. Scale details (beer, level, battery, firmware)
  7. "Yes, this is the right scale" → enter maintenance flow
  8. "Flash again" → re-send identify command
  9. "Wrong scale" → disconnect
  10. 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

type BeerLibraryCache = {
  beers:      Beer[]
  fetched_at: string
  ttl_hours:  24
}

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.