Case study · 2025 – 2026 · Personal project

AppFullSport: a full-stack fitness app with a local LLM coach, React Native, FastAPI and PostgreSQL

A mobile fitness app with workout tracking, nutrition, a social feed and an AI coach — where the language model runs locally on a CPU-only server, and where the hardest bug lived in Kotlin code I never wrote.

React NativeExpoFastAPIPostgreSQLOllamaDockernginxHealth Connect
Role
Sole developer — mobile app, API, admin dashboard, AI, deployment
Stack
Expo 54 · React Native 0.81 · FastAPI · SQLAlchemy 2 + Alembic · PostgreSQL 16 · Ollama · nginx
Scale
104 API routes · 23 database tables · 29 screens · 83 seeded exercises · EN / FR / AR
Status
Backend running on a self-managed VPS; app used through Expo Go / EAS builds — not on the stores yet

What it does

Architecture

Four Docker Compose services, all with healthchecks. Only nginx is reachable from outside; the API, the database and the LLM runner are reachable on the internal network only. The entrypoint waits for the database, runs alembic upgrade head, and seeds the exercise catalog idempotently on every boot.

Running an LLM coach without a GPU

The text model is qwen2.5 7B-instruct in Ollama, on CPU. That is slow, and the design accepts it rather than hiding it:

Food photos go the other way: they are always proxied through the backend to a hosted vision model, so the API key never ships inside the app.

The bug that lived in someone else's Kotlin

On a Samsung A10, opening the steps screen killed the app with an UninitializedPropertyAccessException — inside the Health Connect library, below anything JavaScript can catch. In order, I tried: a safe-mode diagnostic build, gating every call on the SDK status, and removing the library entirely.

The actual cause: the library expects its permission delegate to be registered in the activity's onCreate, and in an Expo app you do not own that file. The fix is a custom Expo config plugin that patches MainActivity.kt at prebuild time to call HealthConnectPermissionDelegate.setPermissionDelegate(this).

Then a second, quieter failure: requestPermission() returned an empty list and no error. The library's own plugin does not declare the android.permission.health.* entries in the manifest; eight of them had to be added by hand. Silent empty results are worse than crashes.

Smaller decisions I would make again

Security

A strict password policy (12+ characters, four classes, a blocklist, and it may not contain your name or email), account lockout after 5 failures, bcrypt, short reset codes from secrets with a 15-minute expiry, per-route rate limits in the API (register 5/min, login 10/min, forgot-password 3/min) and limit_req zones in nginx (2 r/s on the AI routes), a restricted CORS list, CSP and the usual security headers, a non-root backend container, and a global exception handler that never leaks internals.

What is not done

I would rather list it than have you find it: there is no real email delivery yet (reset codes are logged server-side), the subscription screen is a placeholder, there are no push notifications, some screens are not translated, and there is no automated test suite — this project predates the habit I built on the ones that followed it. TLS is configured and ready but waits for a domain name.

Keep reading