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:
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" />;
|
||||
}
|
||||
Reference in New Issue
Block a user