From Flask Monolith to Multiplatform App: Migration Journey Part 4

learningexporeactdocs

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

  1. What we're building
  2. Tech stack at a glance
  3. M0: Scaffolding
  4. M1: Auth — Supabase, PKCE, and a Keychain size limit
  5. M2: The API client and a very misleading 401
  6. M3–M4: Core workouts, templates & library
  7. M5–M6: Social, profile & two native bugs
  8. M7: Polish — refresh, dark mode, i18n, skeletons
  9. M8: Testing infrastructure
  10. M9 part 1: Build config, icon design, secrets
  11. M9 part 2: Shipping to a real iPhone
  12. This session: retiring the wheel picker
  13. Bugs that bit us
  14. 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.

TechnologyRoleLearn more
Expo (managed) + TypeScriptApp framework, fast iteration, OTA updatesExpo
Expo RouterFile-based routing, same mental model as Next.js App RouterExpo Router
@supabase/supabase-js + expo-secure-storeAuth, secure on-device token storageSupabase Auth
TanStack QueryServer state — same library, same caching model as webTanStack Query
react-hook-formForm state + validation, same patterns as webreact-hook-form
NativeWindTailwind for React Native — reuses web's utility classesNativeWind
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 a className prop — 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:ios afterward — 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!!session alone 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.Screen needs 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


7. M5–M6: Social, profile & two native bugs

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.json alone didn't work. npx expo run:ios only does a full native "prebuild" (regenerating Info.plist from app.json) the first time ios/ doesn't exist — once it exists, later expo run:ios calls just rebuild the existing native project without resyncing config changes. Had to force it with npx expo prebuild --clean. Worth remembering any time a plugin config change in app.json doesn'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 transformIgnorePatterns to the jest config here. jest-expo's preset already ships one covering expo-modules-core and the rest of the RN ecosystem — overriding it instead of extending it breaks expo-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.calisthenicsprogressionsixt.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 to 22.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 overriding node/yarn/bundler/fastlane/ cocoapods/ruby, and fails validation immediately if you try npm.

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-deps is 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

TechnologyRole
expo-routerFile-based routing
expo-secure-storeNative secure token storage
react-native-quick-crypto + react-native-nitro-modulesWebCrypto polyfill for PKCE
@react-native-async-storage/async-storageBulk storage for LargeSecureStore
aes-jsSession envelope encryption
nativewindTailwind for React Native
@react-native-picker/pickerNative <select> equivalent — added M3, removed this session
expo-router/unstable-native-tabsNative tab bar
expo-image-pickerProfile picture selection
jest-expo + @testing-library/react-native v14 + test-rendererTest infrastructure
eas-cliReproducible EAS builds (devDependency, not global)
@react-native/jest-presetExplicit devDependency after the peer-auto-install side effect

Key files modified this session (section 12)

FileWhat changed
src/components/workout/WorkoutForm.tsxProgression <Picker> → chip row; exercise <Picker> → tap-to-open search modal (ExercisePickerField)
src/components/workout/WorkoutForm.test.tsxselectExercise() helper replacing valueChange events; new progression-chip test
src/i18n/translations/en.ts / de.tsworkoutForm.searchExercisePlaceholder, workoutForm.noExercisesFound
package.json-@react-native-picker/picker, +@react-native/jest-preset
eas.jsonEXPO_PUBLIC_API_URL pointed at the real production backend
.npmrcNew 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.