mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
5
apps/mobile/.claude/settings.json
Normal file
5
apps/mobile/.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
41
apps/mobile/.gitignore
vendored
Normal file
41
apps/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
3
apps/mobile/AGENTS.md
Normal file
3
apps/mobile/AGENTS.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Expo HAS CHANGED
|
||||
|
||||
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
|
||||
33
apps/mobile/README.md
Normal file
33
apps/mobile/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @siop/mobile — l'app du technicien (R4)
|
||||
|
||||
Expo (SDK 57), offline-first, périmètre fermé : **Ma journée, scan, préventif, synchro** —
|
||||
la gestion reste web, le demandeur reste sur le portail QR public. Maquettes validées :
|
||||
`docs/02-design/maquettes/maquette-r4.html` (+ décisions D1-D5 au journal du 17/07/2026).
|
||||
|
||||
## Lancer
|
||||
|
||||
```bash
|
||||
# API locale d'abord (apps/api : pnpm dev), puis :
|
||||
pnpm --filter @siop/mobile start # QR Expo Go (téléphone sur le MÊME réseau)
|
||||
EXPO_PUBLIC_API_URL=http://192.168.x.y:3000 pnpm --filter @siop/mobile start
|
||||
```
|
||||
|
||||
- `EXPO_PUBLIC_API_URL` : URL de l'API vue **depuis le téléphone** (IP LAN, pas localhost).
|
||||
Défaut : `http://localhost:3000` (suffisant pour `expo start --web`).
|
||||
- Vérification navigateur : `expo start --web` + `CORS_ORIGINS=http://localhost:8081`
|
||||
côté API (les apps natives n'envoient pas d'Origin — CORS ne concerne que le web).
|
||||
|
||||
## Ce que porte R4.1 (socle)
|
||||
|
||||
- Connexion e-mail/mot de passe + **sélecteur démo** (ADR-002 : n'existe que si l'API l'expose).
|
||||
- Coquille tabbar (onglets à venir marqués R4.2/R4.3) + **Ma journée** : OT triés
|
||||
priorité puis échéance (`src/lib/journee.ts`, testé), urgence en tête, pastille de synchro.
|
||||
- **Lecture hors-ligne (D1)** : cache TanStack persisté dans AsyncStorage (7 jours),
|
||||
NetInfo pilote `onlineManager` + bandeau. L'écriture en file arrive en R4.3.
|
||||
- Client typé généré depuis `docs/openapi.json` (`pnpm generate:client`) — règle d'or ADR-001.
|
||||
- Jeton dans SecureStore (trousseau) ; AsyncStorage en repli web de dev.
|
||||
|
||||
## Tests
|
||||
|
||||
`pnpm --filter @siop/mobile test` (jest-expo — logique pure : tri, tokens) ;
|
||||
`typecheck` et le lint racine s'appliquent (job CI `mobile`).
|
||||
31
apps/mobile/app.json
Normal file
31
apps/mobile/app.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "SIOP",
|
||||
"slug": "siop2-mobile",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"expo-secure-store"
|
||||
],
|
||||
"scheme": "siop"
|
||||
}
|
||||
}
|
||||
56
apps/mobile/app/(tabs)/_layout.tsx
Normal file
56
apps/mobile/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Text, type ColorValue } from 'react-native';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** La tabbar de la maquette R4. Les onglets à venir restent visibles
|
||||
* (périmètre annoncé, même patron que la sidebar web) mais mènent à un
|
||||
* écran « disponible en R4.x ». */
|
||||
|
||||
function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) {
|
||||
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
|
||||
}
|
||||
|
||||
export default function CoquilleTabs() {
|
||||
const t = useTokens();
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: t.primaire,
|
||||
tabBarInactiveTintColor: t.encre3,
|
||||
tabBarStyle: { backgroundColor: t.surface, borderTopColor: t.bordure },
|
||||
tabBarLabelStyle: { fontFamily: 'Manrope_700Bold', fontSize: 10 },
|
||||
sceneStyle: { backgroundColor: t.fond },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="journee"
|
||||
options={{
|
||||
title: 'Ma journée',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="scanner"
|
||||
options={{
|
||||
title: 'Scanner',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="preventif"
|
||||
options={{
|
||||
title: 'Préventif',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="synchro"
|
||||
options={{
|
||||
title: 'Synchro',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
240
apps/mobile/app/(tabs)/journee.tsx
Normal file
240
apps/mobile/app/(tabs)/journee.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { router } from 'expo-router';
|
||||
import { FlatList, Pressable, RefreshControl, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
WORK_ORDER_PRIORITY_LABELS,
|
||||
WORK_ORDER_STATUS_LABELS,
|
||||
WORK_ORDER_TYPE_LABELS,
|
||||
type WorkOrderPriority,
|
||||
type WorkOrderStatus,
|
||||
} from '@siop/shared';
|
||||
import { api, unwrap } from '@/api/client';
|
||||
import { useHorsLigne, useLogout, useMe } from '@/auth/session';
|
||||
import { triJournee } from '@/lib/journee';
|
||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||
|
||||
/** Écran 1 de la maquette R4 : les OT du technicien, priorité puis
|
||||
* échéance, « personne bloquée » en tête. Hors-ligne : le cache persisté
|
||||
* sert la liste, le bandeau l'assume (D1). */
|
||||
|
||||
function useWorkOrders() {
|
||||
return useQuery({
|
||||
queryKey: ['work-orders'],
|
||||
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
|
||||
});
|
||||
}
|
||||
|
||||
const STYLE_STATUT: Record<WorkOrderStatus, (t: Tokens) => [string, string]> = {
|
||||
OPEN: (t) => [t.stOuvert, t.stOuvertFond],
|
||||
IN_PROGRESS: (t) => [t.stEncours, t.stEncoursFond],
|
||||
ON_HOLD: (t) => [t.stAttente, t.stAttenteFond],
|
||||
DONE: (t) => [t.stTermine, t.stTermineFond],
|
||||
CANCELLED: (t) => [t.stAnnule, t.stAnnuleFond],
|
||||
};
|
||||
const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
|
||||
PERSON_TRAPPED: (t) => t.prioBloque,
|
||||
HIGH: (t) => t.prioHaute,
|
||||
MEDIUM: (t) => t.prioMoyenne,
|
||||
LOW: (t) => t.prioBasse,
|
||||
NONE: () => 'transparent',
|
||||
};
|
||||
|
||||
export default function PageJournee() {
|
||||
const t = useTokens();
|
||||
const horsLigne = useHorsLigne();
|
||||
const { data: me } = useMe();
|
||||
const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
|
||||
const logout = useLogout();
|
||||
|
||||
const ots = triJournee(workOrders ?? []);
|
||||
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
|
||||
const jour = new Intl.DateTimeFormat('fr-FR', { weekday: 'long', day: 'numeric', month: 'short' })
|
||||
.format(new Date());
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
||||
{/* Entête app : marque + pastille synchro + compte */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<Text style={{ color: t.safran, fontSize: 15 }}>♦</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
|
||||
SIOP
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre3 }}>
|
||||
Technicien
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginLeft: 'auto' }}>
|
||||
<Text
|
||||
accessibilityLabel="État de synchronisation"
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 10.5,
|
||||
color: horsLigne ? t.stAttente : t.stTermine,
|
||||
backgroundColor: horsLigne ? t.stAttenteFond : t.stTermineFond,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{horsLigne ? 'Hors-ligne' : 'Synchro à jour'}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Se déconnecter"
|
||||
onLongPress={() => {
|
||||
void logout().then(() => router.replace('/connexion'));
|
||||
}}
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
backgroundColor: t.stEncours,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 10 }}>
|
||||
{(me?.displayName ?? '·')
|
||||
.split(' ')
|
||||
.map((m) => m[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase()}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||
Ma journée
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
fontSize: 11,
|
||||
color: t.encre3,
|
||||
}}
|
||||
>
|
||||
{jour}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{horsLigne ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: t.stAttenteFond,
|
||||
borderColor: t.stAttente,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 9,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||
⚠ Hors-ligne — liste du{' '}
|
||||
{dataUpdatedAt
|
||||
? new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(dataUpdatedAt)
|
||||
: '…'}
|
||||
. Vos saisies partiront en file (R4.3).
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{urgences.length ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: t.prioBloqueFond,
|
||||
borderRadius: 10,
|
||||
padding: 9,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
|
||||
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
|
||||
{urgences.length === 1
|
||||
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
|
||||
: `${urgences.length} personnes bloquées`}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<FlatList
|
||||
data={ots}
|
||||
keyExtractor={(o) => o.id}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isFetching} onRefresh={() => void refetch()} tintColor={t.primaire} />
|
||||
}
|
||||
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||
ListEmptyComponent={
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||
Aucun OT en cours — tirez pour rafraîchir.
|
||||
</Text>
|
||||
}
|
||||
renderItem={({ item: o }) => {
|
||||
const [enc, fond] = STYLE_STATUT[o.status](t);
|
||||
return (
|
||||
<View
|
||||
accessibilityLabel={`${o.reference} — ${o.title}`}
|
||||
style={{
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: STRIE_PRIORITE[o.priority](t),
|
||||
padding: 11,
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 10.5,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
color: t.encre2,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{o.reference} · {WORK_ORDER_TYPE_LABELS[o.type]}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 10.5,
|
||||
color: enc,
|
||||
backgroundColor: fond,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{WORK_ORDER_STATUS_LABELS[o.status]}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||
{o.title}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||
Asc. {o.assetReference} — {o.siteName}
|
||||
{o.dueDate
|
||||
? ` · échéance ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(o.dueDate))}`
|
||||
: ''}
|
||||
{o.priority !== 'NONE' && o.priority !== 'PERSON_TRAPPED'
|
||||
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
|
||||
: ''}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
11
apps/mobile/app/(tabs)/preventif.tsx
Normal file
11
apps/mobile/app/(tabs)/preventif.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AVenir } from '@/composants/a-venir';
|
||||
|
||||
export default function PagePreventif() {
|
||||
return (
|
||||
<AVenir
|
||||
titre="Préventif"
|
||||
release="R4.2"
|
||||
detail="La grille du mois, cochable au pouce et hors-ligne — chaque coche partira en file individuellement."
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
apps/mobile/app/(tabs)/scanner.tsx
Normal file
11
apps/mobile/app/(tabs)/scanner.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AVenir } from '@/composants/a-venir';
|
||||
|
||||
export default function PageScanner() {
|
||||
return (
|
||||
<AVenir
|
||||
titre="Scanner"
|
||||
release="R4.2"
|
||||
detail="Visez le QR de l'étiquette de cabine (posée en R1) — la fiche s'ouvrira même hors-ligne, sur le parc déjà synchronisé."
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
apps/mobile/app/(tabs)/synchro.tsx
Normal file
11
apps/mobile/app/(tabs)/synchro.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AVenir } from '@/composants/a-venir';
|
||||
|
||||
export default function PageSynchro() {
|
||||
return (
|
||||
<AVenir
|
||||
titre="Synchro"
|
||||
release="R4.3"
|
||||
detail="La file d'attente visible et honnête : vos saisies hors-ligne, rejouées dans l'ordre — et les conflits que VOUS tranchez (verrou optimiste, décision D2)."
|
||||
/>
|
||||
);
|
||||
}
|
||||
77
apps/mobile/app/_layout.tsx
Normal file
77
apps/mobile/app/_layout.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Manrope_400Regular,
|
||||
Manrope_600SemiBold,
|
||||
Manrope_700Bold,
|
||||
Manrope_800ExtraBold,
|
||||
useFonts,
|
||||
} from '@expo-google-fonts/manrope';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
|
||||
import { onlineManager, QueryClient } from '@tanstack/react-query';
|
||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HorsLigneProvider } from '@/auth/session';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Racine de l'app (D1 validée) : le cache TanStack est PERSISTÉ dans
|
||||
* AsyncStorage — les OT lus restent lisibles hors-ligne, y compris après
|
||||
* redémarrage. NetInfo pilote onlineManager (pas de retry dans le vide)
|
||||
* et le drapeau hors-ligne des écrans. */
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60_000,
|
||||
gcTime: 7 * 24 * 3600_000, // une semaine de lecture hors-ligne
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const persister = createAsyncStoragePersister({
|
||||
storage: AsyncStorage,
|
||||
key: 'siop.cache',
|
||||
});
|
||||
|
||||
export default function RacineApp() {
|
||||
const t = useTokens();
|
||||
const [horsLigne, setHorsLigne] = useState(false);
|
||||
const [polices] = useFonts({
|
||||
Manrope_400Regular,
|
||||
Manrope_600SemiBold,
|
||||
Manrope_700Bold,
|
||||
Manrope_800ExtraBold,
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
NetInfo.addEventListener((etat) => {
|
||||
const enLigne = !!etat.isConnected;
|
||||
onlineManager.setOnline(enLigne);
|
||||
setHorsLigne(!enLigne);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!polices) return null;
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister, maxAge: 7 * 24 * 3600_000 }}
|
||||
>
|
||||
<HorsLigneProvider valeur={horsLigne}>
|
||||
<StatusBar style="auto" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: t.fond },
|
||||
}}
|
||||
/>
|
||||
</HorsLigneProvider>
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
150
apps/mobile/app/connexion.tsx
Normal file
150
apps/mobile/app/connexion.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Connexion mobile — mêmes règles que le web : formulaire e-mail/mot de
|
||||
* passe, et le sélecteur démo (< 3 s pour changer de rôle, ADR-002)
|
||||
* UNIQUEMENT si l'API l'expose. */
|
||||
export default function PageConnexion() {
|
||||
const t = useTokens();
|
||||
const [email, setEmail] = useState('');
|
||||
const [motDePasse, setMotDePasse] = useState('');
|
||||
const { data: comptes } = useDemoAccounts();
|
||||
const login = useLogin();
|
||||
const demo = useDemoLogin();
|
||||
|
||||
const entrer = () => router.replace('/(tabs)/journee');
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: t.fond }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView contentContainerStyle={{ padding: 24, paddingTop: 80, gap: 12 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
||||
<Text style={{ color: t.safran, fontSize: 22 }}>♦</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}>
|
||||
SIOP
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre3 }}>
|
||||
Technicien
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', color: t.encre2, marginBottom: 8 }}>
|
||||
Vos ordres de travail, sur le terrain — même sans réseau.
|
||||
</Text>
|
||||
|
||||
<View style={{ gap: 10 }}>
|
||||
<TextInput
|
||||
accessibilityLabel="E-mail"
|
||||
placeholder="E-mail"
|
||||
placeholderTextColor={t.encre3}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
style={{
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordureForte,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 10,
|
||||
padding: 13,
|
||||
color: t.encre,
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
accessibilityLabel="Mot de passe"
|
||||
placeholder="Mot de passe"
|
||||
placeholderTextColor={t.encre3}
|
||||
secureTextEntry
|
||||
value={motDePasse}
|
||||
onChangeText={setMotDePasse}
|
||||
style={{
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordureForte,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 10,
|
||||
padding: 13,
|
||||
color: t.encre,
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
}}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={!email || !motDePasse || login.isPending}
|
||||
onPress={() =>
|
||||
login.mutate({ email, password: motDePasse }, { onSuccess: entrer })
|
||||
}
|
||||
style={{
|
||||
backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire,
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 15 }}>
|
||||
{login.isPending ? 'Connexion…' : 'Se connecter'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{login.isError ? (
|
||||
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 13 }}>
|
||||
{login.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{comptes?.length ? (
|
||||
<View style={{ marginTop: 18, gap: 8 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 11,
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
color: t.encre3,
|
||||
}}
|
||||
>
|
||||
Mode démo — connexion 1 clic
|
||||
</Text>
|
||||
{comptes.map((c) => (
|
||||
<Pressable
|
||||
key={c.id}
|
||||
accessibilityRole="button"
|
||||
disabled={demo.isPending}
|
||||
onPress={() => demo.mutate(c.id, { onSuccess: entrer })}
|
||||
style={{
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: 'Manrope_700Bold', color: t.encre, fontSize: 14 }}>
|
||||
{c.displayName}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', color: t.encre3, fontSize: 12 }}>
|
||||
{c.roleName}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
20
apps/mobile/app/index.tsx
Normal file
20
apps/mobile/app/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
import { useMe } from '@/auth/session';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Aiguillage : session valide → Ma journée ; sinon → connexion.
|
||||
* (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */
|
||||
export default function Aiguillage() {
|
||||
const t = useTokens();
|
||||
const { data: me, isLoading } = useMe();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: t.fond }}>
|
||||
<ActivityIndicator color={t.primaire} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return me ? <Redirect href="/(tabs)/journee" /> : <Redirect href="/connexion" />;
|
||||
}
|
||||
BIN
apps/mobile/assets/android-icon-background.png
Normal file
BIN
apps/mobile/assets/android-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/assets/android-icon-foreground.png
Normal file
BIN
apps/mobile/assets/android-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/mobile/assets/android-icon-monochrome.png
Normal file
BIN
apps/mobile/assets/android-icon-monochrome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/mobile/assets/favicon.png
Normal file
BIN
apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/mobile/assets/icon.png
Normal file
BIN
apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
apps/mobile/assets/splash-icon.png
Normal file
BIN
apps/mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
10
apps/mobile/jest.config.js
Normal file
10
apps/mobile/jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** jest-expo (convention CLAUDE.md) — R4.1 teste la logique pure (tri de
|
||||
* Ma journée, tokens bi-thème) ; les parcours complets passeront par la
|
||||
* recette sur appareil. */
|
||||
module.exports = {
|
||||
preset: 'jest-expo',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testMatch: ['**/*.test.ts'],
|
||||
};
|
||||
48
apps/mobile/package.json
Normal file
48
apps/mobile/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@siop/mobile",
|
||||
"version": "0.1.0",
|
||||
"main": "expo-router/entry",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/manrope": "^0.4.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/netinfo": "12.0.1",
|
||||
"@siop/shared": "workspace:*",
|
||||
"@tanstack/query-async-storage-persister": "^5.101.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-persist-client": "^5.101.2",
|
||||
"expo": "~57.0.6",
|
||||
"expo-constants": "~57.0.5",
|
||||
"expo-font": "~57.0.1",
|
||||
"expo-linking": "~57.0.3",
|
||||
"expo-router": "~57.0.6",
|
||||
"expo-secure-store": "~57.0.1",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"openapi-fetch": "^0.13.8",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-web": "^0.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~19.2.2",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "^57.0.2",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"react-test-renderer": "^19.2.7",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "jest",
|
||||
"generate:client": "openapi-typescript ../../docs/openapi.json -o src/api/schema.d.ts",
|
||||
"lint": "eslint app src"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
31
apps/mobile/src/api/client.ts
Normal file
31
apps/mobile/src/api/client.ts
Normal 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);
|
||||
}
|
||||
22
apps/mobile/src/api/jeton.ts
Normal file
22
apps/mobile/src/api/jeton.ts
Normal 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
4112
apps/mobile/src/api/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
77
apps/mobile/src/auth/session.tsx
Normal file
77
apps/mobile/src/auth/session.tsx
Normal 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>;
|
||||
}
|
||||
40
apps/mobile/src/composants/a-venir.tsx
Normal file
40
apps/mobile/src/composants/a-venir.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
apps/mobile/src/lib/journee.test.ts
Normal file
44
apps/mobile/src/lib/journee.test.ts
Normal 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 d’abord, 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']);
|
||||
});
|
||||
});
|
||||
32
apps/mobile/src/lib/journee.ts
Normal file
32
apps/mobile/src/lib/journee.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
20
apps/mobile/src/theme/tokens.test.ts
Normal file
20
apps/mobile/src/theme/tokens.test.ts
Normal 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}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
80
apps/mobile/src/theme/tokens.ts
Normal file
80
apps/mobile/src/theme/tokens.ts
Normal 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;
|
||||
}
|
||||
10
apps/mobile/tsconfig.json
Normal file
10
apps/mobile/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"types": ["jest"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user