Skip to content

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, /profile Source 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, so org_unit_id is 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.ai for dev/preview builds, api.kwilo.ai for 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 returned id_token's aud is 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 an intent param. The one exception: understand, for a learner eligible for Understand routing, goes to the dedicated /understand router 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_KIND in components/chat/constants.ts maps 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) in components/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:

  1. useThemeStore (Zustand + MMKV) holds the learner's preference: light, dark, or system. MMKV reads are synchronous, so the persisted value is available at store creation with no hydration flag dance.
  2. resolveThemeScheme() resolves system against RN's useColorScheme() into a concrete scheme, which is then fed to both ThemeProvider from @kwilo/ui-native (which is nativewind-free and exposes semantic colors) and NativeWind's colorScheme.set() (so dark: 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 /understand screen 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 (and apps/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/mobile exists for that.
  • Not a thin webview wrapper. No apps/web screens 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-native is 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-native instead.
  • 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 /welcome when 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.