Papillon is built with React Native and Expo, following a modular architecture that separates concerns between UI, business logic, data persistence, and external service integrations.
Project Structure
Here’s an overview of the main directories in the Papillon codebase:
Papillon/
├── app/ # Expo Router screens and layouts
│ ├── (tabs)/ # Bottom tab navigation screens
│ ├── (settings)/ # Settings screens
│ ├── (onboarding)/ # Onboarding flow
│ ├── (features)/ # Feature-specific screens
│ ├── (modals)/ # Modal screens
│ └── _layout.tsx # Root layout
├── services/ # External service integrations
│ ├── pronote/ # PRONOTE integration
│ ├── skolengo/ # Skolengo integration
│ ├── ecoledirecte/ # EcoleDirecte integration
│ └── shared/ # Shared types and interfaces
├── database/ # WatermelonDB schemas and hooks
│ ├── models/ # Database models
│ ├── mappers/ # API to DB mappers
│ └── schema.ts # Database schema
├── stores/ # Zustand state management
│ ├── account/ # Account state
│ ├── settings/ # App settings
│ ├── global/ # Global app state
│ └── ...
├── components/ # Reusable React components
├── ui/ # UI component library (Papillon UI)
│ ├── components/ # Base UI components
│ ├── hooks/ # UI-specific hooks
│ └── utils/ # UI utilities
├── utils/ # Utility functions
├── constants/ # App constants
├── hooks/ # Custom React hooks
├── locales/ # i18n translations
└── assets/ # Images, fonts, and other assets
Core Concepts
1. Expo Router (File-based Routing)
Papillon uses Expo Router for navigation, which provides file-based routing similar to Next.js:
// app/(tabs)/index.tsx → Home screen
// app/(settings)/profile.tsx → Profile settings
// app/[id].tsx → Dynamic route with ID parameter
The app/ directory structure defines your navigation:
- (tabs): Bottom tab navigation
- (settings): Settings stack
- (modals): Modal presentations
- _layout.tsx: Layout wrappers
Expo Router uses typed routes (configured in app.config.ts) for type-safe navigation.
2. Service Architecture
Papillon supports multiple school management systems through a plugin-based service architecture.
Service Plugin Interface
All services implement the SchoolServicePlugin interface:
export interface SchoolServicePlugin {
displayName: string; // User-facing name
service: Services; // Service identifier enum
capabilities: Capabilities[]; // Supported features
authData: Auth; // Authentication data
session: any; // Service-specific session
// Core methods
refreshAccount: (credentials: Auth) => Promise<ServicePlugin>;
// Optional feature methods
getHomeworks?: (weekNumber: number) => Promise<Homework[]>;
getNews?: () => Promise<News[]>;
getGradesForPeriod?: (period: Period) => Promise<PeriodGrades>;
getWeeklyTimetable?: (weekNumber: number, date: Date) => Promise<CourseDay[]>;
// ... more optional methods
}
Capabilities System
Each service declares its capabilities:
export enum Capabilities {
REFRESH, // Can refresh session
HOMEWORK, // Supports homework
NEWS, // Supports news/announcements
GRADES, // Supports grades
ATTENDANCE, // Supports attendance
CANTEEN_MENU, // Supports canteen menus
CHAT_READ, // Can read messages
CHAT_CREATE, // Can create new chats
CHAT_REPLY, // Can reply to messages
TIMETABLE, // Supports timetable
// ... more capabilities
}
Example: PRONOTE Service
export class Pronote implements SchoolServicePlugin {
displayName = "PRONOTE";
service = Services.PRONOTE;
capabilities: Capabilities[] = [Capabilities.REFRESH];
session: SessionHandle | undefined;
authData: Auth = {};
async refreshAccount(credentials: Auth): Promise<Pronote> {
const refresh = await refreshPronoteAccount(this.accountId, credentials);
this.authData = refresh.auth;
this.session = refresh.session;
// Add capabilities based on user permissions
for (const tab of this.session.user.authorizations.tabs) {
// Map tabs to capabilities
}
return this;
}
async getHomeworks(weekNumber: number): Promise<Homework[]> {
await this.checkTokenValidty();
if (this.session) {
return fetchPronoteHomeworks(this.session, this.accountId, weekNumber);
}
error("Session is not valid", "Pronote.getHomeworks");
}
// ... more methods
}
3. Database Layer (WatermelonDB)
Papillon uses WatermelonDB for local data persistence with reactive queries.
Database Schema
Schemas are defined in database/schema.ts:
export const schema = appSchema({
version: 1,
tables: [
tableSchema({
name: 'homeworks',
columns: [
{ name: 'account_id', type: 'string', isIndexed: true },
{ name: 'subject_name', type: 'string' },
{ name: 'description', type: 'string' },
{ name: 'due_date', type: 'number' },
{ name: 'done', type: 'boolean' },
// ... more columns
]
}),
// ... more tables
]
});
Database Hooks
Custom hooks provide reactive access to data:
// database/useHomework.ts
export const useHomework = (accountId: string, weekNumber: number) => {
const database = useDatabase();
const [homeworks, setHomeworks] = useState<Homework[]>([]);
useEffect(() => {
const subscription = database
.get('homeworks')
.query(Q.where('account_id', accountId))
.observe()
.subscribe(setHomeworks);
return () => subscription.unsubscribe();
}, [accountId]);
return homeworks;
};
Data Flow
- Fetch from API: Service methods retrieve data from external APIs
- Map to Database: Mapper functions convert API responses to DB models
- Store in DB: Data is persisted using WatermelonDB
- Reactive Queries: Components subscribe to database changes
- Auto-update UI: UI updates automatically when data changes
4. State Management (Zustand)
Papillon uses Zustand for global state management.
Account Store Example
// stores/account/index.ts
import { create } from 'zustand';
interface AccountState {
accounts: Account[];
currentAccount: Account | null;
addAccount: (account: Account) => void;
removeAccount: (id: string) => void;
switchAccount: (id: string) => void;
}
export const useAccountStore = create<AccountState>((set) => ({
accounts: [],
currentAccount: null,
addAccount: (account) =>
set((state) => ({
accounts: [...state.accounts, account]
})),
removeAccount: (id) =>
set((state) => ({
accounts: state.accounts.filter(a => a.id !== id)
})),
switchAccount: (id) =>
set((state) => ({
currentAccount: state.accounts.find(a => a.id === id)
})),
}));
Usage in Components
import { useAccountStore } from '@/stores/account';
function AccountSelector() {
const { accounts, currentAccount, switchAccount } = useAccountStore();
return (
<View>
{accounts.map(account => (
<Pressable
key={account.id}
onPress={() => switchAccount(account.id)}
>
<Text>{account.name}</Text>
</Pressable>
))}
</View>
);
}
5. UI Components (Papillon UI)
The ui/ directory contains Papillon’s design system.
Component Structure
// ui/components/Button.tsx
import { Pressable, Text } from 'react-native';
import { useTheme } from '@/ui/hooks/useTheme';
export function Button({ title, onPress, variant = 'primary' }) {
const theme = useTheme();
return (
<Pressable
style={[styles.button, { backgroundColor: theme.colors[variant] }]}
onPress={onPress}
>
<Text style={styles.text}>{title}</Text>
</Pressable>
);
}
Data Flow Example
Here’s how data flows through the app when fetching homework:
User requests homework
Component calls useHomework(accountId, weekNumber) hook
Check cache
Hook queries WatermelonDB for cached homework
Fetch from service if needed
If cache is stale, fetch from service:const service = getService(account.service); // e.g., Pronote
const homework = await service.getHomeworks(weekNumber);
Map and store
Mapper converts API response to database models:const mapped = homework.map(hw => mapHomeworkToDB(hw, accountId));
await database.batch(...mapped.map(hw => hw.prepareCreate()));
UI updates automatically
WatermelonDB notifies observers, React components re-render with new data
Key Technologies
React Native & Expo
- React Native: 0.81.5 with New Architecture enabled
- Expo SDK: ~54.0.32
- Expo Router: File-based routing with typed routes
- Expo Modules: Native module development
Database & State
- WatermelonDB: High-performance reactive database
- Zustand: Lightweight state management
- MMKV: Fast key-value storage
UI & Animation
- React Native Reanimated: 60 FPS animations
- React Native Skia: Advanced graphics
- React Native Gesture Handler: Native gesture handling
- Lucide React Native: Icon library
- TypeScript: Strict mode enabled
- ESLint: Code linting with custom rules
- Prettier: Code formatting
- Jest: Testing framework
Best Practices
Import Organization
Imports are automatically sorted by eslint-plugin-simple-import-sort:
// 1. External dependencies
import { View, Text } from 'react-native';
import { useEffect, useState } from 'react';
// 2. Internal imports with @ alias
import { Pronote } from '@/services/pronote';
import { useAccountStore } from '@/stores/account';
import { Button } from '@/ui/components/Button';
// 3. Relative imports
import { helper } from './helper';
Error Handling
Use the logger utility instead of console:
import { error, warn, info } from '@/utils/logger/logger';
error('Session is not valid', 'Pronote.getHomeworks');
warn('Cache is stale, refetching');
info('Account refreshed successfully');
Type Safety
- Use TypeScript strict mode
- Define interfaces for all data structures
- Use type guards for runtime checks
- Leverage Expo Router’s typed routes
Optimization Strategies
- Lazy Loading: Use React.lazy() and Suspense for code splitting
- Memoization: Use React.memo, useMemo, and useCallback appropriately
- FlashList: Use @shopify/flash-list instead of FlatList for long lists
- Reanimated: Keep animations on UI thread with Reanimated worklets
- Database Indexing: Index frequently queried columns in WatermelonDB
React Native New Architecture
Papillon uses React Native’s New Architecture:
- TurboModules: Faster native module initialization
- Fabric: New rendering system
- JSI: Direct JavaScript-to-native communication
The New Architecture is enabled in app.config.ts with newArchEnabled: true.
Next Steps