Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
41 changes: 41 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files

# dependencies
node_modules/

# Expo
.expo/
dist/
web-build/
expo-env.d.ts

# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision

# Metro
.metro-health-check*

# debug
npm-debug.*
yarn-debug.*
yarn-error.*

# macOS
.DS_Store
*.pem

# local env files
.env*.local

# typescript
*.tsbuildinfo

# generated native folders
/ios
/android
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v24
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Expo HAS CHANGED

Read the exact versioned docs at https://docs.expo.dev/versions/v54.0.0/ before writing any code.

This project targets **Expo SDK 54** (pinned to match the Expo Go version available on the maintainer's device). Do not upgrade the SDK without confirming the target Expo Go / development-build story first.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
165 changes: 163 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,163 @@
# openflight-mobile
Mobile companion app for openflight
# OpenFlight Mobile

An [Expo](https://expo.dev/) (React Native) companion app for OpenFlight. It
connects to the OpenFlight server over Socket.IO and mirrors the web UI's live
shot data. The goal (see [ROADMAP.md](ROADMAP.md)) is a **complete standalone
interface** so a builder can run the Pi headless and skip the kiosk touchscreen.

The app lives in its own repo and is **self-contained** — it has no build-time
dependency on the server. The `Shot` type and socket event names are
deliberately mirrored from the Python wire contract in
[open-flight/openflight](https://github.com/open-flight/openflight) (`src/openflight/server.py`'s `shot_to_dict()` and
its SocketIO events) rather than shared with the web `ui/`.

## Prerequisites

- **Node.js** — the version pinned in this repo's `.node-version` (currently
**v24**), and npm.
- **Expo Go on your phone** — the app targets **Expo SDK 54**. Each Expo Go
build supports exactly one SDK version and it must match the project's, so you
need an **SDK 54** build specifically; a newer Expo Go rejects the app with
*"Project is incompatible with this version of Expo Go."* Getting the right
build differs by platform — see [Get an SDK 54 Expo Go](#get-an-sdk-54-expo-go)
and Expo's [version-mismatch troubleshooting guide](https://docs.expo.dev/troubleshooting/expo-go-version-mismatch/).
**Do not upgrade the Expo SDK** without confirming the Expo Go / dev-build
story first — the SDK is pinned to match the maintainer's Expo Go (see
[AGENTS.md](AGENTS.md)).
- **A running OpenFlight server** to connect to (see
[Connecting to the server](#connecting-to-the-server)).
- Your **phone and computer on the same Wi-Fi / LAN**.

## Install (first time only)

```bash
npm install
```

## Run the dev server

Once dependencies are installed:

```bash
npm start # equivalently: npx expo start
```

This just starts Metro (it doesn't reinstall). It launches the bundler and
prints a **QR code** in the terminal.

## Testing on a phone with Expo Go

### Get an SDK 54 Expo Go

Each Expo Go build supports exactly one SDK version, and neither store lets you
pick one — so where an SDK 54 build comes from depends on your platform. The two
stores have diverged: the App Store copy is frozen at SDK 54, while the Play
Store tracks the latest SDK.

| Target | How to get an SDK 54 Expo Go |
| --- | --- |
| **iPhone (physical)** | Install from the App Store — it's [capped at **SDK 54**](https://docs.expo.dev/troubleshooting/expo-go-version-mismatch/) because SDK 55+ was never approved by Apple, so it already matches this project. (Needing a *newer* Expo Go later is the awkward case, not this one: [sign.expo.dev](https://sign.expo.dev/) re-signs a build with a free Apple ID but the certificate lapses after ~7 days, and `npx eas-cli@latest go` ships one via TestFlight but needs a paid Developer Program membership.) |
| **Android (device or emulator)** | The Play Store serves the latest (~SDK 57), which **won't** run the app, and it has no version picker. Sideload the SDK 54 build instead — see below. |
| **iOS Simulator** | Download the SDK 54 build from [expo.dev/go](https://expo.dev/go). |

On **Android**, get the SDK 54 build one of two ways:

```sh
npx expo-go download android 54 # downloads the SDK 54 Expo Go, cached in ~/.expo
```

or pick **SDK 54** + your target at [expo.dev/go](https://expo.dev/go) and
install the APK (you'll enable "install unknown apps"). The sideloaded build
replaces the Play Store Expo Go — one Expo Go SDK per device at a time.

> For anything beyond quick local testing, use a
> [development build](https://docs.expo.dev/develop/development-builds/introduction/)
> instead: a binary compiled for our exact SDK, independent of whatever Expo Go
> version the stores ship.

### Run it

1. Install an **SDK 54** Expo Go (see above).
2. Make sure your **phone and dev machine are on the same Wi-Fi network**.
3. Start the dev server: `npm start`.
4. Scan the QR code:
- **iOS** — open the built-in **Camera** app and point it at the QR; tap the
Expo banner.
- **Android** — open **Expo Go** and use its **Scan QR code** option.
5. The app downloads the JS bundle from Metro and opens on your phone. Saving a
file hot-reloads it.

### If the QR / LAN connection fails

Some networks (guest Wi-Fi, corporate APs with client isolation) block the
phone from reaching Metro on your machine. Use a tunnel instead:

```bash
npx expo start --tunnel
```

Tunnel mode routes through Expo's servers (slower, needs `@expo/ngrok`) but
works across network boundaries.

### After changing `babel.config.js` or the entry point

Metro caches aggressively. Clear it so changes take effect:

```bash
npx expo start -c
```

### Simulator / emulator (optional)

If you have Xcode or Android Studio set up, you can skip the phone:

```bash
npm run ios # iOS Simulator
npm run android # Android emulator
```

## Connecting to the server

The app talks to the OpenFlight Python server's Socket.IO endpoint on **port
8080**.

1. Start a server for the app to reach. The server lives in
[open-flight/openflight](https://github.com/open-flight/openflight). For development without hardware, run the mock
server from a checkout of that repo:

```bash
make dev # scripts/start-kiosk.sh --mock
```

Or run the real server on the Pi (`make start`).

2. Find the server machine's LAN IP:

```bash
ipconfig getifaddr en0 # macOS
hostname -I # Linux / Raspberry Pi
```

3. In the app's connection screen, enter `http://<server-ip>:8080` (e.g.
`http://192.168.1.100:8080`). A successful connection is remembered for next
launch. The default guess is `http://192.168.1.100:8080` — change it to your
server's address.

The phone and the server must be on the same LAN. There is a **Simulate**
action to generate a fake shot end-to-end once connected.

## Tests

```bash
npm test # Jest (jest-expo)
npm run test:watch
```

## Troubleshooting

| Symptom | Fix |
| --- | --- |
| "Project is incompatible with this version of Expo Go" | Your Expo Go isn't SDK 54. Install an SDK 54 build (see [Get an SDK 54 Expo Go](#get-an-sdk-54-expo-go)); don't bump the SDK (see [AGENTS.md](AGENTS.md)). |
| App loads but can't connect to the server | Confirm phone + server share the LAN, the server is running on port 8080, the IP is correct, and no firewall blocks 8080. |
| QR scan does nothing / times out | Use `npx expo start --tunnel`. |
| Stale code after editing babel/entry config | `npx expo start -c` to clear the Metro cache. |
135 changes: 135 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Mobile App Roadmap — Touchscreen-Optional, Phone-Capable-as-Sole-Interface

The goal is to bring the Expo mobile app to a sensible subset of web-UI parity, and
specifically to make it a **complete standalone interface** so a builder can skip the
kiosk touchscreen for cost/space reasons and run the Pi headless.

The mobile app stays **self-contained** in this repo — no workspaces, and the `Shot`
type / socket event names are deliberately mirrored from the Python wire contract
rather than shared with the web `ui/`. The real source of truth is
`src/openflight/server.py`'s `shot_to_dict()` payload and its SocketIO events in
[open-flight/openflight](https://github.com/open-flight/openflight).

## Locked decisions

- **Scope:** Tier 1 (core golfer parity) + a foundation phase, plus the three
sole-interface items (graceful shutdown, connection bootstrapping, device/status view).
- **Control:** **Full control** — a sole interface must do everything the touchscreen
could. Destructive actions (**delete shot, clear session, shutdown**) are guarded by
**confirm dialogs**; the footgun is UI-level, not a network-trust problem.
- **Navigation:** **Bottom tab bar** (Live / Shots / Stats / Device) via **expo-router**
(the Expo SDK 54 default, file-based routing).
- **Topology:** one app works in all three setups — phone-only (headless Pi),
phone + kiosk touchscreen, and phone + passive `/display` monitor. Same server, same app.

## Non-goals (explicitly deferred)

Camera feed, radar-config editing, trigger-diagnostic deep dive, Launch Daddy easter egg,
sim-shot badges, swing-speed mode (Tier 2), and the `/display` TV route (a web concern).
None are needed for a complete headless golfer experience; each can be added later without
rework.

---

## Phase 0 — Foundation

**Goal:** restructure so features are cheap to add. Little new user-facing behavior.

Today the app puts socket logic, connection state, and shot state inline in `App.tsx`
with `useState`. That won't scale to multiple tabs sharing session state. Mirror the web
app's proven split (a singleton socket service + a store), kept self-contained here.

| Work item | Detail | Files |
|---|---|---|
| Navigation shell | Bottom tabs via expo-router (Live / Shots / Stats / Device). | new `app/` (expo-router) |
| Socket service | Extract inline socket logic into a singleton mirroring the web UI's `src/services/socketService.ts` — one place mapping every server event → store. DRY within mobile. | `services/socket.ts` |
| State store | A store shared across tabs (zustand works in RN; or reducer + context). Holds shots, connection, session-derived flags. | `stores/` |
| Connection persistence | Persist server URL via AsyncStorage; default to AP fixed IP `192.168.4.1:8080` with `192.168.1.100` as a fallback hint; auto-reconnect with backoff. | socket service + store |
| Wire-contract expansion | Grow `types.ts` to cover later-phase events (`session_state` extras, `shot_processing`, `session_cleared`, `club_changed`, `player_changed`, `trigger_status`, `power_status`). | `types.ts` |
| Test infra | There are zero tests in the app today. Stand up `jest-expo` + `@testing-library/react-native`. Prerequisite, not optional. | repo config |

**Test story:** the socket service's event→state transitions are pure and highly testable;
cover connect/disconnect/reconnect and each event handler. **Size: M.** Risk: low, but
load-bearing.

---

## Phase 1 — Core golfer parity (Tier 1)

**Goal:** the phone is a genuinely useful launch-monitor client.

| # | Feature | Emits / consumes | Notes |
|---|---|---|---|
| 1 | Shot history list + delete | `delete_shot` → `session_cleared`/`shot` | New Shots tab. Delete behind a confirm. |
| 2 | Session stats + clear | `clear_session` → `session_cleared` | New Stats tab. Port the web aggregates. Clear behind a confirm. |
| 3 | Club selection + on-connect prompt | `set_club` / `club_changed` | Mirror the web club-select screen on first connect; reflect server-pushed club changes. |
| 4 | Player selection | `set_player` / `player_changed` | |
| 5 | Unit toggle (imperial/metric) | client-side, persisted | The gauge/tiles hardcode mph/yds today — thread a unit through `CurrentShotView`. |
| 6 | Live polish | `shot_processing` | Show capturing/calculating states + a shot-arrival flash. Makes "waiting for a shot" feel alive. |

**Test story:** unit conversion (pure, table-tested hard), stats aggregation (pure), and
reducer handling of delete/clear/club/player. Component test for club-select-on-connect.
**Size: L.** Risk: low; items are independent and land incrementally.

---

## Phase 2 — Sole-interface completeness

**Goal:** a no-touchscreen build is fully operable and diagnosable from the phone.

| # | Feature | Mechanism | Notes |
|---|---|---|---|
| 1 | Graceful shutdown | `POST /api/shutdown` (already exists) | Confirm dialog → pending/success/error, mirroring web `ShutdownDialog`. Prevents yanking power on a live Pi (SD-card corruption risk). |
| 2 | Device/Status view | `trigger_status`, `radar_config` (read-only), `power_status` | New Device tab: connection health, radar/trigger status, battery if present. The troubleshooting lifeline when there's no screen. |
| 3 | Connection bootstrapping | mDNS discovery and/or AP default | "Just tap Connect" without reading an IP off a screen you removed. |

**Test story:** shutdown state machine (confirm→pending→success/error) as a component
test; status view across present/absent hardware; discovery logic mockable. **Size: M.**
Risk: mDNS on RN can be fiddly (may need a dev-build native module, not pure Expo Go) —
ship AP-default first, treat mDNS as a stretch. Confirm the dev-build story against the
Expo SDK 54 / Expo Go constraint in `AGENTS.md` before adding native deps.

---

## Phase 3 — Deployment track (ops, parallel — not app code)

**Goal:** make the headless + AP topology real on the Pi. Independent of the app.

| Work item | Detail |
|---|---|
| Pi access-point setup | `hostapd` + `dnsmasq` setup script alongside `scripts/setup/` in [open-flight/openflight](https://github.com/open-flight/openflight). Fixed AP IP the app defaults to. Optional — a builder opts in. |
| Headless start | A flag/variant of `start-kiosk.sh` (server repo) that runs the server without launching Chromium (the browser step already no-ops without a display; make it intentional + documented). |

**Size: S–M.** Risk: low; fully decoupled from the mobile phases.

---

## Sequencing & dependencies

```
Phase 0 (foundation) ──► Phase 1 (parity) ──► Phase 2 (sole-interface)

Phase 3 (Pi AP + headless) ── independent, any time ──┘
```

Phase 0 gates everything. Phases 1 and 2 are each internally incremental (ship
item-by-item). Phase 3 is parallelizable.

## Cross-cutting principles

- **DRY within this app**, but not across the web `ui/` ↔ mobile — the duplicated `Shot` type and
event names are deliberate; keep them mirrored, not shared.
- **Confirm dialogs** on all three destructive actions (delete / clear / shutdown).
- **Tests land with each feature**, not after; Phase 0 exists partly to make that possible.
- **Explicit over clever:** a plain socket-service + store, mirroring the web app's
already-proven shape.

## Server-side contract reference

Client → server emits used by this roadmap: `get_session`, `set_club`, `set_player`,
`delete_shot`, `clear_session`, `simulate_shot`, `get_trigger_status`, `get_radar_config`;
plus `POST /api/shutdown`.

Server → client events consumed: `session_state`, `shot`, `shot_processing`,
`session_cleared`, `club_changed`, `player_changed`, `trigger_status`, `radar_config`,
`power_status`.
33 changes: 33 additions & 0 deletions __tests__/connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { DEFAULT_SERVER_URL, loadServerUrl, saveServerUrl } from '../storage/connection';

// Use the official in-memory mock shipped with async-storage.
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);

beforeEach(async () => {
await AsyncStorage.clear();
jest.clearAllMocks();
});

describe('connection storage', () => {
it('returns the default URL when nothing is saved', async () => {
await expect(loadServerUrl()).resolves.toBe(DEFAULT_SERVER_URL);
});

it('round-trips a saved URL', async () => {
await saveServerUrl('http://10.0.0.5:8080');
await expect(loadServerUrl()).resolves.toBe('http://10.0.0.5:8080');
});

it('falls back to the default if the read throws', async () => {
(AsyncStorage.getItem as jest.Mock).mockRejectedValueOnce(new Error('storage unavailable'));
await expect(loadServerUrl()).resolves.toBe(DEFAULT_SERVER_URL);
});

it('never throws if the write fails', async () => {
(AsyncStorage.setItem as jest.Mock).mockRejectedValueOnce(new Error('disk full'));
await expect(saveServerUrl('http://10.0.0.5:8080')).resolves.toBeUndefined();
});
});
Loading