EngineeringMobile client (dev)

Mobile client (dev)

The NexSchool mobile app is an Expo / React Native client (client/) for teachers, students, and school admins — tenant-scoped, role-adaptive, iOS + Android.

App structure

Expo ~54 with Expo Router 6 (file-based routing), React Native 0.81, React 19, TypeScript, TanStack React Query 5 for server state, Zod 4 for validation, and i18next for localization. Token and settings persistence uses Expo Async Storage; file/photo uploads use Expo Document Picker and Image Picker; push handling uses Expo Notifications.

Two top-level trees:

  • client/app/ — Expo Router routes, split into (auth)/ (public) and (protected)/ (authenticated) route groups.
  • client/modules/ — feature modules (attendance, students, teachers, classes, subjects, timetable, finance, transport, hostel, profile, notifications, devices, announcements, student-leaves, teacher-leaves, search, audit, dashboard, permissions, …).
  • client/common/ — cross-cutting code: services/api.ts (HTTP wrapper), constants/ (api.ts base URL, navigation.ts tab definitions), components/, theme/, forms/, shared hooks/ and types/.

Module pattern

Each feature module follows the same layout, so a new module is predictable:

modules/<name>/
├── screens/       <FeatureName>Screen.tsx
├── hooks/         use<Feature>.ts  (React Query hooks)
├── services/      <feature>Service.ts  (apiGet/apiPost calls)
├── types.ts
└── components/    module-specific components

Screens are thin: a service calls the API, a hook wraps it in useQuery/useMutation, and the screen renders the hook’s data. Admin light-CRUD screens use shared RHF form primitives in common/forms/ (FormField, FormSelect, FormDatePicker, FormTextArea, FormSection) built on react-hook-form + @hookform/resolvers.

Design system

The app uses the nexchool “Academic Fluidity” design system. Theme tokens live in client/common/theme/ (tokens.ts, palettes.ts light/dark, typography.ts — 11 Inter roles, elevation.ts, motion.ts) and are consumed through useTheme(). Never hard-code hex, fontSize, or fontFamily — a scoped no-restricted-syntax ESLint rule bans raw literals in modules/** and app/**. Blessed primitives in common/components/ include <Text variant color>, <AppIcon>, <PressScale>, Button, Input, ScreenContainer, EmptyState, Skeleton, plus selection/date primitives (BottomSheet/FieldTrigger, custom themed DatePicker/DateRangePicker, SelectSheet/MultiSelectSheet, FilterChips).

Three navigation types compose the app:

  1. Bottom tab bar (BottomTabBar) — 4 tabs: Home / Schedule / Notifications / Profile, with a pill-shaped active state.
  2. Sidebar drawer (AppDrawer) — slide-from-left drawer with a profile card and an RBAC-filtered module list grouped into labeled sections (People / Academics / Operations / Communication / Admin); empty sections hide after role/flag filtering. Footer: Settings / Help / Sign out.
  3. Stack navigation — detail screens pushed on top (e.g. students/[id], finance/invoices/[id]).

The protected route tree renders inside client/common/components/chrome/AppShell, which composes AppHeader (sticky: hamburger, school name, academic-year chip for admin/teacher, notification bell, global-search icon for admin/teacher), BottomTabBar, AppDrawer, and AcademicYearSheet. MainLayout.tsx mounts <AppShell> around the current router Slot. Because AppShell / AppHeader own the top inset, screen bodies must not add their own SafeAreaView (a second one causes a phantom top gap).

Dynamic tab & drawer visibility

Navigation entries are gated by both user permissions and plan features, defined in common/constants/navigation.ts:

export const ALL_TABS = [
  {
    name: 'students',
    label: 'Students',
    icon: 'people',
    requiredPermissions: ['student.read'],
    requiredPlanFeature: 'student_management',
  },
  // ...
]
 
export function getVisibleTabs(
  permissions: string[],
  enabledFeatures: string[]
): Tab[] {
  return ALL_TABS.filter(tab => {
    const hasPermission = tab.requiredPermissions.some(p => permissions.includes(p))
    const hasFeature = !tab.requiredPlanFeature ||
      enabledFeatures.includes(tab.requiredPlanFeature)
    return hasPermission && hasFeature
  })
}

day_of_week in timetable routes is 0-indexed (0=Mon .. 6=Sun).

API client & auth

All HTTP goes through client/common/services/api.ts, which exposes typed helpers: apiGet, apiPost, apiPostForm, apiPut, apiDelete. Every request auto-injects auth and tenant headers, and the response envelope is unwrapped to return json.data.

const BASE_URL = process.env.EXPO_PUBLIC_API_URL
 
async function getHeaders() {
  const token = await AsyncStorage.getItem('access_token')
  const tenantId = await AsyncStorage.getItem('tenant_id')
  const refreshToken = await AsyncStorage.getItem('refresh_token')
  return {
    'Authorization': `Bearer ${token}`,
    'X-Tenant-ID': tenantId,
    'X-Refresh-Token': refreshToken,
    'Content-Type': 'application/json',
  }
}
 
async function handleResponse<T>(response: Response): Promise<T> {
  if (response.status === 401) {
    const newToken = await refreshAccessToken()
    if (!newToken) {
      clearAuthStorage()
      router.replace('/login')
      throw new Error('Session expired')
    }
  }
  const json = await response.json()
  if (!json.success) throw new Error(json.message)
  return json.data as T
}

Notes:

  • Tenant isolation on mobile is the X-Tenant-ID header, not a query-key wrapper (the tenant-in-query-key wrapper is an admin-web / panel concern only). Plain useQuery with a module key is the norm here.
  • apiPostForm deletes Content-Type so the platform sets the multipart boundary — used for file/photo uploads.
  • A 401 triggers a token refresh and retry; if refresh fails, auth storage is cleared and the user is routed to /login.

Auth flow

Token and session state persist in Expo Async Storage; tokens are read on every request via getHeaders().

App launch (app/_layout.tsx)
  └─ Check AsyncStorage for access_token
       ├─ No token  → (auth)/login
       └─ Has token → (protected)/home

Login:
  apiPost('/api/auth/login', { email, password, tenant_id })
  └─ Store access_token, refresh_token, tenant_id, user,
     permissions, enabled_features
  └─ Navigate to (protected)/home

Protected routes:
  Each request auto-injects Authorization + X-Tenant-ID
  401 → refresh token → retry
  Refresh fails → clear storage → login

The (auth)/ group hosts login, register, forgot-password, reset-password, verify-email. Change-password lives under the protected profile tree (profile/change-password.tsxPOST /api/auth/password/change).

Role-adaptive UI

The same routes render different content per role — most top-level screens are role dispatchers:

  • app/(protected)/home.tsx dispatches to AdminHome, TeacherHome, or StudentHome (modules/home/components/), each fed by a different dashboard endpoint (GET /api/dashboard/ for admin; teacher/student home data endpoints).
  • profile.tsx dispatches student → MyProfileScreen, admin/teacher → StaffProfileScreen, unknown role → empty state.
  • finance/index.tsx, timetable, my-leaves, my-attendance, and others dispatch by role similarly.

Two enforcement layers keep this honest:

  • Server-side scoping — endpoints return only what the caller may see (e.g. GET /api/subjects/mine returns all subjects for admin, taught subjects for a teacher, own-class subjects for a student; GET /api/search scopes each result group by permission). The client just renders the response; it does not attempt client-side row filtering it lacks permissions to compute.
  • Client-side gating — drawer/tab entries filter on requiredPermissions + requiredPlanFeature; individual screens self-guard with hasPermission(...) and isFeatureEnabled(...). Permission string constants are mirrored in modules/permissions/constants/permissions.ts.

Screens render only fields actually present on the API response — cards and rows with no value are omitted rather than fabricated, and mockup sections without a real data source are intentionally not built.

Push notifications

Push handling uses Expo Notifications, with device registration in modules/devices/. The in-app inbox lives at app/(protected)/notifications/index.tsx (modules/notifications/):

  • A SectionList buckets entries into Today / Yesterday / Earlier via notificationGrouping.bucketByDate (empty buckets omitted).
  • Two chip rows in NotificationFilterChips: status (All / Unread) and category (All / Announcements / Fees / Leaves / System) derived from the notification type prefix via categoryOf().
  • NotificationRow wraps unread rows in a react-native-gesture-handler Swipeable with a right “Mark read” action (read rows render plain; leading 8px unread dot). This requires <GestureHandlerRootView> as the outermost wrapper in app/_layout.tsx.
  • The header bell shows an unread badge via useUnreadNotificationsBadge.

Announcements (modules/announcements/) are a related but separate surface: admins compose/schedule/recall; all roles read a published/recalled inbox. Attachments upload via expo-document-picker to S3 endpoints and open through presigned URLs (Linking.openURL).

Releases (Expo OTA / store)

Production builds use EAS (Expo Application Services):

eas build --platform ios
eas build --platform android
 
# Local build (requires Xcode / Android Studio)
npx expo run:ios
npx expo run:android

Running locally:

cd client
npm install
npm start            # Expo dev server — press 'i' (iOS) / 'a' (Android), or scan QR (Expo Go)

Point EXPO_PUBLIC_API_URL at your running API:

  • Android emulator: http://10.0.2.2/api
  • iOS simulator: http://localhost/api
  • Physical device: http://192.168.x.x/api (your LAN IP)