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.
| Concern | apps/ios-user | apps/android/app-user |
|---|---|---|
| Language | Swift 5, async/await | Kotlin 2.1, JVM target 17 |
| UI | SwiftUI, NavigationStack | Jetpack Compose + Material 3, Navigation Compose (plain string routes) |
| Min target | iOS 17 | SDK 26 |
| Pattern | MVVM-lite — @Observable ViewModel per screen | StateFlow + ViewModel per screen (coroutines) |
| DI | Manual via AppEnvironment | Manual AppContainer singleton — no Hilt |
| Networking | URLSession | OkHttp 4 — no Retrofit |
| Serialization | Codable + SuperJSON envelope | kotlinx.serialization + SuperJSON envelope |
| Auth storage | Keychain | EncryptedSharedPreferences |
| Project gen | XcodeGen — IosUser.xcodeproj is gitignored, project.yml is source of truth | Gradle 8.14 wrapper, single :app module |
| Google sign-in | GoogleSignIn-iOS | androidx Credential Manager |
| Images | Kingfisher | Coil 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).
App structure
Section titled “App structure”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
SignInPromptwhen signed out. iOS wraps each tab in its ownNavigationStackinside aTabView; Android drives the whole shell from a single flatNavHostof string routes (the auth flow is a nestednavigation("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+NavigationStackpush +TabView; Android a global Navigation-Compose transition set on theNavHost, 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 instantTabViewswitch 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 inAppEnvironment/AppContainer:SessionStore,AuthStore/AuthState,FavoritesStore,SettingsStore,SearchFiltersStore,RecentSearchesStore, and aToastCenter. DI is manual — no Combine/TCA on iOS, no Hilt on Android. - Deep links — a pure
DeepLinkparser maps theexpo-user://scheme to navigation:expo-user://properties/{listingId}→ property detail,expo-user://tours→ My Tours.
tRPC-over-HTTP client
Section titled “tRPC-over-HTTP client”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:
- Queries —
GET /api/trpc/{router.procedure}?input=<urlencoded {"json": <input>}> - Mutations —
POST /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).
Auth — cookies, not Bearer
Section titled “Auth — cookies, not Bearer”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-otp→sign-in/email-otp→ (new accounts) collect a name viausers.updateProfile. - Google sign-in uses the native SDK
idTokenflow — the app obtains anidTokenlocally (Credential Manager on Android, GoogleSignIn-iOS on iOS) and posts it toPOST /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
401on any request triggers a global sign-out: clear the stored cookie and flip auth state back to the welcome screen.
Design tokens & fonts
Section titled “Design tokens & fonts”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.
Components
Section titled “Components”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):
bun scripts/sync-native-i18n.ts| App | Catalog location |
|---|---|
| iOS | IosUser/Resources/messages-{en,es}.json |
| Android | app/src/main/assets/messages/{en,es}.json |
Enums (native too)
Section titled “Enums (native too)”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.
Monitoring (Sentry)
Section titled “Monitoring (Sentry)”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.
Generated models
Section titled “Generated models”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
testsjob (inci.yml) regenerates the models and runsgit diff --exit-code— a schema change without a committed regen, or a hand-edited generated file, fails the PR — thenfixtures:validate, which parses every committed fixture through its schema. - Decode tests —
scripts/contract-fixtures/generate-fixtures.tscaptures real API envelopes as committed golden<proc>.jsonfiles;ContractFixtureTests.swift/ContractFixtureTest.ktdecode 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.
Commands
Section titled “Commands”cd apps/ios-userxcodegen generate # regenerate IosUser.xcodeproj from project.ymlxcodebuild -project IosUser.xcodeproj -scheme IosUser \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' buildxcodebuild -project IosUser.xcodeproj -scheme IosUser \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' testAndroid
Section titled “Android”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:lintadb install app-user/build/outputs/apk/debug/app-user-debug.apk# the agent app is the same build — swap :app-user for :app-agentBoth 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.
Status
Section titled “Status”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 links —
expo-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.