From Flask Monolith to Multiplatform App: Migration Journey Part 4
Milestones 0–9: Going Mobile with Expo
Follow-up to Journey Part 3. The whole Expo mobile app, from an empty folder to a real build running on a real iPhone.
Table of Contents
- What we're building
- Tech stack at a glance
- M0: Scaffolding
- M1: Auth — Supabase, PKCE, and a Keychain size limit
- M2: The API client and a very misleading 401
- M3–M4: Core workouts, templates & library
- M5–M6: Social, profile & two native bugs
- M7: Polish — refresh, dark mode, i18n, skeletons
- M8: Testing infrastructure
- M9 part 1: Build config, icon design, secrets
- M9 part 2: Shipping to a real iPhone
- This session: retiring the wheel picker
- Bugs that bit us
- Files changed across all milestones
1. What we're building
Part 3 left the Next.js web app at full feature parity with the old Jinja2 monolith. But most people don't track workouts from a laptop — they track them from their phone, mid-gym-session. That's the whole reason Phase 3 exists: a real Expo (React Native) app, consuming the exact same Flask REST API the web app already uses, authenticating against the same Supabase project.
The mobile app was built in nine milestones, M0 through M9, going from "empty folder" to "installed and logged in on a real iPhone." This entry covers all nine, plus a UI rework done in the current session, after the first real build had already shipped.
Learn more: Expo documentation
2. Tech stack at a glance
The stack deliberately mirrors the already-validated web stack, so patterns and knowledge transfer directly instead of being re-learned from scratch.
| Technology | Role | Learn more |
|---|---|---|
| Expo (managed) + TypeScript | App framework, fast iteration, OTA updates | Expo |
| Expo Router | File-based routing, same mental model as Next.js App Router | Expo Router |
@supabase/supabase-js + expo-secure-store | Auth, secure on-device token storage | Supabase Auth |
| TanStack Query | Server state — same library, same caching model as web | TanStack Query |
| react-hook-form | Form state + validation, same patterns as web | react-hook-form |
| NativeWind | Tailwind for React Native — reuses web's utility classes | NativeWind |
api.ts (ported) | Same get/post/put/delete/upload shape as web, EXPO_PUBLIC_API_URL instead of NEXT_PUBLIC_API_URL | — |
3. M0: Scaffolding
mobile/ was created with create-expo-app on SDK 57. The SDK 57
template has a different layout than older Expo templates — source lives
under mobile/src/ (src/app/ for routes, src/components/,
src/constants/, src/hooks/), not directly under mobile/app/.
expo-router and expo-linking came pre-included; so did
react-native-reanimated and react-native-safe-area-context. Installed
by hand: expo-secure-store, @supabase/supabase-js,
@tanstack/react-query, react-hook-form, nativewind + tailwindcss.
NativeWind needed babel.config.js and metro.config.js, neither of
which exists by default — both generated via npx expo customize.
Two decisions worth knowing about for later sessions:
Native UI vs. NativeWind. SDK 57's template also pulls in @expo/ui,
expo-glass-effect, and expo-symbols — Expo's newer bindings for native
platform UI (SwiftUI/Jetpack Compose, iOS Liquid Glass). We considered
switching the whole UI layer to these instead of NativeWind, but decided
to stay with NativeWind for parity with web's Tailwind classes, and
because @expo/ui was too thin for the app's more complex custom screens
(the workout-logging form, specifically). @expo/ui remains an option
later for isolated native-feeling accents — a tab bar, some icons —
without owning the whole UI.
Expo Go doesn't work for this project. Expo Go only ever supports the
single latest-released SDK, and separately this project depends on native
modules Expo Go doesn't bundle (expo-secure-store, and eventually
@expo/ui/expo-glass-effect). The fix is a development build
instead: npx expo run:ios (needs Xcode plus an iOS Simulator runtime).
Once the dev client is installed, day-to-day iteration goes back to plain
npx expo start with fast refresh — only native-dependency or
Babel/Metro-config changes require re-running expo run:ios.
Lesson learned: the stock template also ships custom theming components (
ThemedText,ThemedView) that do not forward aclassNameprop — they're a separate theming mechanism, unrelated to NativeWind. Worth knowing before assuming any component accepts Tailwind classes just because the rest of the app does.
4. M1: Auth — Supabase, PKCE, and a Keychain size limit
Auth needed to work identically to web — same Supabase project, same JWTs sent to the same Flask API — but the storage and redirect mechanics are entirely different on native.
A platform-aware storage adapter
The Supabase client (src/lib/supabase/client.ts) uses a storage adapter
that picks expo-secure-store on native and localStorage on web,
guarded for the Node SSR case (since app.json's web.output: "static"
makes the dev server also try to prerender routes in plain Node, where
neither storage API exists). flowType: "pkce" is set explicitly.
PKCE needs WebCrypto — Hermes doesn't have it
Hermes (React Native's JS engine) has no crypto.subtle, so Supabase's
PKCE implementation silently fell back to the weaker plain
code-challenge method — no error, no warning, just a quietly less secure
auth flow. Fixed with react-native-quick-crypto +
react-native-nitro-modules, with the polyfill installed at the very top
of src/app/_layout.tsx, before anything else runs.
Lesson learned: this is a native module. Adding it needs
npx expo run:iosafterward — a plain Metro restart isn't enough to pick up new native code.
The deep-link redirect and the recovery-session guard trick
app.json's scheme: "mobile" (auto-generated, never renamed) is what
mobile://reset-password resolves against — the same URL has to be
allow-listed in the Supabase dashboard under Authentication → URL
Configuration → Redirect URLs.
Exchanging the deep link's code for a session via
exchangeCodeForSession creates a real session. Without extra
handling, the root layout's Stack.Protected guard (a declarative route
guard, not a hand-rolled useEffect/useSegments redirect) would see
!!session and yank the user straight into the authenticated app — before
they've actually set a new password. The fix: watch for the
PASSWORD_RECOVERY event from onAuthStateChange, and add
&& !passwordRecovery / || passwordRecovery to the two guards. The
reset-password screen calls clearPasswordRecovery() after a successful
updateUser({ password }), letting the guard proceed normally.
Pattern name: this is a state machine disguised as a boolean guard —
!!sessionalone isn't enough to answer "is this user allowed into the app," because a session can exist in an intermediate state (mid-password-reset) that shouldn't count as "logged in" for routing purposes.
Email-confirmation deep links were deliberately deferred, not blocked —
the exact same exchangeCodeForSession handling already works for it,
it just needs options: { emailRedirectTo: Linking.createURL("/") }
added to the signUp() call and the URL allow-listed the same way.
Learn more: Expo Router — Stack.Protected, Supabase Auth — PKCE flow
5. M2: The API client and a very misleading 401
Porting the client
src/lib/api.ts ports web's get/post/put/delete/upload shape
near-verbatim, reading EXPO_PUBLIC_API_URL and importing the supabase
singleton directly. types/index.ts is a verbatim copy of web's types —
no changes needed, since both clients talk to the same API.
LargeSecureStore — the Keychain has a size limit
Not part of the original milestone plan, but a genuine prerequisite
discovered along the way: iOS Keychain caps individual
expo-secure-store values around 2048 bytes, and Supabase's persisted
session blob can exceed that. The fix — src/lib/supabase/ largeSecureStore.ts — encrypts the session with a per-key AES key (via
aes-js, using crypto.getRandomValues from the PKCE polyfill above, so
no extra RNG dependency was needed), stores the ciphertext in
@react-native-async-storage/async-storage (no practical size limit),
and keeps only the small AES key in SecureStore.
Pattern name: envelope encryption — a small, size-constrained secure store holds the key, while a larger, less-trusted store holds the (now-encrypted) bulk data.
The debugging saga: a 401 that wasn't about tokens at all
One specific Supabase-authenticated test account
(ruechan.sixt@proton.me) got a consistent 401 "Authentication required"
from /api/v1/auth/profile, while a different account worked fine
against the identical code path. Every obvious hypothesis was tested and
ruled out, one by one: token expiry, SecureStore truncation, a
PKCE/algorithm mismatch, JWKS key rotation, even clipboard transcription
corruption from copy-pasting a token.
The actual cause was in backend/project/api/auth_utils.py. Its
_get_or_create_user auto-provisioning step was colliding with a
legacy Flask-Login account — same email, a real password_hash, no
supabase_uid — already sitting in the local SQLite dev database.
The collision's IntegrityError was being silently swallowed with zero
logging, which made the failure look exactly like a JWT verification
problem instead of a database uniqueness conflict.
A compounding factor made this worse: debugging happened against the
wrong database for a while. Supabase's Postgres "production" branch was
being inspected in the dashboard, while the running flask run process
was actually reading local SQLite the whole time — DATABASE_URL was
unset in backend/.env, silently falling back to SQLite per
config.py's documented default.
Fixes: the legacy row's supabase_uid was linked to the correct Supabase
UID, and _get_or_create_user's except IntegrityError now logs when
the fallback re-query also comes up empty — previously fully silent.
Lesson learned: if a Supabase-authenticated request gets a generic 401 from the Flask API despite the JWT looking valid, check for a pre-existing legacy user row with the same email before assuming a token or crypto problem — and confirm which database the running Flask process actually points at before trusting anything seen in a cloud dashboard.
6. M3–M4: Core workouts, templates & library
WorkoutForm — the same nested field arrays, a new dropdown problem
WorkoutForm (src/components/workout/WorkoutForm.tsx) ports the nested
exercise/set field-array logic from web's workout-exercise-form.tsx
faithfully — including the mm:ss ⇄ seconds conversion and the
useWatch-driven progression-levels/reps-vs-duration conditional
rendering covered in Part 3. The one real platform swap: web's HTML
<select> became @react-native-picker/picker for the exercise and
progression dropdowns. It worked, and shipped through M3–M9 — but it's
also the subject of section 12, once real usage
surfaced a UX problem with it.
Delete confirmation uses React Native's built-in Alert.alert instead of
porting web's custom ConfirmDialog — simpler and more idiomatic than a
custom modal on this platform.
Nesting the tabs one level deeper
expo-router/unstable-native-tabs's NativeTabs only renders routes
explicitly declared as <NativeTabs.Trigger> — a plain sibling file like
workouts/new.tsx had nowhere to render. The fix restructured
navigation: (app)/(tabs)/_layout.tsx now holds just <AppTabs />, and
the outer (app)/_layout.tsx is a <Stack> hosting (tabs) plus
workouts/new, workouts/[id]/index, and workouts/[id]/edit as pushed
screens.
Lesson learned: every
Stack.Screenneeds a matching file, or Expo Router logs an "extraneous route" warning — add the file and the screen entry together, not one before the other.
M4: templates, exercises, categories
Since a template is just a Workout with is_template: true, the
template create/edit screens reuse WorkoutForm directly. "Start
workout" calls useUseTemplate and lands on the new workout's edit
screen. Navigation grew a third tab, (tabs)/library.tsx, a simple hub
linking to Categories/Exercises/Templates as pushed routes.
One deliberate UX deviation from web: category deletion asks for
Alert.alert confirmation on mobile, while web deletes immediately on
tap — touch targets are easier to mis-tap than a mouse click, so the
extra confirmation seemed worth the small inconsistency.
Learn more: Expo Router — Native tabs
M5: explore, follow, messages
The Explore tab shows real workout activity from other users. The user
profile screen adds follow/unfollow. Messages was promoted to a 4th
tab — "Send message" from a profile pre-fills and locks the recipient
via router.push({ pathname, params }), the typed-routes-safe way to
pass query params in Expo Router, versus web's string-concatenated ?to=.
The unread-message-count badge on the Messages tab icon was deliberately
skipped — unstable-native-tabs's badge API was experimental enough that
wiring it up wasn't worth a debugging detour for a polish item.
useUnreadMessageCount() was still built, just not wired to any UI yet.
M6: profile becomes the 5th tab
View/edit profile, password change, profile picture upload, sign out, and delete account all landed here, completing the originally-recommended five-tab structure: Workouts, Explore, Library, Messages, Profile.
Bug: expo-image-picker crashed the app outright
Not a soft failure — a hard crash. iOS requires
NSPhotoLibraryUsageDescription in Info.plist before it will even show
the permission prompt. The fix looked simple: add the plugin's config
form to app.json — ["expo-image-picker", { "photosPermission": "..." }].
Lesson learned: adding it to
app.jsonalone didn't work.npx expo run:iosonly does a full native "prebuild" (regeneratingInfo.plistfromapp.json) the first timeios/doesn't exist — once it exists, laterexpo run:ioscalls just rebuild the existing native project without resyncing config changes. Had to force it withnpx expo prebuild --clean. Worth remembering any time a plugin config change inapp.jsondoesn't seem to take effect.
Bug: FormData rejected the classic RN upload shape
FormData.append() rejected the historically-lenient RN file-upload
shape ({ uri, name, type }) with "Unsupported FormDataPart implementation" — this networking stack wants a real Blob, not that
object shape. Fixed with fetch(asset.uri) → .blob() →
formData.append("picture", blob, filename) before handing it to
api.upload(). If this class of error resurfaces elsewhere,
expo-file-system's uploadAsync() bypasses FormData/Blob entirely.
Learn more: Expo Image Picker, MDN — FormData
8. M7: Polish — refresh, dark mode, i18n, skeletons
M7 turned out to be four fairly independent pieces, tackled in priority order.
Pull-to-refresh
RefreshControl was added to all 6 list screens — essentially free,
since every list already used TanStack Query's refetch/isRefetching.
Along the way, a real gap was caught: Explore and Messages had
page/setPage state with no pagination UI ever wired to it, dropped by
oversight when porting from web back in M5. A Previous/Next footer was
added, matching the other lists.
Dark mode
25 files across Workouts, Templates, Exercises, Categories, Explore,
Messages, Profile, and the 4 auth screens got dark: variants, following
the same color-mapping convention web already uses. No
tailwind.config.js changes were needed — Tailwind's darkMode: "media"
default already follows the OS setting, matching how app.json's
userInterfaceStyle: "automatic" and the root layout's
useColorScheme()-driven ThemeProvider already behave.
i18n
web/src/i18n/translations/en.ts and de.ts were ported verbatim
(~220 flat keys, so mobile and web stay in sync by construction), along
with a LanguageProvider/useTranslation() adapted for RN:
AsyncStorage instead of localStorage, expo-localization's
Localization.getLocales()[0]?.languageCode instead of
navigator.language for initial system-locale detection. All 25 files
touched for dark mode got retrofitted with t() calls, plus the bottom
tab bar labels — easy to miss, since NativeTabs.Trigger.Label text
lives outside the normal screen component tree.
Lesson learned: a couple of screens initially reused the wrong web translation key by surface-level name match. Caught and corrected to either a literal string (no matching web key existed for that exact case) or the semantically-right key. Matching keys by string similarity instead of by meaning is an easy trap when porting i18n wholesale.
Skeleton loaders
Web's skeleton components were ported to
mobile/src/components/ui/skeleton.tsx, shaped to mobile's actual card
layouts (buttons wrap below content on mobile, versus web's side-by-side
desktop layout — not a straight visual port). The pulse animation uses
react-native-reanimated (withRepeat/withSequence/withTiming on
opacity), since NativeWind 4.2 has no built-in animate-pulse support
for React Native — confirmed by grepping node_modules before assuming
it would just work.
Product vision note, from the user: the app's social angle should center on the actual performance numbers, not just workout activity — e.g. congratulating an athlete for reaching 20 reps of push-ups in a single set. Reactions tied to specific numeric achievements (not just "workout posted") is the direction for future social features. This surfaced while restoring per-set reps/duration detail in the Explore feed's card, which had been oversimplified to plain set counts back in M5.
Bonus fix: the useToggleDone cache bug
Not originally part of M7, but caught during dark-mode testing: marking a workout done required a manual pull-to-refresh to show the toggled state. The real bug had two layered causes.
First, useWorkouts (the list query) caches { data: Workout[] }, but
useWorkout(id) (the single-item query, used on detail/edit screens)
caches { data: Workout } — under the same "workouts" key prefix.
The first optimistic-update attempt assumed every "workouts"-prefixed
cache entry was array-shaped and called .map() on it unconditionally —
which crashed silently whenever a single-workout entry was also in cache
(say, from a prior detail-page visit), disrupting the whole mutation
callback chain. Fixed by making the setQueriesData updater check
Array.isArray(old.data) and handle both shapes.
Second, a transient version of the same symptom traced to the list's
query simply not being in the cache yet at the exact moment of the tap —
a mount/fetch timing race, confirmed by dumping the entire QueryClient
cache mid-mutation, not reproducible once the tab had settled.
Lesson learned: when two React Query hooks share a key prefix but cache differently-shaped data, any cache-mutating code touching that prefix has to handle every shape actually stored under it — not just the shape the hook you're testing happens to use.
Learn more: TanStack Query — Optimistic updates
9. M8: Testing infrastructure
Testing used jest-expo + @testing-library/react-native v14 (RTL's new
async-render API — render/renderHook/fireEvent.press all return
Promises now) plus test-renderer, the React-19-compatible replacement
for the deprecated react-test-renderer that RTL v14 requires as a
separate peer package. npx expo install — not plain npm install —
picked the compatible versions automatically, notably jest@~29.7.0 and
not the latest jest@30, since jest-expo internally pins several
@jest/* packages to major 29.
Lesson learned: never add a custom
transformIgnorePatternsto the jest config here.jest-expo's preset already ships one coveringexpo-modules-coreand the rest of the RN ecosystem — overriding it instead of extending it breaksexpo-modules-core's own transform, failing every test suite with a cryptic "Cannot use import statement outside a module" error.
tsconfig.json needed an explicit "types": ["jest", "node"] — leaving
types unset should auto-include @types/jest per the TypeScript
handbook, but empirically did not in this project. Once types is set
explicitly, @types/node has to be listed too, or ambient types like
NodeJS.Timeout disappear.
npm test runs jest --forceExit deliberately, not as a band-aid.
TanStack Query schedules a gcTime (default 5 minutes) cleanup
setTimeout on every QueryClient the moment a query enters cache, and
that timer legitimately outlives a fast test — confirmed via
--detectOpenHandles, which pointed straight at Query.scheduleGc.
Setting gcTime: 0 to dodge this was tried and reverted: it garbage-
collects the query before the test's own waitFor/assertion can read it
back, hanging the whole suite.
jest.setup.ts also calls notifyManager.setScheduler((cb) => cb()) —
TanStack Query's own documented recommendation for test environments,
since it otherwise batches observer notifications via setTimeout(fn, 0), which fires after RTL's act()/waitFor() have already closed
and produces "not wrapped in act(...)" warnings. One consequence: any
test that calls a mutation directly on a hook's returned handle (e.g.
result.current.mutate(id), not routed through fireEvent, which
already wraps in act() internally) has to wrap that call in
act(() => result.current.mutate(id)) itself.
The WorkoutForm.test.tsx suite — the most complex form in the app,
nested exercise/set field arrays — needed testIDs added to the
Picker instances and remove buttons, since plain text queries didn't
work: common.remove renders identically for both the per-exercise and
per-set remove buttons, and RTL v14 dropped the old UNSAFE_getByType
escape hatch that would have sidestepped the whole problem. Before
writing the real suite, Picker interaction under Jest was verified with
a throwaway experiment first — fireEvent(picker, "valueChange", value)
does fire onValueChange correctly — rather than assuming it and
discovering otherwise after building six tests around it.
Learn more: jest-expo, TanStack Query — Testing
10. M9 part 1: Build config, icon design, secrets
Bundle identifier, name, and a placeholder icon
The bundle identifier moved from Expo's scaffolding placeholder
(com.anonymous.mobile) to services.sixt.calisthenicsprogression —
sixt.services is the domain the web app already lives under, and more
apps may share it later, hence the shared services.sixt reverse-DNS
prefix. The app name/slug changed from "mobile" to "Calisthenics
Progression" / calisthenics-progression, but scheme was deliberately
left as "mobile" — it's used for the auth deep links from
section 4, and changing it would mean also updating the
redirect URL already allow-listed in the Supabase dashboard.
The app icon went through three rejected concepts before landing on a final design: a literal "CP" monogram (illegible blob at icon size), and ascending bars (reads as a generic signal-strength icon, drops the calisthenics identity entirely). The one that shipped: a single "C"-shaped ring with a 45°-diagonal progression arrow breaking out through the gap — one mark doing double duty, reading as both a "C" and a nod to the ring-based calisthenics motif. One real bug caught while building it: the first version ran the arrow shaft straight through the ring's center, visually crossing the far arc and looking like a "no entry" sign — fixed by shortening the tail so it only travels from just inside the gap outward.
Lesson learned: the icon was built as SVG and rasterized with
rsvg-convert(brew install librsvg) — ImageMagick's own built-in SVG delegate silently dropped the stroked circles and misrendered the stroke color. Caught by inspecting the actual output before trusting it, not by assuming the conversion worked.
This is explicitly a placeholder icon, swapped for real branding before any real store submission.
A permission nobody asked for
RECORD_AUDIO showed up in app.json's Android permissions, unused by
any code — grepping for audio/microphone/expo-av/expo-camera turned
up nothing. Removing the explicit permissions array entry alone didn't
stop it appearing in npx expo config, because expo-image-picker's own
config plugin injects it by default, in case a consuming app uses its
video/camera capture path (this one only calls launchImageLibraryAsync
for profile pictures). Fixed at the source: cameraPermission: false and
microphonePermission: false in the expo-image-picker plugin config —
which also drops the matching unused iOS Info.plist usage-description
keys.
eas.json and secrets out of the repo
eas.json got development/preview/production build profiles.
eas-cli was installed as a devDependency (not global), so builds stay
reproducible without relying on what happens to be installed on any given
machine. eas init --account richi-sixt linked the project, writing the
EAS project ID into app.json's extra.eas.projectId automatically.
The Supabase URL and anon key were first committed directly in
eas.json — the reasoning being that the Supabase anon key is
public-by-design (RLS policies protect the data, not this key), confirmed
by checking that rowsecurity was true for every table via a direct
pg_tables query. That reasoning was correct — but GitGuardian flagged
it on push anyway, since it can't distinguish an anon key from a
service_role key by shape (both are JWTs). Rather than explain the false
positive away each time, both values were moved to EAS's own
environment-variable store via eas env:create/eas env:set (plaintext
visibility, one call per var covering all three environments). Each
eas.json profile now carries an "environment": "<profile-name>"
field, so EAS auto-injects the matching stored vars at build time.
Lesson learned: default to keeping secret-shaped values (JWTs, API keys, tokens) out of committed config files whenever an equally-low-effort alternative exists — even for values that are technically safe to expose. Avoiding the alert entirely beats re-explaining it every time it fires.
Learn more: EAS Build — Environment variables
11. M9 part 2: Shipping to a real iPhone
Two things were blocking a real build when M9 first wrapped up: Apple
Developer Program approval (enrolled, pending), and a real, publicly
reachable backend URL — both .env.local files still pointed
EXPO_PUBLIC_API_URL/NEXT_PUBLIC_API_URL at localhost:5001, which
can't work for a build running on a physical device.
Both blockers cleared without extra deployment work
Apple approval landed. Separately, checking the production domain
directly turned up something unexpected: https://calisthenics- progression.sixt.services/ was already serving the Next.js web app, and
https://calisthenics-progression.sixt.services/api/v1/categories
returned a real Flask JSON response ({"error":"Authentication required."}, not a 404) — confirming nginx already proxies /api/v1/*
to the Flask backend on the same domain as the already-live web app. The
backend deployment blocker had quietly resolved itself as a side effect
of earlier production infrastructure work, without anyone specifically
"deploying the mobile backend." eas.json's preview/production
profiles were updated to point EXPO_PUBLIC_API_URL at that real URL
(development stays on localhost, for local-simulator builds against
a local backend).
The first two builds failed identically — and the fix wasn't obvious
eas build --platform ios --profile preview walked through Apple sign-in
(interactive, 2FA via SMS/device code), registered the bundle
identifier, generated a distribution certificate, and needed a device
registered for ad-hoc distribution — done via EAS's "Website" flow, which
opens a URL on the phone that installs a small registration profile and
captures the UDID automatically, simpler than a manual UDID lookup.
Both of the first two build attempts failed identically at "Install
dependencies," with npm ci --include=dev throwing an EUSAGE error:
Missing: typescript@5.9.3 from lock file. This looked like a lockfile
drift problem — except mobile/package.json/package-lock.json were
provably in sync (typescript@~6.0.3 in both, confirmed at the exact
commit EAS built via its EAS_BUILD_GIT_COMMIT_HASH env var), and a real
clean npm ci --include=dev succeeded locally against those exact files.
--clear-cache didn't help either, ruling out a stale build cache.
The actual root cause: eas-cli's own nested transitive dependency,
node_modules/eas-cli/node_modules/@expo/require-utils, declares an
optional peer dependency on typescript restricted to ^5.0.0 only
— not ^6.x. It's correctly marked optional: true in the lockfile, so
a spec-compliant npm should just skip validating it when unsatisfied. But
the EAS build image's bundled npm (10.9.8, shipped with its default
Node 22.23.1) has a real bug mishandling optional peer-dependency
validation during npm ci, treating it as a hard failure instead of
skipping it — confirmed absent locally with npm 11.15.0, which
handles the same lockfile correctly.
Two fix attempts didn't pan out before finding the real one:
- Pinning
eas.json's"node"field to22.17.1, to match the working local Node version, silently did nothing — that exact patch version isn't cached on EAS's nvm mirror, so it fell back to the image's default (22.23.1) with no error at all. Only caught by reading the build log's environment-setup section, before "Install dependencies," which lists the actually-resolved Node/npm versions. - Adding an
"npm"field per build profile isn't valid —eas.json's schema only supports overridingnode/yarn/bundler/fastlane/cocoapods/ruby, and fails validation immediately if you trynpm.
The actual fix: mobile/.npmrc with legacy-peer-deps=true, which makes
npm skip strict peer-dependency validation entirely — regardless of which
npm version the build image ships — sidestepping the buggy optional-peer
check outright. Confirmed locally first (a clean npm ci --include=dev --dry-run) before spending another build on it.
It built. It ran. It logged in.
The third attempt, eas build --platform ios --profile preview --clear-cache, succeeded — producing an installable ad-hoc IPA behind a
QR code/link from expo.dev. Installing it on the user's iPhone (a
company-managed/MDM device) hit one more wall: "Entwicklermodus
erforderlich" — iOS's Developer Mode gate, required since iOS 16 for any
app installed outside the App Store or TestFlight. It wasn't blocked by
the device's MDM profile in this case; enabling it (Settings → Privacy &
Security → Developer Mode → toggle → restart → confirm "Turn On") let
the app launch and log in successfully against the real production
backend — the first confirmation that the whole chain (bundle ID,
provisioning, production API URL, Supabase auth) actually works outside
the simulator.
Ad-hoc vs. TestFlight
The build that shipped is ad-hoc: signed with a distribution certificate tied to a provisioning profile listing specific device UDIDs by name, installed via a direct link, no Apple review involved. Because it's a paid Apple Developer Program account (not the free tier), the build isn't subject to the usual 7-day free-account expiry — it's valid roughly a year, tied to the certificate/profile's own expiration.
TestFlight is the next step, not yet done: a production-profile
build (App Store distribution certificate, no per-device UDID list),
pushed via eas submit --platform ios to App Store Connect, with testers
invited through TestFlight itself. It bypasses the Developer Mode
requirement entirely — a real advantage for locked-down/managed devices —
and adds crash reporting.
Learn more: EAS Build, Apple — TestFlight
12. This session: retiring the wheel picker
The ad-hoc build worked — but using it surfaced a real UX problem that
only shows up on a physical device, not in a code review: WorkoutForm's
exercise and progression dropdowns, both @react-native-picker/picker,
render on iOS as an always-expanded native wheel sitting directly in
the form's layout — roughly 200pt of vertical space, permanently visible,
with the classic "fading into focus" look of the surrounding rows. It's
exactly right as an HTML <select> on web. As an always-open wheel
sitting inline in a mobile form, it reads as visually odd and eats a lot
of screen real estate for what should be a compact field.
Progression levels became chips
Progression levels are typically 3–6 options — a wheel was overkill.
The fix reuses a pattern the same form already had, one screen over: the
category filter chips. Each level renders as a Pressable pill,
bg-blue-100/dark:bg-blue-900/30 when selected versus
bg-gray-100/dark:bg-gray-700 otherwise:
<Pressable
onPress={() => onChange(level.name)}
className={`rounded-full px-2.5 py-1 ${
value === level.name
? "bg-blue-100 dark:bg-blue-900/30"
: "bg-gray-100 dark:bg-gray-700"
}`}
>
<Text
className={
value === level.name
? "text-xs font-medium text-blue-700 dark:text-blue-400"
: "text-xs font-medium text-gray-600 dark:text-gray-400"
}
>
{level.name}
</Text>
</Pressable>
The chip row also moved to its own full-width line above the reps/ duration fields, instead of sitting side-by-side with them — a wrapping row of chips needs width a fixed-width half-column can't give it.
Pattern name: reusing an existing visual language. The app already had a chip component for category filters; giving progression levels the same shape instead of inventing a new control keeps the UI internally consistent, and it was cheaper to build than anything novel.
The exercise picker became a search modal
The exercise list can run to dozens of items, filterable by category — a
wheel with no search doesn't scale there at all. The fix is a
Pressable field showing the selected exercise's name (or a
placeholder), which opens a Modal (presentationStyle="pageSheet")
containing a search TextInput and a FlatList:
const filteredDefs = useMemo(() => {
const query = search.trim().toLowerCase();
return query
? exerciseDefs.filter((def) =>
def.title.toLowerCase().includes(query)
)
: exerciseDefs;
}, [exerciseDefs, search]);
The filtering is entirely client-side, against the exercise list the
parent form already fetched and filtered (by the existing mine/all
toggle and category chips) — no new network request for search. Tapping
a row calls onChange(String(item.id)) and closes the modal, clearing
the search text for next time. An empty state ("No exercises found.")
covers the no-matches case.
Learn more: React Native — Modal, React Native — FlatList
Removing the now-unused dependency had a side effect
With both dropdowns rewritten, @react-native-picker/picker was no
longer imported anywhere in the project — confirmed with a repo-wide
grep before removing it. npm uninstall @react-native-picker/picker
looked like a clean, no-risk cleanup. It broke npm test.
The cause traces straight back to section 11's EAS fix:
mobile/.npmrc's legacy-peer-deps=true disables npm's automatic
installation of peer dependencies — all of them, not just the specific
optional one it was added to work around. @react-native/jest-preset is
a required (non-optional) peer of jest-expo, and it had only ever
been present in node_modules/the lockfile because npm auto-installed it
silently, with nothing in package.json actually declaring it. Once
legacy-peer-deps was active, npm uninstall's graph recomputation
dropped it — no warning, just a broken jest-expo preset on the next
npm test run.
The fix: add it as an explicit devDependency
(npm install --save-dev @react-native/jest-preset@0.86.2, matching the
already-installed react-native version) instead of relying on implicit
peer auto-install.
Lesson learned: turning on
legacy-peer-depsis not a zero-cost, single-purpose fix — it changes how npm resolves every peer dependency in the project from then on, including ones that used to "just work" silently. Anything that depended on npm's automatic peer-install behavior now needs to be declared explicitly.
Both UI changes were verified against real tests (WorkoutForm.test.tsx,
now 8 tests, up from 6 — the old tests drove the wheel picker via RTL's
fireEvent(picker, "valueChange", value) escape hatch, replaced with a
selectExercise() helper that presses the field open and presses the
matching result row), a clean tsc --noEmit, and live in the iOS
Simulator via screenshots — the chip selection and the search-modal
filtering both confirmed working end to end.
13. Bugs that bit us
A consolidated list, across all nine milestones and this session.
Bug 1: PKCE silently downgraded to plain
Symptom: no visible error — just a weaker auth flow than intended.
Root cause: Hermes has no crypto.subtle, and Supabase's PKCE
implementation fell back silently instead of failing loudly.
Fix: react-native-quick-crypto + react-native-nitro-modules,
polyfilled before anything else runs. See section 4.
Bug 2: one account, consistent 401, unrelated root cause
Symptom: /api/v1/auth/profile returns 401 for one specific account
only, despite a valid-looking JWT.
Root cause: a legacy Flask-Login row with the same email colliding
with auto-provisioning, its IntegrityError silently swallowed.
Fix: linked the legacy row's supabase_uid, added logging to the
previously-silent exception path. See section 5.
Bug 3: expo-image-picker crashed the app outright
Symptom: hard crash on opening the image picker.
Root cause: missing NSPhotoLibraryUsageDescription, and a stale
native project that didn't pick up the app.json fix.
Fix: config plugin entry + npx expo prebuild --clean. See
section 7.
Bug 4: FormData rejected the classic RN upload shape
Symptom: "Unsupported FormDataPart implementation" on profile
picture upload.
Root cause: the networking stack wants a real Blob, not the
{ uri, name, type } object shape.
Fix: fetch(asset.uri).blob() before formData.append(...). See
section 7.
Bug 5: Mark Done needed a manual refresh
Symptom: toggling a workout done didn't update the list until a
manual pull-to-refresh.
Root cause: an optimistic-update helper assumed every
"workouts"-prefixed cache entry was array-shaped, crashing silently on
the differently-shaped single-workout cache entry. See
section 8.
Fix: Array.isArray(old.data) branch in the cache updater.
Bug 6: npm 10.9.8 mishandled an optional peer dependency
Symptom: eas build failed at "Install dependencies" with a
lockfile-sync error that didn't reproduce locally.
Root cause: a real bug in the EAS build image's bundled npm,
mishandling eas-cli's own optional peer on typescript. See
section 11.
Fix: mobile/.npmrc's legacy-peer-deps=true.
Bug 7: fixing bug 6 broke npm test
Symptom: npm test failed after an unrelated npm uninstall, with
a jest-expo preset-loading error.
Root cause: legacy-peer-deps=true disabled npm's automatic
installation of required peers too, dropping @react-native/jest- preset. See section 12.
Fix: pinned it as an explicit devDependency.
14. Files changed across all milestones
Directory structure (new)
mobile/
├── src/
│ ├── app/ # Expo Router routes
│ │ ├── (app)/ # authenticated stack + native tabs
│ │ └── (auth)/ # login, register, password reset
│ ├── components/
│ │ ├── workout/ # WorkoutForm, WorkoutCard, ...
│ │ ├── ui/ # skeleton.tsx and friends
│ │ └── app-tabs.tsx
│ ├── hooks/ # use-workouts, use-exercises, ...
│ ├── i18n/ # LanguageProvider, en.ts, de.ts
│ ├── lib/
│ │ ├── api.ts
│ │ └── supabase/ # client.ts, largeSecureStore.ts
│ ├── providers/ # AuthProvider
│ └── types/
├── app.json
├── eas.json
├── .npmrc
└── package.json
Tech stack additions since Part 3
| Technology | Role |
|---|---|
expo-router | File-based routing |
expo-secure-store | Native secure token storage |
react-native-quick-crypto + react-native-nitro-modules | WebCrypto polyfill for PKCE |
@react-native-async-storage/async-storage | Bulk storage for LargeSecureStore |
aes-js | Session envelope encryption |
nativewind | Tailwind for React Native |
@react-native-picker/picker | Native <select> equivalent — added M3, removed this session |
expo-router/unstable-native-tabs | Native tab bar |
expo-image-picker | Profile picture selection |
jest-expo + @testing-library/react-native v14 + test-renderer | Test infrastructure |
eas-cli | Reproducible EAS builds (devDependency, not global) |
@react-native/jest-preset | Explicit devDependency after the peer-auto-install side effect |
Key files modified this session (section 12)
| File | What changed |
|---|---|
src/components/workout/WorkoutForm.tsx | Progression <Picker> → chip row; exercise <Picker> → tap-to-open search modal (ExercisePickerField) |
src/components/workout/WorkoutForm.test.tsx | selectExercise() helper replacing valueChange events; new progression-chip test |
src/i18n/translations/en.ts / de.ts | workoutForm.searchExercisePlaceholder, workoutForm.noExercisesFound |
package.json | -@react-native-picker/picker, +@react-native/jest-preset |
eas.json | EXPO_PUBLIC_API_URL pointed at the real production backend |
.npmrc | New file — legacy-peer-deps=true |
Next up: eas submit --platform ios for a real TestFlight build, and
once mobile ships for real — Phase 4, retiring Jinja2/Flask-Login/WTForms
from the backend for good.