B2C mobile app (apps/mobile-b2c)¶
Status: in-progress LNO: L. A native consumer app is the acquisition and retention surface for the B2C bet. Everything below (streaks, push, Home tiers) only pays off if the app itself is the daily habit. Owners: @bhanu Last updated: 2026-07-13 Roles affected: B2C learners (
role == b2c_user), free and Pro. No B2B surfaces ship in this app. Primary routes:/welcome,/(Home tab),/chat,/study-plan,/practice,/profileSource of the ask: self-initiated strategic bet, @bhanu. Not a user interview or a support ticket. See the honest validation flag below. Related: B2C flow, B2C upload limits, Mobile B2C push notifications, Mobile B2C ops reference, Architecture
TL;DR¶
apps/mobile-b2c is a React Native / Expo app that puts the whole B2C learner product (chat tutor, study plans, practice, test prep, artifacts, My Space) into an Android-first native app. It is a second client on the same backend, not a companion to apps/web: a learner can do their entire loop here without opening a browser. Package id ai.kwilo.b2c, Expo SDK 54, expo-router file routing, @kwilo/ui-native design system. Roughly 40 commits shipped and it had zero documentation until this page.
It is not apps/mobile. That is the older B2B companion app (com.vidyanet.app, React Navigation, faculty and K-12 features). The two share a backend and nothing else.
Why this exists¶
Who asked for it¶
Nobody, in the interview sense. This is a self-initiated strategic bet by @bhanu: the B2C learner in India is a phone-first user, and a mobile web app on app.kwilo.ai is a weak vehicle for a daily-habit product (no push, no home-screen icon, no app-store discovery). Filed here honestly rather than dressed up as a validated request.
What user pain does it solve¶
The observation, not a quote from a user study: B2C learners who sign up on kwilo.ai on a phone land in a browser tab. A browser tab is not something you return to at 9pm on a Tuesday. The pains that follow:
- No re-entry surface. Nothing on the home screen, nothing in the notification tray, nothing that says "you have a study plan waiting".
- Chat and lesson reading on mobile web fight the browser chrome, the keyboard, and the address bar.
- The learner cannot be reminded. Push is the single retention lever that a web SPA on iOS/Android simply does not have in a reliable form.
Cost of not doing it¶
If B2C stays browser-only: acquisition can still work (the marketing site converts), but retention has no lever. A study plan the learner never returns to is a plan that never produces mastery, never produces a Pro upgrade, and never produces a testimonial. The cost is not one broken feature, it is that the whole B2C funnel leaks at the "come back tomorrow" step.
Is the pain validated or a guess?¶
Weak validation. This is a guess with good priors. Flag it plainly:
- Not derived from user interviews with Kwilo learners.
- The priors are strong and external: the Indian consumer edtech category is overwhelmingly app-first, and phone-first usage among the target learner is not seriously contested.
- No pre-ship experiment established that our specific learners would install and return.
Because validation is weak, everything in here is reversible: it is a separate app, it shares the backend, and pulling it costs nothing in apps/web.
How we know it worked¶
User-outcome signals, not build metrics:
- A learner who installs it comes back on a day they were not going to come back (streak length beyond day 1, return sessions that begin from a notification tap).
- The learner finishes a study plan lesson on the phone, not just starts one.
- Sean Ellis counterfactual: if we pulled the app a week after ship, would learners complain with specifics? Honestly, today: probably not, the install base is too small. That is the number to move first. Until a cohort would notice its absence, the app is unvalidated regardless of how good it looks.
What it is and who it is for¶
- Audience: B2C learners only. A learner is
role == b2c_user; every B2C user has an Individual workspace org unit, soorg_unit_idis never null (do not gate on that). - Platform: Android-first. iOS is deferred. The Expo config declares an iOS bundle id (
ai.kwilo.b2c), but no Apple credentials are configured, so iOS builds are manual-only and nothing ships to the App Store today. All three EAS build profiles target Android in practice. - Relationship to the backend: it is a plain API client.
api-staging.kwilo.aifor dev/preview builds,api.kwilo.aifor production. No mobile-specific backend, one mobile-specific endpoint (POST /auth/google/mobile).
Stack¶
| Layer | Choice |
|---|---|
| Runtime | React Native 0.81.5, Expo SDK 54, new architecture enabled |
| Routing | expo-router 6 (file-based, typed ROUTES constants in constants/routes.ts) |
| Styling | NativeWind 4.2 + Tailwind 3.4, tokens from @kwilo/ui-native |
| Design system | @kwilo/ui-native (Screen, Text, Button, ListRow, SmartBadge, SteppedLoader, BrandLoader, StepProgress, ...) |
| Server state | TanStack Query 5 + Axios + Zod-validated services |
| Client state | Zustand (auth, chat, onboarding, streak, theme, notification prefs) |
| Persistence | expo-secure-store for tokens, react-native-mmkv for preferences and streak |
| Type of copy | i18next, every user-visible string via t() |
| Fonts | Manrope via @expo-google-fonts/manrope |
| Analytics | posthog-react-native, opt-in (blank key disables it) |
useReducer is banned here as it is everywhere in the monorepo; multi-state flows are Zustand stores under stores/.
Route map¶
File-based under apps/mobile-b2c/app/. Two groups: (auth) for the unauthenticated shell, (app) for the gated one.
(auth): unauthenticated¶
| Route | File |
|---|---|
/welcome |
app/(auth)/welcome.tsx |
/login |
app/(auth)/login.tsx |
/signup |
app/(auth)/signup.tsx |
/forgot-password |
app/(auth)/forgot-password.tsx |
/reset-password |
app/(auth)/reset-password.tsx |
(app)/(tabs): the bottom tab bar¶
Six tabs, declared as one TAB_DEFS const array in app/(app)/(tabs)/_layout.tsx and mapped to <Tabs.Screen>:
| Tab | Route file | Notes |
|---|---|---|
| Home | (tabs)/index.tsx |
Native header title blanked; the screen renders its own "Hi, {name}" greeting |
| Chat | (tabs)/chat.tsx |
Accepts an intent param pushed from Home's intent tiles |
| Study Plan | (tabs)/study-plan.tsx |
|
| Practice | (tabs)/practice.tsx |
|
| Profile | (tabs)/profile.tsx |
Carries the notifications unread-count badge |
| More | (tabs)/more.tsx |
Not a screen. Its tabPress is intercepted and opens MoreSheet; the file itself only redirects Home on a direct deep link |
(app): stacked routes¶
| Route | File |
|---|---|
/onboarding |
app/(app)/onboarding.tsx |
/chat/[conversationId] |
app/(app)/chat/[conversationId].tsx |
/understand |
app/(app)/understand/index.tsx |
/study-plan/create |
app/(app)/study-plan/create.tsx |
/study-plan/[planId] |
app/(app)/study-plan/[planId]/index.tsx |
/study-plan/[planId]/edit |
app/(app)/study-plan/[planId]/edit.tsx |
/study-plan/[planId]/lesson/[sequence] |
app/(app)/study-plan/[planId]/lesson/[sequence].tsx (the lesson player) |
/practice/session |
app/(app)/practice/session.tsx (the live player) |
/practice/test/[id] |
app/(app)/practice/test/[id].tsx (question-by-question review of a finished run) |
/test-prep, /test-prep/create, /test-prep/[id] |
app/(app)/test-prep/* |
/artifacts, /artifacts/[type]/[id] |
app/(app)/artifacts/* |
/presentations, /presentations/[id] |
app/(app)/presentations/* |
/research/[id] |
app/(app)/research/[id].tsx |
/my-space |
app/(app)/my-space.tsx |
/history |
app/(app)/history.tsx |
/notifications |
app/(app)/notifications.tsx |
/upgrade |
app/(app)/upgrade.tsx |
/settings/* |
about, account, appearance, notifications, preferences, profile, security, subscription |
Every path is built through constants/routes.ts (ROUTES.*), never a raw string at a call site. Two path builders carry real logic: practiceSession (encodes subject, chapter, optional session id, and a from=review retake marker) and artifactDetail (non-document artifact types have no backend detail fetch yet, so display fields ride along as query params).
MoreSheet surfaces the five secondary destinations that do not deserve a tab: History, Artifacts, My Space, Test Prep, Presentations. It reuses the same icon and label keys as Home's quick-entry list, so the two entry points into the same place never drift apart.
Auth¶
Welcome-first¶
app/(app)/_layout.tsx is the gate. Unauthenticated users are redirected to /welcome, not /login. The welcome screen is the brand moment plus the two ways in (Google, email). This is deliberate: a cold-launch user who has never heard of Kwilo should not be handed a password field.
There is a known TODO in the gate: it cannot yet distinguish a first launch from a logged-out return visit, so returning users also land on welcome instead of going straight to login. Cheap to live with, worth fixing.
Above the gate, app/_layout.tsx runs a BrandIntro splash (core RN Animated, not Reanimated, which does not run at the expo-router root) before handing off to the router.
After auth, the gate checks useStudentHomeContext(): if onboarding_required and not onboarding_skipped, it redirects to /onboarding.
Native Google Sign-In¶
Uses @react-native-google-signin/google-signin (Android Credential Manager), not a browser expo-auth-session flow. The important and counter-intuitive part:
- The native SDK authenticates against the Web OAuth client (
EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID). The returnedid_token'saudis that web client, which the backend already trusts, so no per-Android-client allowlist is needed. - An Android OAuth client (package
ai.kwilo.b2c+ the build's signing SHA-1) must still exist in GCP so Google recognizes the app. It is matched by signature and never passed in code. - Every keystore has a different SHA-1 (EAS build keystore, Play App Signing, each dev's local debug keystore). A build whose SHA-1 is not registered fails with
DEVELOPER_ERROR. This is the single most common setup failure.
The id_token goes to POST /auth/google/mobile (services/googleAuth.ts), which returns access + refresh tokens; the app then hydrates the user via authService.me(), the same as every other token-issuing flow.
Email / password¶
Standard login, signup, forgot-password, reset-password. Tokens live in expo-secure-store.
One hard-won rule (see services/api.ts): the 401 interceptor must reject the boot /auth/me probe rather than redirecting to /login. If it redirects, it hijacks the (app) gate's welcome redirect and the cold-launch routing breaks.
Cloudflare Access on staging¶
api-staging.kwilo.ai sits behind Cloudflare Access, which 302-redirects any session-less request to interactive SSO. A native client cannot complete that. So the app sends a Service Auth token as headers on every request:
CF-Access-Client-Id: <EXPO_PUBLIC_CF_ACCESS_CLIENT_ID>
CF-Access-Client-Secret: <EXPO_PUBLIC_CF_ACCESS_CLIENT_SECRET>
Set in services/api.ts from constants/config.ts. Production (api.kwilo.ai) is not gated, so the vars are empty there. These values are inlined into the shipped bundle and are extractable from any distributed binary. Ship the CF token only in internal builds against staging, never in a public store build.
Home: the three-tier dashboard¶
(tabs)/index.tsx renders one screen with three deliberately unequal tiers, so the learner's eye lands in the right place:
- Tier 1,
HomeHero. Greeting plus the quota and streak chip row, wrapped in one panel. Answers "who am I and where do I stand". - Tier 2,
IntentGrid. The four learner intents as tiles: get help, understand, practice, revise. This is the loudest thing on the screen because it is the decision the learner actually has to make. Tapping a tile pushes into the Chat tab with anintentparam. The one exception: understand, for a learner eligible for Understand routing, goes to the dedicated/understandrouter instead of chat. - Tier 3,
QuickEntryList. History, Artifacts, My Space, Test Prep, Presentations at a quieter visual scale, so it reads as "below" the intent tiles rather than competing with them. Same items as MoreSheet.
HomeSkeleton mirrors all three tiers so the loading state does not reflow the layout.
Streak¶
StreakBadge (a SmartBadge with tone="streak") plus StreakMilestoneBanner, a one-shot celebration the first time a learner crosses a milestone (3 / 7 / 14 / 30 / 60 / 100 days, utils/streakStorage.ts). The counter is client-side, persisted in MMKV. Be honest about what that means: it is a nudge, not a source of truth, and it does not survive a reinstall.
The stepped generation loader¶
AI generation on a phone is a long wait on a small screen, and a spinner tells the learner nothing. So generation waits render a SteppedLoader (from @kwilo/ui-native): a vertical checklist that ticks through named steps at roughly a one-second pace.
- Chat:
GENERATION_STEPS_BY_KINDincomponents/chat/constants.tsmaps the generation kind (presentation,research, ...) to its own step list. Research steps come from the real backend stage stream; presentation steps are a fixed list. - Study plan lessons:
LESSON_GENERATION_STEPS(reading, building, questions) incomponents/study-plan/constants.ts.
Important caveat, stated in the code and repeated here: for lessons there is no real backend stage signal. The steps are driven by useSimulatedSteps, a paced simulation. It is honest about what kinds of work are happening and dishonest about when each one finishes. If a real progress stream lands, swap the simulation out rather than adding more fake steps.
Theming and dark mode¶
Two layers, kept in sync in app/_layout.tsx:
useThemeStore(Zustand + MMKV) holds the learner's preference:light,dark, orsystem. MMKV reads are synchronous, so the persisted value is available at store creation with no hydration flag dance.resolveThemeScheme()resolvessystemagainst RN'suseColorScheme()into a concrete scheme, which is then fed to bothThemeProviderfrom@kwilo/ui-native(which is nativewind-free and exposes semanticcolors) and NativeWind'scolorScheme.set()(sodark:classes in app code follow the same resolved scheme).
Screens read colors from useTheme() rather than hardcoding. The preference is editable at /settings/appearance.
Relationship to apps/web B2C¶
This is a peer client, not a companion. The mental model to hold: everything a B2C learner does in apps/web they should be able to do here.
Parity (shipped on both)¶
Chat with modes and attachments, study plans (create, detail, edit, lesson player), Practice Hub with the two-face model, live practice sessions and retake-into-review, test prep, artifacts (federated view over AI outputs), presentations, research reports, My Space, conversation history, onboarding, upgrade and subscription, account settings.
Intentionally different¶
- Navigation shape. Web has a sidebar; mobile has five tabs plus a More sheet. Adding a sixth tab was rejected; secondary destinations belong in the sheet.
- Understand routing. On web, "understand" is an intent inside the chat surface. On mobile, an eligible learner is routed to a dedicated
/understandscreen with a grade-then-chapter step flow, because a multi-step picker inside a chat composer is miserable on a phone. - Artifact detail for non-document types. Web can re-fetch; mobile passes already-fetched display fields as query params because there is no backend detail endpoint for presentation / research report / practice test / image artifacts yet.
- Generation waits. The mobile stepped loader has no web equivalent for lessons.
Mobile-only¶
Push notifications (expo-notifications, local and remote, with deep-link-on-tap), the streak badge and milestone banner, the branded intro splash, the More sheet.
Missing / deferred¶
- iOS. No Apple credentials, nothing in the App Store.
- B2B surfaces. No courses, assignments, admin, attendance, or faculty tooling. Deliberate: that is
apps/mobile's job (andapps/web's). - Creator funnel. Not present.
- Server-backed streaks. Client-only today.
Non-goals¶
- Not a B2B app. If a feature only makes sense for an institution, it does not go here.
apps/mobileexists for that. - Not a thin webview wrapper. No
apps/webscreens are embedded. Native routing, native components, native design system. - Not an iOS launch. iOS support is code-level only until Apple credentials and a store listing exist.
- Not a second design system.
@kwilo/ui-nativeis the only source of components and tokens. No raw hex, no per-screen color constants.
Rejected alternatives¶
- Mobile web PWA on
app.kwilo.ai. Cheapest option, and it dies on the retention step: no reliable push, no home-screen presence, no store discovery. The whole reason for the app is the re-entry surface a PWA cannot give us. - Extending
apps/mobile(the B2B companion) with B2C screens. Would have meant one binary serving faculty attendance, face enrollment, and consumer learning. Different audience, different store listing, different onboarding, different everything below the API client. The shared code that mattered (design tokens) was extracted into@kwilo/ui-nativeinstead. - A sixth bottom tab instead of the More sheet. Six tabs on a phone is a row of unreadable icons. The sheet keeps the tab bar to the five things a learner does daily.
- Real backend progress for lesson generation before shipping the loader. Would have blocked the UX fix behind a backend change. The simulated stepped loader ships the honesty about what is happening now; the when can get real later.
- Browser-based Google OAuth (
expo-auth-session). Works, but it bounces the learner out to a browser and back on the very first screen. The native Credential Manager flow is one tap.
Open questions¶
- First-launch vs logged-out-return detection in the auth gate (returning users see
/welcomewhen they should see/login). - Should the streak move server-side? A client-only streak is a lie after a reinstall.
- What is the actual install-to-day-7-return rate? Until that number exists, the Gate answer for validation stays "guess".
Changelog¶
- 2026-07-13: doc created retroactively, after roughly 40 commits had already shipped the app. Captured honestly: this should have been written before the first commit, not after.