feat(r4.1): socle mobile Expo — connexion démo, Ma journée, lecture hors-ligne

apps/mobile (Expo SDK 57, TS strict, workspace pnpm) : connexion
e-mail/mdp + sélecteur démo ADR-002 (uniquement si l'API l'expose),
tabbar de la maquette validée (onglets futurs marqués R4.2/R4.3),
« Ma journée » triée priorité puis échéance (fonction pure testée),
urgence en tête, pastille de synchro.

D1 en actes (lecture) : cache TanStack persisté dans AsyncStorage
(7 jours), NetInfo → onlineManager + bandeau hors-ligne horodaté ;
jeton en SecureStore ; tokens light/dark répliqués et testés ; client
typé régénéré depuis docs/openapi.json (règle d'or).

API : CORS_ORIGINS opt-in (vide par défaut) — Expo web/debug seulement,
le web de prod reste derrière le proxy, les apps natives n'ont pas
d'Origin.

Vérifié 10/10 en Expo web piloté (connexion démo Ahmed → Ma journée
scopée → hors-ligne servie du cache → retour) ; 6 tests jest-expo ;
lint racine étendu (react-hooks) ; job CI mobile, deploy en dépend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-17 03:12:34 +01:00
parent 2736a2f3a1
commit 5c8d5aac92
39 changed files with 9873 additions and 22 deletions

View File

@@ -0,0 +1,31 @@
import createClient from 'openapi-fetch';
import type { paths } from './schema';
import { lireJeton } from './jeton';
/** Client typé — généré depuis docs/openapi.json (règle d'or ADR-001 :
* spec committée, clients régénérés dans le même commit).
* EXPO_PUBLIC_API_URL pointe l'API (IP LAN pour Expo Go sur téléphone). */
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
export const api = createClient<paths>({ baseUrl: API_URL });
api.use({
async onRequest({ request }) {
const jeton = await lireJeton();
if (jeton) request.headers.set('Authorization', `Bearer ${jeton}`);
return request;
},
});
export async function unwrap<T>(res: {
data?: T;
error?: unknown;
response: Response;
}): Promise<T> {
if (res.data !== undefined) return res.data;
const message =
res.error && typeof res.error === 'object' && 'message' in res.error
? String((res.error as { message: unknown }).message)
: `API injoignable ou refus (${res.response.status})`;
throw new Error(message);
}

View File

@@ -0,0 +1,22 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
/** Le jeton vit dans le trousseau du téléphone (SecureStore) ; sur web
* (vérifications de dev seulement), repli AsyncStorage. */
const CLE = 'siop.jeton';
const natif = Platform.OS !== 'web';
export async function lireJeton(): Promise<string | null> {
return natif ? SecureStore.getItemAsync(CLE) : AsyncStorage.getItem(CLE);
}
export async function ecrireJeton(jeton: string): Promise<void> {
if (natif) await SecureStore.setItemAsync(CLE, jeton);
else await AsyncStorage.setItem(CLE, jeton);
}
export async function effacerJeton(): Promise<void> {
if (natif) await SecureStore.deleteItemAsync(CLE);
else await AsyncStorage.removeItem(CLE);
}

4112
apps/mobile/src/api/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createContext, useContext, type ReactNode } from 'react';
import { api, unwrap } from '@/api/client';
import { ecrireJeton, effacerJeton } from '@/api/jeton';
/** Session mobile — mêmes règles que le web : le sélecteur démo n'existe
* que si l'API répond (ADR-002 : 404 sinon, une seule source de vérité). */
export function useMe() {
return useQuery({
queryKey: ['me'],
retry: false,
queryFn: async () => unwrap(await api.GET('/users/me')),
});
}
export function useDemoAccounts() {
return useQuery({
queryKey: ['demo-accounts'],
retry: false,
queryFn: async () => {
const res = await api.GET('/auth/demo-accounts');
if (res.response.status === 404) return null; // démo désactivée
return (await unwrap(res)).accounts;
},
});
}
function useApresConnexion() {
const queryClient = useQueryClient();
return async (accessToken: string) => {
await ecrireJeton(accessToken);
await queryClient.invalidateQueries();
};
}
export function useLogin() {
const apres = useApresConnexion();
return useMutation({
mutationFn: async (input: { email: string; password: string }) => {
const res = await unwrap(await api.POST('/auth/login', { body: input }));
await apres(res.accessToken);
},
});
}
export function useDemoLogin() {
const apres = useApresConnexion();
return useMutation({
mutationFn: async (userId: string) => {
const res = await unwrap(await api.POST('/auth/demo-login', { body: { userId } }));
await apres(res.accessToken);
},
});
}
export function useLogout() {
const queryClient = useQueryClient();
return async () => {
await effacerJeton();
queryClient.clear();
};
}
/** Drapeau hors-ligne partagé (pastille topbar + bandeaux d'écrans). */
export const HorsLigneContexte = createContext(false);
export const useHorsLigne = () => useContext(HorsLigneContexte);
export function HorsLigneProvider({
valeur,
children,
}: {
valeur: boolean;
children: ReactNode;
}) {
return <HorsLigneContexte.Provider value={valeur}>{children}</HorsLigneContexte.Provider>;
}

View File

@@ -0,0 +1,40 @@
import { Text, View } from 'react-native';
import { useTokens } from '@/theme/tokens';
/** Écran futur du périmètre R4 annoncé — même patron que la sidebar web :
* visible, honnête sur sa release, jamais un cul-de-sac silencieux. */
export function AVenir({ titre, release, detail }: { titre: string; release: string; detail: string }) {
const t = useTokens();
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 8 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 18, color: t.encre }}>
{titre}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 11,
color: t.safran,
backgroundColor: t.safranDoux,
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 999,
overflow: 'hidden',
}}
>
Disponible en {release}
</Text>
<Text
style={{
fontFamily: 'Manrope_400Regular',
fontSize: 13,
color: t.encre2,
textAlign: 'center',
maxWidth: 280,
}}
>
{detail}
</Text>
</View>
);
}

View File

@@ -0,0 +1,44 @@
import { triJournee, type OtJournee } from './journee';
const ot = (sur: Partial<OtJournee>): OtJournee => ({
priority: 'NONE',
status: 'OPEN',
dueDate: null,
createdAt: '2026-07-01T08:00:00.000Z',
...sur,
});
describe('triJournee (écran 1 de la maquette R4)', () => {
it('met « personne bloquée » en tête, puis la priorité décroissante', () => {
const tri = triJournee([
ot({ priority: 'LOW' }),
ot({ priority: 'PERSON_TRAPPED' }),
ot({ priority: 'HIGH' }),
ot({ priority: 'MEDIUM' }),
]);
expect(tri.map((o) => o.priority)).toEqual(['PERSON_TRAPPED', 'HIGH', 'MEDIUM', 'LOW']);
});
it('à priorité égale : échéance la plus proche dabord, sans échéance en dernier', () => {
const tri = triJournee([
ot({ priority: 'HIGH', dueDate: null, createdAt: '2026-07-01T08:00:00.000Z' }),
ot({ priority: 'HIGH', dueDate: '2026-07-20T00:00:00.000Z' }),
ot({ priority: 'HIGH', dueDate: '2026-07-18T00:00:00.000Z' }),
]);
expect(tri.map((o) => o.dueDate)).toEqual([
'2026-07-18T00:00:00.000Z',
'2026-07-20T00:00:00.000Z',
null,
]);
});
it('écarte les OT terminés et annulés — la journée ne montre que là-faire', () => {
const tri = triJournee([
ot({ status: 'DONE' }),
ot({ status: 'IN_PROGRESS' }),
ot({ status: 'CANCELLED' }),
ot({ status: 'ON_HOLD' }),
]);
expect(tri.map((o) => o.status)).toEqual(['IN_PROGRESS', 'ON_HOLD']);
});
});

View File

@@ -0,0 +1,32 @@
import type { WorkOrderPriority, WorkOrderStatus } from '@siop/shared';
/** Tri de « Ma journée » (maquette R4, écran 1) : priorité décroissante
* (personne bloquée en tête) puis échéance la plus proche ; les OT clos
* ou annulés n'apparaissent pas. Fonction pure — testée. */
const POIDS: Record<WorkOrderPriority, number> = {
PERSON_TRAPPED: 4,
HIGH: 3,
MEDIUM: 2,
LOW: 1,
NONE: 0,
};
export interface OtJournee {
priority: WorkOrderPriority;
status: WorkOrderStatus;
dueDate: string | null;
createdAt: string;
}
export function triJournee<T extends OtJournee>(ots: readonly T[]): T[] {
return ots
.filter((o) => o.status !== 'DONE' && o.status !== 'CANCELLED')
.sort(
(a, b) =>
POIDS[b.priority] - POIDS[a.priority] ||
(a.dueDate ? Date.parse(a.dueDate) : Infinity) -
(b.dueDate ? Date.parse(b.dueDate) : Infinity) ||
Date.parse(a.createdAt) - Date.parse(b.createdAt),
);
}

View File

@@ -0,0 +1,20 @@
import { CLAIR, SOMBRE } from './tokens';
describe('tokens SIOP portés en RN', () => {
it('le sombre couvre exactement les clés du clair (pas de token orphelin)', () => {
expect(Object.keys(SOMBRE).sort()).toEqual(Object.keys(CLAIR).sort());
});
it('réplique les valeurs pivots de docs/02-design/tokens.css', () => {
expect(CLAIR.primaire).toBe('#1f4fb8');
expect(CLAIR.safran).toBe('#dd8a0b');
expect(SOMBRE.fond).toBe('#0f1622');
expect(SOMBRE.primaire).toBe('#6e92e8');
});
it('chaque valeur est une couleur hex ou transparent', () => {
for (const jeu of [CLAIR, SOMBRE]) {
for (const v of Object.values(jeu)) expect(v).toMatch(/^#[0-9a-f]{6}$/);
}
});
});

View File

@@ -0,0 +1,80 @@
import { useColorScheme } from 'react-native';
/** Tokens SIOP portés en RN — répliques de docs/02-design/tokens.css
* (mêmes valeurs que le web et les maquettes R4, bi-thème). */
export const CLAIR = {
primaire: '#1f4fb8',
primaireDoux: '#eaf0fb',
safran: '#dd8a0b',
safranDoux: '#fdf3e3',
fond: '#f5f7fa',
surface: '#ffffff',
surface2: '#eef2f7',
bordure: '#dce3ec',
bordureForte: '#b9c4d4',
encre: '#1b2534',
encre2: '#55647a',
encre3: '#8494ab',
stOuvert: '#3d6fe0',
stOuvertFond: '#e9effc',
stEncours: '#6d5bd8',
stEncoursFond: '#efecfa',
stAttente: '#b96f07',
stAttenteFond: '#fbf1df',
stTermine: '#178a50',
stTermineFond: '#e6f5ec',
stAnnule: '#68788f',
stAnnuleFond: '#edf0f4',
prioBloque: '#d92626',
prioBloqueFond: '#fdeaea',
prioHaute: '#d92626',
prioMoyenne: '#b96f07',
prioBasse: '#178a50',
prioAucune: '#8494ab',
succes: '#178a50',
alerte: '#b96f07',
danger: '#d92626',
navFond: '#16233b',
} as const;
export const SOMBRE: Tokens = {
primaire: '#6e92e8',
primaireDoux: '#1d2c4c',
safran: '#e89a1f',
safranDoux: '#33270f',
fond: '#0f1622',
surface: '#182234',
surface2: '#1e2a40',
bordure: '#2b3850',
bordureForte: '#3d4d6b',
encre: '#e8edf5',
encre2: '#a7b4c8',
encre3: '#6d7d96',
stOuvert: '#7da2ee',
stOuvertFond: '#1c2a47',
stEncours: '#a394ec',
stEncoursFond: '#262040',
stAttente: '#e0a33c',
stAttenteFond: '#322510',
stTermine: '#4bc084',
stTermineFond: '#12301f',
stAnnule: '#93a3ba',
stAnnuleFond: '#222d3f',
prioBloque: '#f26d6d',
prioBloqueFond: '#3a1414',
prioHaute: '#f26d6d',
prioMoyenne: '#e0a33c',
prioBasse: '#4bc084',
prioAucune: '#6d7d96',
succes: '#4bc084',
alerte: '#e0a33c',
danger: '#f26d6d',
navFond: '#0c1524',
};
export type Tokens = { [K in keyof typeof CLAIR]: string };
export function useTokens(): Tokens {
return useColorScheme() === 'dark' ? SOMBRE : CLAIR;
}