Skip to content

Mobile (Native iOS/Android)

The customer mobile clients are two native apps: apps/ios-user (Swift + SwiftUI) and apps/android/app-user (Kotlin + Jetpack Compose) — same customer-facing scope, same backend. The agent dashboard is likewise native (apps/ios-agent + apps/android/app-agent) on this same stack.

Both live in the monorepo as plain directories — no Turborepo / bun-workspace participation, so bun run check does not cover them. They consume the existing apps/api host (tRPC + Better Auth + image proxy) with zero backend changes.

Per-app dev notes live in apps/ios-user/CLAUDE.md and apps/android/app-user/CLAUDE.md. Migration plan + phasing: .claude/plans/lets-plan-the-migration-greedy-valiant.md.

Concernapps/ios-userapps/android/app-user
LanguageSwift 5, async/awaitKotlin 2.1, JVM target 17
UISwiftUI, NavigationStackJetpack Compose + Material 3, Navigation Compose (plain string routes)
Min targetiOS 17SDK 26
PatternMVVM-lite — @Observable ViewModel per screenStateFlow + ViewModel per screen (coroutines)
DIManual via AppEnvironmentManual AppContainer singleton — no Hilt
NetworkingURLSessionOkHttp 4 — no Retrofit
SerializationCodable + SuperJSON envelopekotlinx.serialization + SuperJSON envelope
Auth storageKeychainEncryptedSharedPreferences
Project genXcodeGen — IosUser.xcodeproj is gitignored, project.yml is source of truthGradle 8.14 wrapper, single :app module
Google sign-inGoogleSignIn-iOSandroidx Credential Manager
ImagesKingfisherCoil 3

Native files follow platform convention — PascalCase matching the primary type (AuthClient.swift, TrpcClient.kt), which overrides the repo-wide kebab-case rule for these two apps only. Android res/ resources stay snake_case (hyphens are a build error there).

Each app is a single target split into three layers — App/ (entry point, root navigation, DI container), Core/ (shared infrastructure: API client, auth, design system, i18n, models, stores), and Features/ (one folder per screen group). Android mirrors this under com.realestatecore.user: root files (App, MainActivity, Navigation, AppContainer) + core/ + features/ packages.

  • Navigation — a five-tab shell: Search · Discovery (Home) · Favorites · Inbox · Menu. Search and Discovery are browsable signed-out; Favorites, Inbox, and Menu are auth-gated and render a SignInPrompt when signed out. iOS wraps each tab in its own NavigationStack inside a TabView; Android drives the whole shell from a single flat NavHost of string routes (the auth flow is a nested navigation("auth") graph), with the bottom bar shown only on the five tab roots. Sub-screens (property detail, settings, tours, compare…) present over the tab shell with a vertical cover transition, drilling deeper between sub-screens pushes horizontally, and tab switches have no directional slide — the same motion on both platforms (iOS .fullScreenCover + NavigationStack push + TabView; Android a global Navigation-Compose transition set on the NavHost, chosen by the source→target route pair; the agent shell carries the same grammar). Predictive back is enabled on Android (both apps; nav-compose 2.9 makes the pop transitions seekable). The §01 tab cross-fade is Android-only — iOS keeps the native instant TabView switch to preserve per-tab state, a documented divergence.
  • Feature modules — Auth (welcome → email → OTP → name), Home (featured + nearby carousels), Search (list + map toggle, filters sheet, location autocomplete), Properties (card, detail, gallery/lightbox, tour-request sheet), Favorites (grid), Tours (list + reschedule), Menu (profile, edit-profile, become-agent, settings).
  • State — one @Observable (iOS) / StateFlow (Android) ViewModel per screen, created fresh per screen, holding transient UI state + tRPC-fetched DTOs. App-scoped stores live in AppEnvironment / AppContainer: SessionStore, AuthStore / AuthState, FavoritesStore, SettingsStore, SearchFiltersStore, RecentSearchesStore, and a ToastCenter. DI is manual — no Combine/TCA on iOS, no Hilt on Android.
  • Deep links — a pure DeepLink parser maps the expo-user:// scheme to navigation: expo-user://properties/{listingId} → property detail, expo-user://tours → My Tours.

There is no @trpc/react-query and no compile-time types from @repo/api. Each app ships a hand-written thin client (TrpcClient.swift / TrpcClient.kt, ~150–200 lines) speaking the wire protocol directly:

  • QueriesGET /api/trpc/{router.procedure}?input=<urlencoded {"json": <input>}>
  • MutationsPOST /api/trpc/{router.procedure}, body {"json": <input>}
  • Response{"result": {"data": {"json": <output>}}}; error{"error": {"json": {...}}}
  • No batching — one request per call; native concurrency (async/await, coroutines) handles parallelism.
  • Transport-failure-only retry — retries the network-failure class, never server 4xx/5xx (which carry data).

Cookie-based Better Auth. The app owns the Cookie: header end-to-end (platform cookie jars disabled) and captures rotations from any Set-Cookie response into the Keychain (SessionStore.swift) / EncryptedSharedPreferences (CookieInterceptor.kt).

  • Email OTP is live: send-verification-otpsign-in/email-otp → (new accounts) collect a name via users.updateProfile.
  • Google sign-in uses the native SDK idToken flow — the app obtains an idToken locally (Credential Manager on Android, GoogleSignIn-iOS on iOS) and posts it to POST /api/auth/sign-in/social ({provider: "google", idToken: ...}). No browser/proxy. This needs iOS + Android OAuth clients in Google Cloud (same project as the web client; serverClientId = the existing web client id).
  • A 401 on any request triggers a global sign-out: clear the stored cookie and flip auth state back to the welcome screen.

Color tokens are generated and committed, never hardcoded. The source of truth is design-tokens/tokens.cjs — the Meridian Native App Map §03 role table (light + dark), including the mode-invariant primarySolid (primary buttons stay white-on-indigo in dark mode); the generated files are IosUser/Core/Design/Tokens.swift and app/src/main/kotlin/.../core/design/Tokens.kt. Read colors via @Environment(\.tokens) (iOS) or LocalTokens.current (Android) — never hardcode hex. Typography is the shared Meridian ramp — Space Grotesk for display roles (prices, KPIs, screen titles; never body text) and Hanken Grotesk for everything else — bundled per app with a matching scale (24 title · 14 body · 13.5 subtitle · 12 caption · 11 micro-caps, floor 11) that follows Dynamic Type (iOS relativeTo:) / the Android font scale (sp). Shape/spacing/motion constants live in each app’s Metrics file.

Both apps ship a hand-built, lockstep primitive library (matching API + visuals on each platform) so feature code never re-rolls UI: PrimaryButton / SecondaryButton (52 height, 600 label; 12pt radius on iOS, a full pill on Android — the §03 platform split), ListRow, SwitchRow, CheckboxRow, SegmentedControl (single + multi select), SelectablePill / DismissibleChip, Avatar (muted-gradient initials), StatusBadge (6pt radius, 700 weight), Toast (via ToastCenter — a top capsule on iOS, the M3 snackbar on Android), EmptyState, Skeleton, text Inputs (muted-fill borderless on iOS, M3 outlined + floating label on Android), PickerSheet, plus the Meridian chrome primitives — TabRootHeader, IconActionButton (Android) / GlassIconButton + ChromeButton (iOS), OfflineBanner, Haptics, Metrics — and the §03-10 property-card variants (vertical / row / tile, with the selectable compare mark: circle on iOS, rounded square on Android). The rule is no hand-rolled buttons, and colors come from the generated palette via @Environment(\.tokens) / LocalTokens.current, never hardcoded hex. The mobile-ui-audit skill flags violations (its Rule 14 lists the shared components).

Catalogs are synced copies of the shared source i18n/user/{en,es}.json (agent apps use i18n/agent/) — flat keys, {{var}} interpolation, _one / _other plural suffixes, es fallback (mapped to es-MX for number/date formatting). To add a key: edit both locales in the shared i18n/ source, then re-run the sync (there is no compile step):

Terminal window
bun scripts/sync-native-i18n.ts
AppCatalog location
iOSIosUser/Resources/messages-{en,es}.json
Androidapp/src/main/assets/messages/{en,es}.json

Same rule as the rest of the repo — read packages/database/src/enums.ts (values) and packages/database/src/generated/db.ts (column types) before hardcoding any status string. Tour.status = REQUESTED, not PENDING. There are no compile-time enum types here, so a wrong literal fails silently at runtime.

Crash + error reporting, one Sentry project per deployable (org na-v1f): ios-user and android-user are their own projects. iOS uses sentry-cocoa (SwiftPM); Android uses sentry-android (no Gradle plugin yet — release symbol upload is deferred). Both init manually at process start — Monitoring.start() from IosUserApp.init() / App.onCreate() — with sendDefaultPii off, tracesSampleRate 0.1, and a beforeSend that redacts cookie / set-cookie / authorization request headers. Android disables the SDK’s io.sentry.auto-init ContentProvider (a manifest meta-data) so the DSN and options live in code. The DSN is a public ingest key, committed — Monitoring.start() no-ops on a non-https placeholder, so the app runs fine before the project exists. A #if DEBUG / BuildConfig.DEBUG-gated “Send test event” row on the Home screen proves the pipeline.

The apps don’t hand-write response DTOs — the model layer is generated from the Zod “wire” schemas in @repo/contract (packages/contract) via scripts/codegen-models.ts (bespoke Swift + Kotlin emitters). The schemas describe result.data.json post-superjson — ISO-string dates, z.int() counts, money as numbers — and the output lands committed in each app’s Core/Models/Generated/ tree. Enums, computed properties, Date accessors and Encodable request bodies stay hand-written in *+App.swift / extension files; regeneration is idempotent. Full pipeline: packages/contract/FEATURE.md.

Two guards keep the models honest:

  • CI tests job (in ci.yml) regenerates the models and runs git diff --exit-code — a schema change without a committed regen, or a hand-edited generated file, fails the PR — then fixtures:validate, which parses every committed fixture through its schema.
  • Decode testsscripts/contract-fixtures/generate-fixtures.ts captures real API envelopes as committed golden <proc>.json files; ContractFixtureTests.swift / ContractFixtureTest.kt decode them through the real client unwrap path, catching decoder regressions. The capture tool is now manual (refresh a fixture when the wire genuinely changes); its old deployed-API CI workflow was removed in favour of the offline tests job. Protected procedures (e.g. users.getMe) need a session cookie (FIXTURE_SESSION_COOKIE) to capture.
Terminal window
cd apps/ios-user
xcodegen generate # regenerate IosUser.xcodeproj from project.yml
xcodebuild -project IosUser.xcodeproj -scheme IosUser \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
xcodebuild -project IosUser.xcodeproj -scheme IosUser \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' test
Terminal window
cd apps/android # the wrapper lives at the umbrella root
./gradlew :app-user:assembleDebug # debug APK → app-user/build/outputs/apk/debug/
./gradlew :app-user:testDebugUnitTest # unit tests (envelope, cookie merge, i18n)
./gradlew :app-user:lint
adb install app-user/build/outputs/apk/debug/app-user-debug.apk
# the agent app is the same build — swap :app-user for :app-agent

Both debug and release builds target the deployed API https://dev-api.real-estate-core.com by default. To test a local apps/api, flip the debug base URL to http://localhost:4000 (iOS simulator — NSAllowsLocalNetworking is already set) or http://10.0.2.2:4000 (Android emulator → host, plus a cleartext network-security-config).

To install on a physical device — currently debug-signed local builds; no TestFlight/Play yet — see Deployment → run on a physical device.

Phase 1 is code-complete on both platforms — the full customer journey ships natively:

  • Browsable tabs + Home — four-tab shell with featured + nearby carousels.
  • Search — list mode (filters sheet incl. the 57-key feature accordion, location autocomplete, sort, infinite scroll) and map mode (native clustering, pre-rasterized price-pill markers, viewport-driven fetch, list↔map toggle).
  • Property detail — gallery + lightbox, price history, neighborhood stats, agent card, and the tour-request sheet.
  • Favorites — grid with the optimistic favorite heart on every card.
  • Tours — My Tours list with reschedule negotiation + cancel.
  • Profile & settings — profile view/edit, multipart avatar upload + remove, become-agent, and live theme + language switching (no restart).
  • Deep linksexpo-user:// for property detail + tours, plus code-side distribution prep (iOS app icon/launch screen, Android conditional release signing).

This builds on the Phase 0 foundations (scaffolding, tRPC client, cookie auth, generated tokens, runtime i18n, Sentry crash reporting, and the @repo/contract generated model layer).

Planned — Phase 2 fast-follows: chat (Stream SDKs), push notifications (APNs/FCM + Knock), addresses, saved searches, compare, recently-viewed, commute. Phase 3 is the store cutover (in-place update to the existing com.realestatecore.user listings). See Planning → Status.