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,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>
);
}