feat(mobile): refonte design DSFR complète + partie admin

- écrans auth/dashboard/journal/info/urgence/profil refondus sur le design system
- AppBar institutionnel République Française sur tous les écrans
- partie admin (utilisateurs, contenus, émotions) avec AdminGuard
- entrée Espace administration dans le profil (admins uniquement)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Azmog
2026-06-19 15:22:15 +02:00
parent 2e96e07c7e
commit 7fd27d8dc2
22 changed files with 2102 additions and 910 deletions
+2
View File
@@ -47,6 +47,7 @@ export default function TabLayout() {
name="urgence" name="urgence"
options={{ options={{
title: 'Urgence', title: 'Urgence',
headerShown: false,
tabBarIcon: ({ color, size }) => <Ionicons name="call" size={size} color={color} />, tabBarIcon: ({ color, size }) => <Ionicons name="call" size={size} color={color} />,
}} }}
/> />
@@ -54,6 +55,7 @@ export default function TabLayout() {
name="profile" name="profile"
options={{ options={{
title: 'Profil', title: 'Profil',
headerShown: false,
tabBarIcon: ({ color, size }) => <Ionicons name="person" size={size} color={color} />, tabBarIcon: ({ color, size }) => <Ionicons name="person" size={size} color={color} />,
}} }}
/> />
+102 -68
View File
@@ -1,126 +1,160 @@
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; import { View, Text, StyleSheet, ScrollView } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { useRapport } from '@/hooks/useRapport'; import { useRapport } from '@/hooks/useRapport';
import { useSaisies } from '@/hooks/useTracker'; import { useSaisies } from '@/hooks/useTracker';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import RepubliqueHeader from '@/components/RepubliqueHeader'; import { Spacing, Radius, FontSize, FontWeight, MIN_TOUCH } from '@/lib/theme';
import { AppBar, Card, Badge, SectionTitle, EmptyState, Loader } from '@/components/ui';
export default function DashboardScreen() { export default function DashboardScreen() {
const router = useRouter(); const router = useRouter();
const { user } = useAuthStore(); const { user } = useAuthStore();
const { data: rapport } = useRapport('month'); const { data: rapport } = useRapport('month');
const { data: saisies } = useSaisies(); const { data: saisies, isLoading } = useSaisies();
const recentSaisies = (saisies || []).slice(0, 3); const recentSaisies = (saisies || []).slice(0, 3);
return ( return (
<View style={styles.screen}> <View style={styles.screen}>
<RepubliqueHeader /> <AppBar
title={`Bonjour ${user?.prenom || ''}`.trim()}
subtitle="Comment allez-vous aujourd'hui ?"
/>
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.greeting}>Bonjour, {user?.prenom} 👋</Text>
<Text style={styles.subtitle}>Comment allez-vous aujourd'hui ?</Text>
{/* Actions rapides */} {/* Actions rapides */}
<View style={styles.actionsRow}> <View style={styles.actionsRow}>
<TouchableOpacity style={styles.actionCard} onPress={() => router.push('/(tabs)/journal/new')}> <Card onPress={() => router.push('/(tabs)/journal/new')} padding={Spacing.lg} style={styles.actionPrimary}>
<View style={[styles.actionIcon, { backgroundColor: `${Colors.primary}30` }]}> <View style={[styles.actionIcon, styles.actionIconPrimary]}>
<Ionicons name="add-circle" size={28} color={Colors.primary} /> <Ionicons name="add-circle" size={28} color={Colors.white} />
</View> </View>
<Text style={styles.actionText}>Nouvelle saisie</Text> <Text style={styles.actionTextPrimary}>Nouvelle saisie</Text>
</TouchableOpacity> </Card>
<TouchableOpacity style={styles.actionCard} onPress={() => router.push('/(tabs)/journal/reports')}> <Card onPress={() => router.push('/(tabs)/journal/reports')} padding={Spacing.lg} style={styles.actionCard}>
<View style={[styles.actionIcon, { backgroundColor: `${Colors.secondary}30` }]}> <View style={[styles.actionIcon, styles.actionIconSecondary]}>
<Ionicons name="bar-chart" size={28} color={Colors.secondary} /> <Ionicons name="bar-chart" size={28} color={Colors.primary} />
</View> </View>
<Text style={styles.actionText}>Mes rapports</Text> <Text style={styles.actionText}>Mes rapports</Text>
</TouchableOpacity> </Card>
</View> </View>
{/* Stats du mois */} {/* Stats du mois */}
{rapport && ( {rapport && (
<View style={styles.statsContainer}> <View style={styles.block}>
<Text style={styles.sectionTitle}>Ce mois-ci</Text> <SectionTitle>Ce mois-ci</SectionTitle>
<Card padding={Spacing.lg}>
<View style={styles.statsRow}> <View style={styles.statsRow}>
<View style={styles.statCard}> <View style={styles.statCell}>
<Text style={styles.statValue}>{rapport.stats.total_saisies}</Text> <Text style={styles.statValue}>{rapport.stats.total_saisies}</Text>
<Text style={styles.statLabel}>Saisies</Text> <Text style={styles.statLabel}>Saisies</Text>
</View> </View>
<View style={styles.statCard}> <View style={styles.statDivider} />
<Text style={styles.statValue}>{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}</Text> <View style={styles.statCell}>
<Text style={styles.statValue}>
{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}
</Text>
<Text style={styles.statLabel}>Intensité moy.</Text> <Text style={styles.statLabel}>Intensité moy.</Text>
</View> </View>
<View style={styles.statCard}> <View style={styles.statDivider} />
<View style={styles.statCell}>
<Text style={styles.statValue}>{rapport.stats.emotion_dominante?.icone || '—'}</Text> <Text style={styles.statValue}>{rapport.stats.emotion_dominante?.icone || '—'}</Text>
<Text style={styles.statLabel}>{rapport.stats.emotion_dominante?.nom || 'Aucune'}</Text> <Text style={styles.statLabel} numberOfLines={1}>
{rapport.stats.emotion_dominante?.nom || 'Aucune'}
</Text>
</View> </View>
</View> </View>
</Card>
</View> </View>
)} )}
{/* Dernieres saisies */} {/* Dernières saisies */}
{recentSaisies.length > 0 && ( <View style={styles.block}>
<View style={styles.recentContainer}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Dernières émotions</Text> <SectionTitle>Dernières saisies</SectionTitle>
<TouchableOpacity onPress={() => router.push('/(tabs)/journal')}> {recentSaisies.length > 0 && (
<Text style={styles.seeAll}>Tout voir</Text> <Text style={styles.seeAll} onPress={() => router.push('/(tabs)/journal')}>
</TouchableOpacity> Tout voir
</Text>
)}
</View> </View>
{recentSaisies.map((saisie) => (
<View key={saisie.id} style={styles.saisieItem}> {isLoading ? (
<Loader label="Chargement…" />
) : recentSaisies.length === 0 ? (
<EmptyState
icon="sparkles-outline"
title="Aucune saisie pour l'instant"
message="Commencez par enregistrer votre première émotion du jour."
actionLabel="Nouvelle saisie"
onAction={() => router.push('/(tabs)/journal/new')}
/>
) : (
recentSaisies.map((saisie) => (
<Card
key={saisie.id}
onPress={() => router.push('/(tabs)/journal')}
padding={Spacing.md}
style={styles.saisieCard}
>
<View style={[styles.emotionIcon, { backgroundColor: `${saisie.emotion?.couleur}20` }]}> <View style={[styles.emotionIcon, { backgroundColor: `${saisie.emotion?.couleur}20` }]}>
<Text style={styles.emotionEmoji}>{saisie.emotion?.icone || '🔵'}</Text> <Text style={styles.emotionEmoji}>{saisie.emotion?.icone || '🔵'}</Text>
</View> </View>
<View style={styles.saisieContent}> <View style={styles.saisieContent}>
<Text style={styles.saisieEmotion}>{saisie.emotion?.nom}</Text> {saisie.emotion?.couleur ? (
<Badge label={saisie.emotion?.nom || ''} color={saisie.emotion.couleur} />
) : (
<Badge label={saisie.emotion?.nom || '—'} tone="neutral" />
)}
<Text style={styles.saisieDate}> <Text style={styles.saisieDate}>
{new Date(saisie.date_saisie).toLocaleDateString('fr-FR')} — Intensité: {saisie.intensite}/10 {new Date(saisie.date_saisie).toLocaleDateString('fr-FR')} — Intensité {saisie.intensite}/10
</Text> </Text>
</View> </View>
</View> </Card>
))} ))
</View>
)} )}
</View>
</ScrollView> </ScrollView>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: Colors.white }, screen: { flex: 1, backgroundColor: Colors.gray[50] },
container: { flex: 1, backgroundColor: Colors.white }, container: { flex: 1 },
content: { padding: 20 }, content: { padding: Spacing.lg, paddingBottom: Spacing.xxxl },
greeting: { fontSize: 28, fontWeight: '800', color: Colors.black, marginTop: 8 }, actionsRow: { flexDirection: 'row', gap: Spacing.md, marginBottom: Spacing.xl },
subtitle: { fontSize: 16, color: Colors.gray[500], marginTop: 4, marginBottom: 24 }, actionCard: { flex: 1, alignItems: 'center', gap: Spacing.md },
actionsRow: { flexDirection: 'row', gap: 12, marginBottom: 24 }, actionPrimary: {
actionCard: { flex: 1,
flex: 1, backgroundColor: Colors.white, borderRadius: 4, padding: 20, alignItems: 'center',
alignItems: 'center', gap: 12, borderWidth: 1, borderColor: Colors.gray[200], gap: Spacing.md,
backgroundColor: Colors.primary,
borderColor: Colors.primary,
}, },
actionIcon: { width: 56, height: 56, borderRadius: 4, justifyContent: 'center', alignItems: 'center' }, actionIcon: { width: 56, height: 56, borderRadius: Radius.sm, justifyContent: 'center', alignItems: 'center' },
actionText: { fontSize: 14, fontWeight: '600', color: Colors.black }, actionIconPrimary: { backgroundColor: Colors.primaryDark },
statsContainer: { marginBottom: 24 }, actionIconSecondary: { backgroundColor: '#E3E3FD' },
sectionTitle: { fontSize: 18, fontWeight: '700', color: Colors.black, marginBottom: 12 }, actionText: { fontSize: FontSize.sm, fontWeight: FontWeight.semibold, color: Colors.black },
statsRow: { flexDirection: 'row', gap: 12 }, actionTextPrimary: { fontSize: FontSize.sm, fontWeight: FontWeight.bold, color: Colors.white },
statCard: { block: { marginBottom: Spacing.xl },
flex: 1, backgroundColor: Colors.gray[50], borderRadius: 4, padding: 16, statsRow: { flexDirection: 'row', alignItems: 'center' },
alignItems: 'center', borderWidth: 1, borderColor: Colors.gray[200], statCell: { flex: 1, alignItems: 'center', gap: Spacing.xs },
statDivider: { width: 1, alignSelf: 'stretch', backgroundColor: Colors.gray[200], marginVertical: Spacing.xs },
statValue: { fontSize: FontSize.xxl, fontWeight: FontWeight.heavy, color: Colors.primary },
statLabel: { fontSize: FontSize.tiny, color: Colors.gray[500], textAlign: 'center' },
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
seeAll: {
fontSize: FontSize.sm,
fontWeight: FontWeight.semibold,
color: Colors.primary,
textDecorationLine: 'underline',
minHeight: MIN_TOUCH,
paddingTop: Spacing.xs,
}, },
statValue: { fontSize: 24, fontWeight: '800', color: Colors.primary }, saisieCard: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md, marginBottom: Spacing.sm },
statLabel: { fontSize: 12, color: Colors.gray[500], marginTop: 4 }, emotionIcon: { width: 44, height: 44, borderRadius: Radius.sm, justifyContent: 'center', alignItems: 'center' },
recentContainer: { marginBottom: 24 },
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
seeAll: { fontSize: 14, fontWeight: '600', color: Colors.primary, textDecorationLine: 'underline' },
saisieItem: {
flexDirection: 'row', alignItems: 'center', gap: 12, padding: 14,
backgroundColor: Colors.white, borderRadius: 4, marginBottom: 8,
borderWidth: 1, borderColor: Colors.gray[200],
},
emotionIcon: { width: 44, height: 44, borderRadius: 4, justifyContent: 'center', alignItems: 'center' },
emotionEmoji: { fontSize: 22 }, emotionEmoji: { fontSize: 22 },
saisieContent: { flex: 1 }, saisieContent: { flex: 1, gap: Spacing.xs, alignItems: 'flex-start' },
saisieEmotion: { fontSize: 15, fontWeight: '600', color: Colors.black }, saisieDate: { fontSize: FontSize.xs, color: Colors.gray[500] },
saisieDate: { fontSize: 13, color: Colors.gray[500], marginTop: 2 },
}); });
+36 -24
View File
@@ -1,8 +1,9 @@
import { View, Text, StyleSheet, ScrollView, ActivityIndicator } from 'react-native'; import { View, Text, StyleSheet, ScrollView } from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router'; import { useLocalSearchParams, Stack } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useFeed } from '@/hooks/useFeeds'; import { useFeed } from '@/hooks/useFeeds';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Spacing } from '@/lib/theme';
import { Badge, EmptyState, Loader } from '@/components/ui';
export default function FeedDetailScreen() { export default function FeedDetailScreen() {
const { slug } = useLocalSearchParams<{ slug: string }>(); const { slug } = useLocalSearchParams<{ slug: string }>();
@@ -10,17 +11,22 @@ export default function FeedDetailScreen() {
if (isLoading) { if (isLoading) {
return ( return (
<View style={styles.centered}> <View style={styles.fill}>
<ActivityIndicator size="large" color={Colors.primary} /> <Stack.Screen options={{ title: 'Article' }} />
<Loader label="Chargement de l'article…" />
</View> </View>
); );
} }
if (isError || !feed) { if (isError || !feed) {
return ( return (
<View style={styles.centered}> <View style={styles.fill}>
<Ionicons name="alert-circle-outline" size={48} color={Colors.gray[400]} /> <Stack.Screen options={{ title: 'Article' }} />
<Text style={styles.errorText}>Article introuvable</Text> <EmptyState
icon="alert-circle-outline"
title="Article introuvable"
message="Cet article n'existe plus ou n'est pas accessible."
/>
</View> </View>
); );
} }
@@ -28,34 +34,40 @@ export default function FeedDetailScreen() {
const texteNettoye = (feed.contenu ?? '').replace(/<[^>]*>/g, ''); const texteNettoye = (feed.contenu ?? '').replace(/<[^>]*>/g, '');
return ( return (
<> <View style={styles.fill}>
<Stack.Screen options={{ title: feed.titre ?? '' }} /> <Stack.Screen options={{ title: feed.titre ?? 'Article' }} />
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.badge}> <Badge label="Information santé mentale" tone="primary" />
<Ionicons name="information-circle" size={14} color={Colors.primary} />
<Text style={styles.badgeText}>Information santé mentale</Text>
</View>
<Text style={styles.title}>{feed.titre}</Text> <Text style={styles.title}>{feed.titre}</Text>
<Text style={styles.date}> <Text style={styles.date}>
Publié le {new Date(feed.created_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })} Publié le{' '}
{new Date(feed.created_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text> </Text>
<Text style={styles.body}>{texteNettoye}</Text> <Text style={styles.body}>{texteNettoye}</Text>
</ScrollView> </ScrollView>
</> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white }, fill: { flex: 1, backgroundColor: Colors.gray[50] },
content: { padding: 20, paddingBottom: 40 }, container: { flex: 1, backgroundColor: Colors.gray[50] },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: Colors.white }, content: { padding: Spacing.xl, paddingBottom: Spacing.xxxl },
badge: { flexDirection: 'row', alignItems: 'center', gap: 6, alignSelf: 'flex-start', backgroundColor: `${Colors.primary}15`, paddingHorizontal: 10, paddingVertical: 5, borderRadius: 4, marginBottom: 12 }, title: {
badgeText: { fontSize: 12, fontWeight: '600', color: Colors.primary }, fontSize: FontSize.xxl,
title: { fontSize: 24, fontWeight: '800', color: Colors.black, marginBottom: 8, lineHeight: 30 }, fontWeight: FontWeight.bold,
date: { fontSize: 13, color: Colors.gray[400], marginBottom: 20 }, color: Colors.black,
body: { fontSize: 15, lineHeight: 24, color: Colors.black }, marginTop: Spacing.md,
errorText: { marginTop: 12, fontSize: 16, color: Colors.gray[400] }, marginBottom: Spacing.sm,
lineHeight: 30,
},
date: { fontSize: FontSize.xs, color: Colors.gray[400], marginBottom: Spacing.xl },
body: { fontSize: FontSize.body, lineHeight: 24, color: Colors.black },
}); });
+6 -3
View File
@@ -1,17 +1,20 @@
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import { FontWeight } from '@/lib/theme';
export default function InfoLayout() { export default function InfoLayout() {
return ( return (
<Stack <Stack
screenOptions={{ screenOptions={{
headerStyle: { backgroundColor: Colors.white }, headerStyle: { backgroundColor: Colors.white },
headerTitleStyle: { fontWeight: '700', color: Colors.black }, headerTitleStyle: { fontWeight: FontWeight.bold, color: Colors.black },
headerTintColor: Colors.primary, headerTintColor: Colors.primary,
}} }}
> >
<Stack.Screen name="index" options={{ title: 'Infos' }} /> {/* La liste rend son propre <AppBar/>, on masque le header natif. */}
<Stack.Screen name="[slug]" options={{ title: '', headerBackTitle: 'Retour' }} /> <Stack.Screen name="index" options={{ headerShown: false }} />
{/* Le détail garde un header natif avec bouton retour. */}
<Stack.Screen name="[slug]" options={{ title: 'Article', headerBackTitle: 'Retour' }} />
</Stack> </Stack>
); );
} }
+45 -42
View File
@@ -1,81 +1,84 @@
import { View, Text, StyleSheet, FlatList, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, FlatList } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useFeeds } from '@/hooks/useFeeds'; import { useFeeds } from '@/hooks/useFeeds';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
import { AppBar, Card, EmptyState, Loader } from '@/components/ui';
import type { Feed } from '@/types'; import type { Feed } from '@/types';
export default function InfoListScreen() { export default function InfoListScreen() {
const { data: feeds, isLoading } = useFeeds(); const { data: feeds, isLoading } = useFeeds();
const router = useRouter(); const router = useRouter();
const renderItem = ({ item }: { item: Feed }) => ( const renderItem = ({ item }: { item: Feed }) => {
<TouchableOpacity const extrait = (item.contenu ?? '').replace(/<[^>]*>/g, '').trim();
style={styles.card} return (
onPress={() => router.push(`/(tabs)/info/${item.slug}`)} <Card onPress={() => router.push(`/(tabs)/info/${item.slug}`)} style={styles.card}>
> <View style={styles.cardRow}>
<View style={styles.cardIcon}> <View style={styles.cardIcon}>
<Ionicons name="document-text" size={24} color={Colors.primary} /> <Ionicons name="document-text" size={24} color={Colors.primary} />
</View> </View>
<View style={styles.cardContent}> <View style={styles.cardContent}>
<Text style={styles.cardTitle}>{item.titre}</Text> <Text style={styles.cardTitle}>{item.titre}</Text>
{extrait ? (
<Text style={styles.cardExcerpt} numberOfLines={2}> <Text style={styles.cardExcerpt} numberOfLines={2}>
{item.contenu?.replace(/<[^>]*>/g, '').substring(0, 120)}... {extrait}
</Text> </Text>
) : null}
<Text style={styles.cardDate}> <Text style={styles.cardDate}>
{new Date(item.created_at).toLocaleDateString('fr-FR')} {new Date(item.created_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text> </Text>
</View> </View>
<Ionicons name="chevron-forward" size={20} color={Colors.gray[300]} /> <Ionicons name="chevron-forward" size={20} color={Colors.gray[400]} />
</TouchableOpacity> </View>
</Card>
); );
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
<AppBar title="Informations" subtitle="Ressources pour votre santé mentale" />
{isLoading ? (
<Loader label="Chargement des articles…" />
) : (
<FlatList <FlatList
data={feeds || []} data={feeds ?? []}
keyExtractor={(item) => String(item.id)} keyExtractor={(item) => String(item.id)}
renderItem={renderItem} renderItem={renderItem}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.headerTitle}>Santé mentale</Text>
<Text style={styles.headerSubtitle}>Ressources et informations pour votre bien-être</Text>
</View>
}
ListEmptyComponent={ ListEmptyComponent={
isLoading ? ( <EmptyState
<View style={styles.loading}><Text style={styles.loadingText}>Chargement...</Text></View> icon="newspaper-outline"
) : ( title="Aucun article disponible"
<View style={styles.empty}> message="Les ressources d'information apparaîtront ici dès leur publication."
<Ionicons name="newspaper-outline" size={48} color={Colors.gray[300]} /> />
<Text style={styles.emptyText}>Aucun article disponible</Text>
</View>
)
} }
/> />
)}
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white }, container: { flex: 1, backgroundColor: Colors.gray[50] },
listContent: { padding: 16 }, listContent: { padding: Spacing.lg, gap: Spacing.md },
header: { marginBottom: 20 }, card: { padding: Spacing.lg },
headerTitle: { fontSize: 14, fontWeight: '700', color: Colors.primary, textTransform: 'uppercase', letterSpacing: 1 }, cardRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md },
headerSubtitle: { fontSize: 15, color: Colors.gray[500], marginTop: 4 }, cardIcon: {
card: { width: 48,
flexDirection: 'row', alignItems: 'center', gap: 14, padding: 16, height: 48,
backgroundColor: Colors.white, borderRadius: 4, marginBottom: 10, borderRadius: Radius.sm,
borderWidth: 1, borderColor: Colors.gray[200], backgroundColor: '#E3E3FD',
justifyContent: 'center',
alignItems: 'center',
}, },
cardIcon: { width: 48, height: 48, borderRadius: 4, backgroundColor: `${Colors.primary}15`, justifyContent: 'center', alignItems: 'center' },
cardContent: { flex: 1 }, cardContent: { flex: 1 },
cardTitle: { fontSize: 15, fontWeight: '700', color: Colors.black, marginBottom: 4 }, cardTitle: { fontSize: FontSize.body, fontWeight: FontWeight.bold, color: Colors.black, marginBottom: Spacing.xs },
cardExcerpt: { fontSize: 13, color: Colors.gray[500], lineHeight: 18 }, cardExcerpt: { fontSize: FontSize.xs, color: Colors.gray[500], lineHeight: 18 },
cardDate: { fontSize: 11, color: Colors.gray[400], marginTop: 4 }, cardDate: { fontSize: FontSize.tiny, color: Colors.gray[400], marginTop: Spacing.xs },
loading: { padding: 40, alignItems: 'center' },
loadingText: { color: Colors.gray[400] },
empty: { alignItems: 'center', paddingTop: 60 },
emptyText: { fontSize: 16, color: Colors.gray[400], marginTop: 12 },
}); });
+5 -3
View File
@@ -1,18 +1,20 @@
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import { FontWeight } from '@/lib/theme';
export default function JournalLayout() { export default function JournalLayout() {
return ( return (
<Stack <Stack
screenOptions={{ screenOptions={{
headerStyle: { backgroundColor: Colors.white }, headerStyle: { backgroundColor: Colors.white },
headerTitleStyle: { fontWeight: '700', color: Colors.black }, headerTitleStyle: { fontWeight: FontWeight.bold, color: Colors.black },
headerTintColor: Colors.primary,
}} }}
> >
<Stack.Screen name="index" options={{ title: 'Mon journal' }} /> <Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="reports" options={{ headerShown: false }} />
<Stack.Screen name="new" options={{ title: 'Nouvelle saisie', presentation: 'modal' }} /> <Stack.Screen name="new" options={{ title: 'Nouvelle saisie', presentation: 'modal' }} />
<Stack.Screen name="edit" options={{ title: 'Modifier la saisie', presentation: 'modal' }} /> <Stack.Screen name="edit" options={{ title: 'Modifier la saisie', presentation: 'modal' }} />
<Stack.Screen name="reports" options={{ title: 'Mes rapports' }} />
</Stack> </Stack>
); );
} }
+34 -82
View File
@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, TextInput, ScrollView, Alert } from 'react-native'; import { View, Text, StyleSheet, ScrollView, Alert } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router'; import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSaisies, useUpdateSaisie } from '@/hooks/useTracker'; import { useSaisies, useUpdateSaisie } from '@/hooks/useTracker';
import EmotionPicker from '@/components/EmotionPicker'; import EmotionPicker from '@/components/EmotionPicker';
import { IntensityPicker } from './new';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import { Spacing, FontSize, FontWeight } from '@/lib/theme';
import { Button, TextField, Loader } from '@/components/ui';
export default function EditEntryScreen() { export default function EditEntryScreen() {
const router = useRouter(); const router = useRouter();
@@ -38,126 +40,76 @@ export default function EditEntryScreen() {
note: note.trim() || undefined, note: note.trim() || undefined,
date_saisie: dateSaisie, date_saisie: dateSaisie,
}); });
Alert.alert('Succès', 'Saisie modifiée !', [ Alert.alert('Succès', 'Saisie modifiée !', [{ text: 'OK', onPress: () => router.back() }]);
{ text: 'OK', onPress: () => router.back() },
]);
} catch { } catch {
Alert.alert('Erreur', 'Impossible de modifier la saisie'); Alert.alert('Erreur', 'Impossible de modifier la saisie');
} }
}; };
if (!saisie) { if (!saisie) {
return ( return <Loader label="Chargement…" />;
<View style={styles.loading}>
<Text style={styles.loadingText}>Chargement...</Text>
</View>
);
} }
return ( return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>Modifier la saisie</Text> {/* Émotion */}
{/* Emotion */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.label}>Émotion</Text> <Text style={styles.label}>Émotion</Text>
<EmotionPicker value={emotionId} onChange={setEmotionId} /> <EmotionPicker value={emotionId} onChange={setEmotionId} />
</View> </View>
{/* Intensite */} {/* Intensité */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.label}>Intensité</Text> <Text style={styles.label}>Intensité</Text>
<View style={styles.intensityContainer}> <View style={styles.intensityContainer}>
<Text style={styles.intensityValue}>{intensite}</Text> <Text style={styles.intensityValue}>{intensite}</Text>
<Text style={styles.intensityUnit}>/10</Text> <Text style={styles.intensityUnit}>/10</Text>
</View> </View>
<View style={styles.sliderButtons}> <IntensityPicker value={intensite} onChange={setIntensite} />
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => ( <View style={styles.scaleLabels}>
<TouchableOpacity <Text style={styles.scaleLabel}>Faible</Text>
key={v} <Text style={styles.scaleLabel}>Forte</Text>
style={[styles.sliderDot, intensite === v && styles.sliderDotActive]}
onPress={() => setIntensite(v)}
>
<Text style={[styles.sliderDotText, intensite === v && styles.sliderDotTextActive]}>{v}</Text>
</TouchableOpacity>
))}
</View>
<View style={styles.sliderLabels}>
<Text style={styles.sliderLabel}>Faible</Text>
<Text style={styles.sliderLabel}>Forte</Text>
</View> </View>
</View> </View>
{/* Note */} {/* Note */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.label}>Note (optionnel)</Text> <TextField
<TextInput label="Note (optionnel)"
style={styles.textArea}
value={note} value={note}
onChangeText={setNote} onChangeText={setNote}
placeholder="Décrivez ce que vous ressentez..." placeholder="Décrivez ce que vous ressentez…"
multiline multiline
numberOfLines={4} numberOfLines={4}
textAlignVertical="top"
/> />
</View> </View>
{/* Actions */} {/* Actions */}
<View style={styles.actionsRow}> <View style={styles.actionsRow}>
<TouchableOpacity style={styles.cancelBtn} onPress={() => router.back()}> <Button title="Annuler" variant="secondary" onPress={() => router.back()} style={styles.flex} />
<Text style={styles.cancelBtnText}>Annuler</Text> <Button
</TouchableOpacity> title="Enregistrer"
<TouchableOpacity icon="checkmark"
style={[styles.submitBtn, updateSaisie.isPending && styles.btnDisabled]} loading={updateSaisie.isPending}
disabled={!emotionId}
onPress={handleSubmit} onPress={handleSubmit}
disabled={updateSaisie.isPending || !emotionId} style={styles.flex}
> />
<Ionicons name="checkmark" size={20} color={Colors.white} />
<Text style={styles.submitBtnText}>
{updateSaisie.isPending ? 'Envoi...' : 'Enregistrer'}
</Text>
</TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white }, container: { flex: 1, backgroundColor: Colors.gray[50] },
content: { padding: 20, paddingBottom: 40 }, content: { padding: Spacing.lg, paddingBottom: Spacing.xxxl },
loading: { flex: 1, justifyContent: 'center', alignItems: 'center' }, section: { marginBottom: Spacing.xl },
loadingText: { color: Colors.gray[400], fontSize: 16 }, label: { fontSize: FontSize.body, fontWeight: FontWeight.bold, color: Colors.black, marginBottom: Spacing.md },
title: { fontSize: 24, fontWeight: '800', color: Colors.black, marginBottom: 24 }, intensityContainer: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'center', marginBottom: Spacing.lg },
section: { marginBottom: 24 }, intensityValue: { fontSize: FontSize.display, fontWeight: FontWeight.heavy, color: Colors.primary, lineHeight: 56 },
label: { fontSize: 15, fontWeight: '700', color: Colors.black, marginBottom: 10 }, intensityUnit: { fontSize: FontSize.xl, color: Colors.gray[400], marginLeft: Spacing.xs },
intensityContainer: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'center', marginBottom: 16 }, scaleLabels: { flexDirection: 'row', justifyContent: 'space-between', marginTop: Spacing.md },
intensityValue: { fontSize: 48, fontWeight: '800', color: Colors.black }, scaleLabel: { fontSize: FontSize.xs, color: Colors.gray[500] },
intensityUnit: { fontSize: 20, color: Colors.gray[400], marginLeft: 4 }, actionsRow: { flexDirection: 'row', justifyContent: 'space-between', gap: Spacing.md, marginTop: Spacing.sm },
sliderButtons: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }, flex: { flex: 1 },
sliderDot: {
width: 28, height: 28, borderRadius: 4,
backgroundColor: Colors.gray[100], justifyContent: 'center', alignItems: 'center',
},
sliderDotActive: { backgroundColor: Colors.primary },
sliderDotText: { fontSize: 12, fontWeight: '600', color: Colors.gray[500] },
sliderDotTextActive: { color: Colors.white },
sliderLabels: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
sliderLabel: { fontSize: 12, color: Colors.gray[400] },
textArea: {
backgroundColor: Colors.gray[100], borderTopLeftRadius: 4, borderTopRightRadius: 4,
borderBottomWidth: 2, borderBottomColor: Colors.black,
padding: 16, fontSize: 15, color: Colors.black, minHeight: 100,
},
actionsRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8, gap: 12 },
cancelBtn: {
flex: 1, paddingVertical: 16, borderRadius: 4,
backgroundColor: Colors.white, alignItems: 'center', borderWidth: 1, borderColor: Colors.primary,
},
cancelBtnText: { fontSize: 15, fontWeight: '600', color: Colors.primary },
submitBtn: {
flex: 1, flexDirection: 'row', gap: 8, paddingVertical: 16, borderRadius: 4,
backgroundColor: Colors.primary, alignItems: 'center', justifyContent: 'center',
},
submitBtnText: { fontSize: 15, fontWeight: '700', color: Colors.white },
btnDisabled: { opacity: 0.5 },
}); });
+92 -48
View File
@@ -4,6 +4,8 @@ import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker'; import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import { Spacing, Radius, FontSize, FontWeight, Shadow, MIN_TOUCH, HIT_SLOP } from '@/lib/theme';
import { AppBar, Card, Badge, EmptyState, Loader } from '@/components/ui';
import type { SaisieTracker } from '@/types'; import type { SaisieTracker } from '@/types';
export default function JournalScreen() { export default function JournalScreen() {
@@ -36,86 +38,128 @@ export default function JournalScreen() {
}; };
const renderItem = ({ item }: { item: SaisieTracker }) => ( const renderItem = ({ item }: { item: SaisieTracker }) => (
<View style={styles.saisieCard}> <Card padding={Spacing.lg} style={styles.saisieCard}>
<View style={[styles.emotionIcon, { backgroundColor: `${item.emotion?.couleur}20` }]}> <View style={[styles.emotionIcon, { backgroundColor: `${item.emotion?.couleur}20` }]}>
<Text style={styles.emotionEmoji}>{item.emotion?.icone || '🔵'}</Text> <Text style={styles.emotionEmoji}>{item.emotion?.icone || '🔵'}</Text>
</View> </View>
<View style={styles.saisieContent}> <View style={styles.saisieContent}>
<View style={styles.saisieHeader}> <View style={styles.saisieHeader}>
<View style={[styles.badge, { backgroundColor: `${item.emotion?.couleur}20` }]}> {item.emotion?.couleur ? (
<Text style={[styles.badgeText, { color: item.emotion?.couleur }]}>{item.emotion?.nom}</Text> <Badge label={item.emotion?.nom || ''} color={item.emotion.couleur} />
</View> ) : (
<Text style={styles.intensityText}>Intensité: {item.intensite}/10</Text> <Badge label={item.emotion?.nom || '—'} tone="neutral" />
)}
<Text style={styles.intensityText}>Intensité {item.intensite}/10</Text>
</View> </View>
<View style={styles.intensityBar}> <View style={styles.intensityBar}>
<View style={[styles.intensityFill, { width: `${item.intensite * 10}%`, backgroundColor: item.emotion?.couleur || Colors.primary }]} /> <View
style={[
styles.intensityFill,
{ width: `${item.intensite * 10}%`, backgroundColor: item.emotion?.couleur || Colors.primary },
]}
/>
</View> </View>
{item.note && <Text style={styles.note} numberOfLines={2}>{item.note}</Text>} {item.note ? (
<Text style={styles.date}>{new Date(item.date_saisie).toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}</Text> <Text style={styles.note} numberOfLines={2}>
{item.note}
</Text>
) : null}
<Text style={styles.date}>
{new Date(item.date_saisie).toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
})}
</Text>
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
<TouchableOpacity style={styles.actionBtn} onPress={() => router.push({ pathname: '/(tabs)/journal/edit', params: { id: String(item.id) } })}> <TouchableOpacity
<Ionicons name="create-outline" size={18} color={Colors.gray[400]} /> style={styles.actionBtn}
hitSlop={HIT_SLOP}
accessibilityLabel="Modifier la saisie"
onPress={() => router.push({ pathname: '/(tabs)/journal/edit', params: { id: String(item.id) } })}
>
<Ionicons name="create-outline" size={20} color={Colors.primary} />
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.actionBtn} onPress={() => handleDelete(item.id)}> <TouchableOpacity
<Ionicons name="trash-outline" size={18} color={Colors.gray[400]} /> style={styles.actionBtn}
hitSlop={HIT_SLOP}
accessibilityLabel="Supprimer la saisie"
onPress={() => handleDelete(item.id)}
>
<Ionicons name="trash-outline" size={20} color={Colors.error} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </Card>
); );
return ( return (
<View style={styles.container}> <View style={styles.screen}>
<AppBar title="Mon journal" subtitle="Vos émotions au fil du temps" />
{isLoading ? (
<Loader label="Chargement du journal…" />
) : (
<FlatList <FlatList
data={saisies || []} data={saisies || []}
keyExtractor={(item) => String(item.id)} keyExtractor={(item) => String(item.id)}
renderItem={renderItem} renderItem={renderItem}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={Colors.primary} />} refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={Colors.primary} />
}
ListEmptyComponent={ ListEmptyComponent={
<View style={styles.empty}> <EmptyState
<Ionicons name="book-outline" size={48} color={Colors.gray[300]} /> icon="book-outline"
<Text style={styles.emptyTitle}>Aucune saisie</Text> title="Aucune saisie"
<Text style={styles.emptyText}>Commencez par enregistrer votre première émotion</Text> message="Commencez par enregistrer votre première émotion."
</View> actionLabel="Nouvelle saisie"
onAction={() => router.push('/(tabs)/journal/new')}
/>
} }
/> />
<TouchableOpacity style={styles.fab} onPress={() => router.push('/(tabs)/journal/new')}> )}
<Ionicons name="add" size={28} color={Colors.white} /> <TouchableOpacity
style={styles.fab}
accessibilityLabel="Nouvelle saisie"
onPress={() => router.push('/(tabs)/journal/new')}
>
<Ionicons name="add" size={30} color={Colors.white} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white }, screen: { flex: 1, backgroundColor: Colors.gray[50] },
listContent: { padding: 16, paddingBottom: 80 }, listContent: { padding: Spacing.lg, paddingBottom: 96 },
saisieCard: { saisieCard: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.md, marginBottom: Spacing.md },
flexDirection: 'row', alignItems: 'flex-start', gap: 12, padding: 16, emotionIcon: { width: 48, height: 48, borderRadius: Radius.sm, justifyContent: 'center', alignItems: 'center' },
backgroundColor: Colors.white, borderRadius: 4, marginBottom: 10,
borderWidth: 1, borderColor: Colors.gray[200],
},
emotionIcon: { width: 48, height: 48, borderRadius: 4, justifyContent: 'center', alignItems: 'center' },
emotionEmoji: { fontSize: 24 }, emotionEmoji: { fontSize: 24 },
saisieContent: { flex: 1 }, saisieContent: { flex: 1 },
saisieHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 }, saisieHeader: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, marginBottom: Spacing.sm, flexWrap: 'wrap' },
badge: { paddingHorizontal: 10, paddingVertical: 3, borderRadius: 4 }, intensityText: { fontSize: FontSize.xs, color: Colors.gray[500] },
badgeText: { fontSize: 13, fontWeight: '600' }, intensityBar: { height: 6, backgroundColor: Colors.gray[200], borderRadius: Radius.sm, marginBottom: Spacing.sm },
intensityText: { fontSize: 12, color: Colors.gray[400] }, intensityFill: { height: 6, borderRadius: Radius.sm },
intensityBar: { height: 4, backgroundColor: Colors.gray[200], borderRadius: 2, marginBottom: 6 }, note: { fontSize: FontSize.xs, color: Colors.gray[600], marginBottom: Spacing.xs, lineHeight: 19 },
intensityFill: { height: 4, borderRadius: 2 }, date: { fontSize: FontSize.xs, color: Colors.gray[400], textTransform: 'capitalize' },
note: { fontSize: 13, color: Colors.gray[600], marginBottom: 4 }, actions: { gap: Spacing.sm },
date: { fontSize: 12, color: Colors.gray[400] }, actionBtn: {
actions: { gap: 4 }, width: MIN_TOUCH,
actionBtn: { padding: 6, borderRadius: 8 }, height: MIN_TOUCH,
empty: { alignItems: 'center', paddingTop: 80 }, borderRadius: Radius.sm,
emptyTitle: { fontSize: 18, fontWeight: '600', color: Colors.gray[400], marginTop: 12 }, justifyContent: 'center',
emptyText: { fontSize: 14, color: Colors.gray[400], marginTop: 4 }, alignItems: 'center',
},
fab: { fab: {
position: 'absolute', right: 20, bottom: 20, position: 'absolute',
width: 56, height: 56, borderRadius: 4, right: Spacing.lg,
backgroundColor: Colors.primary, justifyContent: 'center', alignItems: 'center', bottom: Spacing.lg,
shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, width: 56,
elevation: 6, height: 56,
borderRadius: Radius.sm,
backgroundColor: Colors.primary,
justifyContent: 'center',
alignItems: 'center',
...Shadow.raised,
}, },
}); });
+87 -93
View File
@@ -1,10 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, TextInput, ScrollView, Alert } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Alert } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useCreateSaisie } from '@/hooks/useTracker'; import { useCreateSaisie } from '@/hooks/useTracker';
import EmotionPicker from '@/components/EmotionPicker'; import EmotionPicker from '@/components/EmotionPicker';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import { Spacing, Radius, FontSize, FontWeight } from '@/lib/theme';
import { Button, TextField } from '@/components/ui';
export default function NewEntryScreen() { export default function NewEntryScreen() {
const router = useRouter(); const router = useRouter();
const createSaisie = useCreateSaisie(); const createSaisie = useCreateSaisie();
@@ -22,11 +25,9 @@ export default function NewEntryScreen() {
note: note.trim() || undefined, note: note.trim() || undefined,
date_saisie: new Date().toISOString().split('T')[0], date_saisie: new Date().toISOString().split('T')[0],
}); });
Alert.alert('Succès', 'Saisie enregistrée !', [ Alert.alert('Succès', 'Saisie enregistrée !', [{ text: 'OK', onPress: () => router.back() }]);
{ text: 'OK', onPress: () => router.back() },
]);
} catch { } catch {
Alert.alert('Erreur', 'Impossible d\'enregistrer la saisie'); Alert.alert('Erreur', "Impossible d'enregistrer la saisie");
} }
}; };
@@ -38,7 +39,7 @@ export default function NewEntryScreen() {
<View key={s} style={styles.stepRow}> <View key={s} style={styles.stepRow}>
<View style={[styles.stepCircle, step >= s && styles.stepActive]}> <View style={[styles.stepCircle, step >= s && styles.stepActive]}>
{step > s ? ( {step > s ? (
<Ionicons name="checkmark" size={16} color={Colors.white} /> <Ionicons name="checkmark" size={18} color={Colors.white} />
) : ( ) : (
<Text style={[styles.stepNumber, step >= s && styles.stepNumberActive]}>{s}</Text> <Text style={[styles.stepNumber, step >= s && styles.stepNumberActive]}>{s}</Text>
)} )}
@@ -48,23 +49,23 @@ export default function NewEntryScreen() {
))} ))}
</View> </View>
{/* Step 1: Emotion */} {/* Step 1: Émotion */}
{step === 1 && ( {step === 1 && (
<View> <View>
<Text style={styles.stepTitle}>Comment vous sentez-vous ?</Text> <Text style={styles.stepTitle}>Comment vous sentez-vous ?</Text>
<EmotionPicker value={emotionId} onChange={setEmotionId} /> <EmotionPicker value={emotionId} onChange={setEmotionId} />
<TouchableOpacity <View style={styles.navRowEnd}>
style={[styles.nextBtn, !emotionId && styles.btnDisabled]} <Button
title="Suivant"
icon="arrow-forward"
onPress={() => emotionId && setStep(2)} onPress={() => emotionId && setStep(2)}
disabled={!emotionId} disabled={!emotionId}
> />
<Text style={styles.nextBtnText}>Suivant</Text> </View>
<Ionicons name="arrow-forward" size={20} color={Colors.white} />
</TouchableOpacity>
</View> </View>
)} )}
{/* Step 2: Intensity */} {/* Step 2: Intensité */}
{step === 2 && ( {step === 2 && (
<View> <View>
<Text style={styles.stepTitle}>À quel point ressentez-vous cette émotion ?</Text> <Text style={styles.stepTitle}>À quel point ressentez-vous cette émotion ?</Text>
@@ -72,37 +73,14 @@ export default function NewEntryScreen() {
<Text style={styles.intensityValue}>{intensite}</Text> <Text style={styles.intensityValue}>{intensite}</Text>
<Text style={styles.intensityLabel}>/10</Text> <Text style={styles.intensityLabel}>/10</Text>
</View> </View>
<View style={styles.sliderContainer}> <IntensityPicker value={intensite} onChange={setIntensite} />
<Text style={styles.sliderLabel}>Faible</Text> <View style={styles.scaleLabels}>
<View style={styles.sliderWrapper}> <Text style={styles.scaleLabel}>Faible</Text>
<View style={styles.nativeSlider}> <Text style={styles.scaleLabel}>Forte</Text>
<View style={styles.sliderTrack}>
<View style={[styles.sliderFill, { width: `${(intensite - 1) * 11.11}%` }]} />
</View>
<View style={styles.sliderButtons}>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
<TouchableOpacity
key={v}
style={[styles.sliderDot, intensite === v && styles.sliderDotActive]}
onPress={() => setIntensite(v)}
>
<Text style={[styles.sliderDotText, intensite === v && styles.sliderDotTextActive]}>{v}</Text>
</TouchableOpacity>
))}
</View>
</View>
</View>
<Text style={styles.sliderLabel}>Forte</Text>
</View> </View>
<View style={styles.navRow}> <View style={styles.navRow}>
<TouchableOpacity style={styles.backBtn} onPress={() => setStep(1)}> <Button title="Retour" variant="secondary" icon="arrow-back" onPress={() => setStep(1)} />
<Ionicons name="arrow-back" size={20} color={Colors.primary} /> <Button title="Suivant" icon="arrow-forward" onPress={() => setStep(3)} />
<Text style={styles.backBtnText}>Retour</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.nextBtn} onPress={() => setStep(3)}>
<Text style={styles.nextBtnText}>Suivant</Text>
<Ionicons name="arrow-forward" size={20} color={Colors.white} />
</TouchableOpacity>
</View> </View>
</View> </View>
)} )}
@@ -111,28 +89,22 @@ export default function NewEntryScreen() {
{step === 3 && ( {step === 3 && (
<View> <View>
<Text style={styles.stepTitle}>Souhaitez-vous ajouter un commentaire ?</Text> <Text style={styles.stepTitle}>Souhaitez-vous ajouter un commentaire ?</Text>
<TextInput <TextField
style={styles.textArea} label="Note (optionnel)"
value={note} value={note}
onChangeText={setNote} onChangeText={setNote}
placeholder="Décrivez ce que vous ressentez, ce qui a déclenché cette émotion..." placeholder="Décrivez ce que vous ressentez, ce qui a déclenché cette émotion…"
multiline multiline
numberOfLines={5} numberOfLines={5}
textAlignVertical="top"
/> />
<View style={styles.navRow}> <View style={styles.navRow}>
<TouchableOpacity style={styles.backBtn} onPress={() => setStep(2)}> <Button title="Retour" variant="secondary" icon="arrow-back" onPress={() => setStep(2)} />
<Ionicons name="arrow-back" size={20} color={Colors.primary} /> <Button
<Text style={styles.backBtnText}>Retour</Text> title="Enregistrer"
</TouchableOpacity> icon="checkmark"
<TouchableOpacity loading={createSaisie.isPending}
style={[styles.submitBtn, createSaisie.isPending && styles.btnDisabled]}
onPress={handleSubmit} onPress={handleSubmit}
disabled={createSaisie.isPending} />
>
<Ionicons name="checkmark" size={20} color={Colors.white} />
<Text style={styles.nextBtnText}>{createSaisie.isPending ? 'Envoi...' : 'Enregistrer'}</Text>
</TouchableOpacity>
</View> </View>
</View> </View>
)} )}
@@ -140,46 +112,68 @@ export default function NewEntryScreen() {
); );
} }
/** Sélecteur d'intensité 1–10, boutons ≥40px, sur deux rangées (wrap). */
export function IntensityPicker({ value, onChange }: { value: number; onChange: (v: number) => void }) {
return (
<View style={pickerStyles.grid}>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => {
const active = value === v;
return (
<TouchableOpacity
key={v}
style={[pickerStyles.dot, active && pickerStyles.dotActive]}
onPress={() => onChange(v)}
accessibilityLabel={`Intensité ${v}`}
>
<Text style={[pickerStyles.dotText, active && pickerStyles.dotTextActive]}>{v}</Text>
</TouchableOpacity>
);
})}
</View>
);
}
const pickerStyles = StyleSheet.create({
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.sm, justifyContent: 'center' },
dot: {
width: 44,
height: 44,
borderRadius: Radius.sm,
backgroundColor: Colors.gray[100],
borderWidth: 1,
borderColor: Colors.gray[200],
justifyContent: 'center',
alignItems: 'center',
},
dotActive: { backgroundColor: Colors.primary, borderColor: Colors.primary },
dotText: { fontSize: FontSize.sm, fontWeight: FontWeight.bold, color: Colors.gray[500] },
dotTextActive: { color: Colors.white },
});
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white }, container: { flex: 1, backgroundColor: Colors.gray[50] },
content: { padding: 20, paddingBottom: 40 }, content: { padding: Spacing.lg, paddingBottom: Spacing.xxxl },
stepper: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginBottom: 32 }, stepper: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.xxl },
stepRow: { flexDirection: 'row', alignItems: 'center' }, stepRow: { flexDirection: 'row', alignItems: 'center' },
stepCircle: { stepCircle: {
width: 32, height: 32, borderRadius: 16, backgroundColor: Colors.gray[200], width: 36,
justifyContent: 'center', alignItems: 'center', height: 36,
borderRadius: Radius.pill,
backgroundColor: Colors.gray[200],
justifyContent: 'center',
alignItems: 'center',
}, },
stepActive: { backgroundColor: Colors.primary }, stepActive: { backgroundColor: Colors.primary },
stepNumber: { fontSize: 14, fontWeight: '700', color: Colors.gray[500] }, stepNumber: { fontSize: FontSize.md, fontWeight: FontWeight.bold, color: Colors.gray[500] },
stepNumberActive: { color: Colors.white }, stepNumberActive: { color: Colors.white },
stepLine: { width: 40, height: 3, backgroundColor: Colors.gray[200], marginHorizontal: 4 }, stepLine: { width: 40, height: 3, backgroundColor: Colors.gray[200], marginHorizontal: Spacing.xs },
stepLineActive: { backgroundColor: Colors.primary }, stepLineActive: { backgroundColor: Colors.primary },
stepTitle: { fontSize: 20, fontWeight: '700', color: Colors.black, marginBottom: 20 }, stepTitle: { fontSize: FontSize.xl, fontWeight: FontWeight.bold, color: Colors.black, marginBottom: Spacing.xl },
intensityContainer: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'center', marginBottom: 24 }, intensityContainer: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'center', marginBottom: Spacing.xl },
intensityValue: { fontSize: 64, fontWeight: '800', color: Colors.black }, intensityValue: { fontSize: FontSize.display, fontWeight: FontWeight.heavy, color: Colors.primary, lineHeight: 60 },
intensityLabel: { fontSize: 24, color: Colors.gray[400], marginLeft: 4 }, intensityLabel: { fontSize: FontSize.xxl, color: Colors.gray[400], marginLeft: Spacing.xs },
sliderContainer: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 32 }, scaleLabels: { flexDirection: 'row', justifyContent: 'space-between', marginTop: Spacing.md, marginBottom: Spacing.xxl },
sliderLabel: { fontSize: 12, color: Colors.gray[400] }, scaleLabel: { fontSize: FontSize.xs, color: Colors.gray[500] },
sliderWrapper: { flex: 1 }, navRow: { flexDirection: 'row', justifyContent: 'space-between', gap: Spacing.md, marginTop: Spacing.md },
nativeSlider: {}, navRowEnd: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: Spacing.xl },
sliderTrack: { height: 6, backgroundColor: Colors.gray[200], borderRadius: 3, marginBottom: 8 },
sliderFill: { height: 6, backgroundColor: Colors.primary, borderRadius: 3 },
sliderButtons: { flexDirection: 'row', justifyContent: 'space-between' },
sliderDot: { width: 28, height: 28, borderRadius: 4, backgroundColor: Colors.gray[100], justifyContent: 'center', alignItems: 'center' },
sliderDotActive: { backgroundColor: Colors.primary },
sliderDotText: { fontSize: 12, fontWeight: '600', color: Colors.gray[500] },
sliderDotTextActive: { color: Colors.white },
textArea: {
backgroundColor: Colors.gray[100], borderTopLeftRadius: 4, borderTopRightRadius: 4,
borderBottomWidth: 2, borderBottomColor: Colors.black,
padding: 16, fontSize: 15, color: Colors.black,
minHeight: 120, marginBottom: 24,
},
navRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 16 },
backBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, paddingHorizontal: 20, borderRadius: 4, backgroundColor: Colors.white, borderWidth: 1, borderColor: Colors.primary },
backBtnText: { fontSize: 15, fontWeight: '600', color: Colors.primary },
nextBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, paddingHorizontal: 24, borderRadius: 4, backgroundColor: Colors.primary },
submitBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, paddingHorizontal: 24, borderRadius: 4, backgroundColor: Colors.primary },
nextBtnText: { fontSize: 15, fontWeight: '700', color: Colors.white },
btnDisabled: { opacity: 0.5 },
}); });
+89 -78
View File
@@ -3,6 +3,8 @@ import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-nati
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRapport } from '@/hooks/useRapport'; import { useRapport } from '@/hooks/useRapport';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import { Spacing, Radius, FontSize, FontWeight } from '@/lib/theme';
import { AppBar, Card, Badge, SectionTitle, EmptyState, Loader } from '@/components/ui';
import EmotionPieChart from '@/components/EmotionPieChart'; import EmotionPieChart from '@/components/EmotionPieChart';
type Period = 'week' | 'month' | 'quarter' | 'year'; type Period = 'week' | 'month' | 'quarter' | 'year';
@@ -19,60 +21,64 @@ export default function ReportsScreen() {
const { data: rapport, isLoading } = useRapport(period); const { data: rapport, isLoading } = useRapport(period);
return ( return (
<View style={styles.screen}>
<AppBar title="Mes rapports" subtitle="Analyse de vos émotions" />
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Period selector */} {/* Sélecteur de période — boutons segmentés */}
<View style={styles.periodRow}> <View style={styles.segment}>
{PERIODS.map((p) => ( {PERIODS.map((p) => {
const active = period === p.value;
return (
<TouchableOpacity <TouchableOpacity
key={p.value} key={p.value}
style={[styles.periodBtn, period === p.value && styles.periodBtnActive]} style={[styles.segmentBtn, active && styles.segmentBtnActive]}
onPress={() => setPeriod(p.value)} onPress={() => setPeriod(p.value)}
accessibilityLabel={p.label}
> >
<Text style={[styles.periodText, period === p.value && styles.periodTextActive]}> <Text style={[styles.segmentText, active && styles.segmentTextActive]}>{p.label}</Text>
{p.label}
</Text>
</TouchableOpacity> </TouchableOpacity>
))} );
})}
</View> </View>
{isLoading ? ( {isLoading ? (
<View style={styles.loading}> <Loader label="Chargement du rapport…" />
<Text style={styles.loadingText}>Chargement...</Text> ) : rapport && rapport.stats.total_saisies > 0 ? (
</View>
) : rapport ? (
<> <>
{/* Stats cards */} {/* Stats */}
<Card padding={Spacing.lg} style={styles.statsCard}>
<View style={styles.statsRow}> <View style={styles.statsRow}>
<View style={styles.statCard}> <View style={styles.statCell}>
<Ionicons name="bar-chart" size={20} color={Colors.primary} /> <Ionicons name="bar-chart" size={20} color={Colors.primary} />
<Text style={styles.statValue}>{rapport.stats.total_saisies}</Text> <Text style={styles.statValue}>{rapport.stats.total_saisies}</Text>
<Text style={styles.statLabel}>Saisies</Text> <Text style={styles.statLabel}>Saisies</Text>
</View> </View>
<View style={styles.statCard}> <View style={styles.statDivider} />
<Ionicons name="trending-up" size={20} color={Colors.secondary} /> <View style={styles.statCell}>
<Ionicons name="trending-up" size={20} color={Colors.primary} />
<Text style={styles.statValue}>{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}</Text> <Text style={styles.statValue}>{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}</Text>
<Text style={styles.statLabel}>Intensité moy.</Text> <Text style={styles.statLabel}>Intensité moy.</Text>
</View> </View>
<View style={styles.statCard}> <View style={styles.statDivider} />
<Ionicons name="heart" size={20} color={rapport.stats.emotion_dominante?.couleur || Colors.blueFranceSun} /> <View style={styles.statCell}>
<Text style={styles.statValue}>{rapport.stats.emotion_dominante?.icone || '—'}</Text> <Text style={styles.statEmoji}>{rapport.stats.emotion_dominante?.icone || '—'}</Text>
<Text style={styles.statLabel}>{rapport.stats.emotion_dominante?.nom || 'Aucune'}</Text> <Text style={styles.statLabel} numberOfLines={1}>
</View> {rapport.stats.emotion_dominante?.nom || 'Aucune'}
</View>
{/* Period info */}
<View style={styles.periodInfo}>
<Text style={styles.periodInfoText}>
Du {new Date(rapport.date_debut).toLocaleDateString('fr-FR')} au {new Date(rapport.date_fin).toLocaleDateString('fr-FR')}
</Text> </Text>
</View> </View>
</View>
<Text style={styles.periodInfo}>
Du {new Date(rapport.date_debut).toLocaleDateString('fr-FR')} au{' '}
{new Date(rapport.date_fin).toLocaleDateString('fr-FR')}
</Text>
</Card>
{/* Repartition */} {/* Répartition */}
{rapport.repartition.length > 0 && ( {rapport.repartition.length > 0 && (
<View style={styles.section}> <View style={styles.block}>
<Text style={styles.sectionTitle}>Répartition des émotions</Text> <SectionTitle>Répartition des émotions</SectionTitle>
{/* Camembert (donut) — équivalent mobile du graphique Recharts du web */} <Card padding={Spacing.lg}>
<EmotionPieChart <EmotionPieChart
data={rapport.repartition.map((item) => ({ data={rapport.repartition.map((item) => ({
nom: item.nom, nom: item.nom,
@@ -81,68 +87,73 @@ export default function ReportsScreen() {
pourcentage: item.pourcentage, pourcentage: item.pourcentage,
}))} }))}
/> />
</Card>
<Text style={styles.subSectionTitle}>Détail par émotion</Text> <View style={styles.detailHeader}>
{rapport.repartition.map((item) => ( <SectionTitle>Détail par émotion</SectionTitle>
<View key={item.nom} style={styles.repartitionItem}> </View>
<View style={styles.repartitionHeader}> <Card padding={Spacing.lg}>
<View style={[styles.colorDot, { backgroundColor: item.couleur }]} /> {rapport.repartition.map((item, idx) => (
<Text style={styles.repartitionName}>{item.nom}</Text> <View
<Text style={styles.repartitionPct}>{item.pourcentage}%</Text> key={item.nom}
style={[styles.repItem, idx > 0 && styles.repItemBorder]}
>
<View style={styles.repHeader}>
<Badge label={item.nom} color={item.couleur} />
<Text style={styles.repPct}>{item.pourcentage}%</Text>
</View> </View>
<View style={styles.progressBar}> <View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${item.pourcentage}%`, backgroundColor: item.couleur }]} /> <View
style={[styles.progressFill, { width: `${item.pourcentage}%`, backgroundColor: item.couleur }]}
/>
</View> </View>
<View style={styles.repartitionFooter}> <View style={styles.repFooter}>
<Text style={styles.repartitionDetail}>{item.count} saisies</Text> <Text style={styles.repDetail}>{item.count} saisies</Text>
<Text style={styles.repartitionDetail}>Moy: {item.intensite_moyenne.toFixed(1)}/10</Text> <Text style={styles.repDetail}>Moy. {item.intensite_moyenne.toFixed(1)}/10</Text>
</View> </View>
</View> </View>
))} ))}
</Card>
</View> </View>
)} )}
</> </>
) : ( ) : (
<View style={styles.empty}> <EmptyState
<Ionicons name="bar-chart-outline" size={48} color={Colors.gray[300]} /> icon="bar-chart-outline"
<Text style={styles.emptyText}>Pas de données pour cette période</Text> title="Aucune donnée"
</View> message="Pas de saisie pour cette période. Changez de période ou ajoutez une émotion."
/>
)} )}
</ScrollView> </ScrollView>
</View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white }, screen: { flex: 1, backgroundColor: Colors.gray[50] },
content: { padding: 20, paddingBottom: 40 }, container: { flex: 1 },
periodRow: { flexDirection: 'row', backgroundColor: Colors.gray[100], borderRadius: 4, padding: 4, marginBottom: 20 }, content: { padding: Spacing.lg, paddingBottom: Spacing.xxxl },
periodBtn: { flex: 1, paddingVertical: 10, alignItems: 'center', borderRadius: 4 }, segment: { flexDirection: 'row', backgroundColor: Colors.gray[100], borderRadius: Radius.sm, padding: Spacing.xs, marginBottom: Spacing.lg },
periodBtnActive: { backgroundColor: Colors.primary }, segmentBtn: { flex: 1, paddingVertical: Spacing.sm, alignItems: 'center', borderRadius: Radius.sm, minHeight: 40, justifyContent: 'center' },
periodText: { fontSize: 13, fontWeight: '600', color: Colors.gray[500] }, segmentBtnActive: { backgroundColor: Colors.primary },
periodTextActive: { color: Colors.white }, segmentText: { fontSize: FontSize.xs, fontWeight: FontWeight.semibold, color: Colors.gray[500] },
loading: { padding: 40, alignItems: 'center' }, segmentTextActive: { color: Colors.white },
loadingText: { color: Colors.gray[400] }, statsCard: { marginBottom: Spacing.xl },
statsRow: { flexDirection: 'row', gap: 10, marginBottom: 16 }, statsRow: { flexDirection: 'row', alignItems: 'center' },
statCard: { statCell: { flex: 1, alignItems: 'center', gap: Spacing.xs },
flex: 1, backgroundColor: Colors.gray[50], borderRadius: 4, padding: 16, statDivider: { width: 1, alignSelf: 'stretch', backgroundColor: Colors.gray[200], marginVertical: Spacing.xs },
alignItems: 'center', gap: 6, borderWidth: 1, borderColor: Colors.gray[200], statValue: { fontSize: FontSize.xxl, fontWeight: FontWeight.heavy, color: Colors.black },
}, statEmoji: { fontSize: FontSize.xxl },
statValue: { fontSize: 22, fontWeight: '800', color: Colors.black }, statLabel: { fontSize: FontSize.tiny, color: Colors.gray[500], textAlign: 'center' },
statLabel: { fontSize: 11, color: Colors.gray[500] }, periodInfo: { fontSize: FontSize.xs, color: Colors.gray[400], textAlign: 'center', marginTop: Spacing.md },
periodInfo: { marginBottom: 20 }, block: { marginBottom: Spacing.xl },
periodInfoText: { fontSize: 13, color: Colors.gray[400], textAlign: 'center' }, detailHeader: { marginTop: Spacing.lg },
section: { marginBottom: 24 }, repItem: { paddingVertical: Spacing.md },
sectionTitle: { fontSize: 18, fontWeight: '700', color: Colors.black, marginBottom: 16 }, repItemBorder: { borderTopWidth: 1, borderTopColor: Colors.gray[200] },
subSectionTitle: { fontSize: 14, fontWeight: '700', color: Colors.gray[600], marginBottom: 12, marginTop: 4 }, repHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: Spacing.sm },
repartitionItem: { marginBottom: 16 }, repPct: { fontSize: FontSize.sm, fontWeight: FontWeight.bold, color: Colors.black },
repartitionHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 }, progressBar: { height: 6, backgroundColor: Colors.gray[200], borderRadius: Radius.sm, marginBottom: Spacing.xs },
colorDot: { width: 10, height: 10, borderRadius: 5, marginRight: 8 }, progressFill: { height: 6, borderRadius: Radius.sm },
repartitionName: { flex: 1, fontSize: 14, fontWeight: '600', color: Colors.black }, repFooter: { flexDirection: 'row', justifyContent: 'space-between' },
repartitionPct: { fontSize: 14, fontWeight: '700', color: Colors.black }, repDetail: { fontSize: FontSize.xs, color: Colors.gray[500] },
progressBar: { height: 6, backgroundColor: Colors.gray[200], borderRadius: 3, marginBottom: 4 },
progressFill: { height: 6, borderRadius: 3 },
repartitionFooter: { flexDirection: 'row', justifyContent: 'space-between' },
repartitionDetail: { fontSize: 12, color: Colors.gray[400] },
empty: { alignItems: 'center', paddingTop: 60 },
emptyText: { fontSize: 16, color: Colors.gray[400], marginTop: 12 },
}); });
+99 -49
View File
@@ -3,11 +3,15 @@ import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, MIN_TOUCH, Radius, Spacing } from '@/lib/theme';
import { AppBar, Badge, Button, Card, SectionTitle } from '@/components/ui';
export default function ProfileScreen() { export default function ProfileScreen() {
const router = useRouter(); const router = useRouter();
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const isAdmin = user?.role?.nom === 'administrateur';
const handleLogout = () => { const handleLogout = () => {
Alert.alert('Déconnexion', 'Voulez-vous vous déconnecter ?', [ Alert.alert('Déconnexion', 'Voulez-vous vous déconnecter ?', [
{ text: 'Annuler', style: 'cancel' }, { text: 'Annuler', style: 'cancel' },
@@ -22,86 +26,132 @@ export default function ProfileScreen() {
]); ]);
}; };
const initiales = `${user?.prenom?.[0] ?? ''}${user?.nom?.[0] ?? ''}`.toUpperCase();
return ( return (
<View style={styles.screen}>
<AppBar title="Profil" />
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Avatar & Name */} {/* Avatar & identité */}
<View style={styles.profileHeader}> <Card style={styles.profileCard}>
<View style={styles.avatar}> <View style={styles.avatar}>
<Text style={styles.avatarText}> <Text style={styles.avatarText}>{initiales}</Text>
{user?.prenom?.[0]}{user?.nom?.[0]}
</Text>
</View> </View>
<Text style={styles.name}>{user?.prenom} {user?.nom}</Text> <Text style={styles.name}>
{user?.prenom} {user?.nom}
</Text>
<Text style={styles.email}>{user?.email}</Text> <Text style={styles.email}>{user?.email}</Text>
<View style={styles.roleBadge}> <View style={styles.roleBadge}>
<Text style={styles.roleText}>{user?.role?.nom || 'Membre'}</Text> <Badge label={user?.role?.nom ?? 'Membre'} tone="primary" />
</View>
</View> </View>
</Card>
{/* Menu items */} {/* Entrée admin — visible uniquement pour les administrateurs */}
<View style={styles.menuSection}> {isAdmin ? (
<Text style={styles.sectionTitle}>Compte</Text> <View style={styles.section}>
<View style={styles.menuCard}> <SectionTitle>Administration</SectionTitle>
<Card onPress={() => router.push('/admin')} style={styles.adminCard}>
<View style={styles.adminIcon}>
<Ionicons name="shield-checkmark" size={24} color={Colors.primary} />
</View>
<View style={styles.adminContent}>
<Text style={styles.adminTitle}>Espace administration</Text>
<Text style={styles.adminSubtitle}>Gérer les utilisateurs, contenus et émotions</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={Colors.gray[400]} />
</Card>
</View>
) : null}
{/* Menu compte */}
<View style={styles.section}>
<SectionTitle>Compte</SectionTitle>
<Card padding={0} style={styles.menuCard}>
<MenuItem icon="person-outline" label="Informations personnelles" /> <MenuItem icon="person-outline" label="Informations personnelles" />
<MenuItem icon="lock-closed-outline" label="Changer le mot de passe" /> <MenuItem icon="lock-closed-outline" label="Changer le mot de passe" />
<MenuItem icon="shield-checkmark-outline" label="Données personnelles (RGPD)" /> <MenuItem icon="shield-checkmark-outline" label="Données personnelles (RGPD)" last />
</View> </Card>
</View> </View>
<View style={styles.menuSection}> {/* Menu application */}
<Text style={styles.sectionTitle}>Application</Text> <View style={styles.section}>
<View style={styles.menuCard}> <SectionTitle>Application</SectionTitle>
<Card padding={0} style={styles.menuCard}>
<MenuItem icon="information-circle-outline" label="À propos de CESIZen" /> <MenuItem icon="information-circle-outline" label="À propos de CESIZen" />
<MenuItem icon="document-text-outline" label="Conditions d'utilisation" /> <MenuItem icon="document-text-outline" label="Conditions d'utilisation" last />
</View> </Card>
</View> </View>
{/* Logout */} {/* Déconnexion */}
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout}> <Button title="Se déconnecter" variant="danger" icon="log-out-outline" fullWidth onPress={handleLogout} />
<Ionicons name="log-out-outline" size={20} color={Colors.error} />
<Text style={styles.logoutText}>Se déconnecter</Text>
</TouchableOpacity>
<Text style={styles.version}>CESIZen v1.0.0</Text> <Text style={styles.version}>CESIZen v1.0.0</Text>
</ScrollView> </ScrollView>
</View>
); );
} }
function MenuItem({ icon, label }: { icon: keyof typeof Ionicons.glyphMap; label: string }) { function MenuItem({
icon,
label,
last = false,
}: {
icon: keyof typeof Ionicons.glyphMap;
label: string;
last?: boolean;
}) {
return ( return (
<TouchableOpacity style={styles.menuItem}> <TouchableOpacity style={[styles.menuItem, last && styles.menuItemLast]} accessibilityRole="button">
<Ionicons name={icon} size={20} color={Colors.gray[600]} /> <Ionicons name={icon} size={20} color={Colors.gray[600]} />
<Text style={styles.menuLabel}>{label}</Text> <Text style={styles.menuLabel}>{label}</Text>
<Ionicons name="chevron-forward" size={18} color={Colors.gray[300]} /> <Ionicons name="chevron-forward" size={18} color={Colors.gray[400]} />
</TouchableOpacity> </TouchableOpacity>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white }, screen: { flex: 1, backgroundColor: Colors.gray[50] },
content: { padding: 20, paddingBottom: 40 }, container: { flex: 1 },
profileHeader: { alignItems: 'center', marginBottom: 32, marginTop: 12 }, content: { padding: Spacing.lg, paddingBottom: Spacing.xxxl, gap: Spacing.xl },
profileCard: { alignItems: 'center', paddingVertical: Spacing.xl },
avatar: { avatar: {
width: 80, height: 80, borderRadius: 4, backgroundColor: Colors.primary, width: 80,
justifyContent: 'center', alignItems: 'center', marginBottom: 12, height: 80,
borderRadius: Radius.sm,
backgroundColor: Colors.primary,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.md,
}, },
avatarText: { fontSize: 28, fontWeight: '800', color: Colors.white }, avatarText: { fontSize: FontSize.display, fontWeight: FontWeight.heavy, color: Colors.white },
name: { fontSize: 22, fontWeight: '700', color: Colors.black }, name: { fontSize: FontSize.xl, fontWeight: FontWeight.bold, color: Colors.black },
email: { fontSize: 14, color: Colors.gray[500], marginTop: 4 }, email: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: Spacing.xs },
roleBadge: { backgroundColor: `${Colors.primary}15`, paddingHorizontal: 12, paddingVertical: 4, borderRadius: 4, marginTop: 8 }, roleBadge: { marginTop: Spacing.sm },
roleText: { fontSize: 12, fontWeight: '600', color: Colors.primary, textTransform: 'capitalize' }, section: { gap: Spacing.sm },
menuSection: { marginBottom: 24 }, adminCard: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md, padding: Spacing.lg },
sectionTitle: { fontSize: 13, fontWeight: '700', color: Colors.gray[500], textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }, adminIcon: {
menuCard: { backgroundColor: Colors.white, borderRadius: 4, overflow: 'hidden', borderWidth: 1, borderColor: Colors.gray[200] }, width: 48,
height: 48,
borderRadius: Radius.sm,
backgroundColor: '#E3E3FD',
justifyContent: 'center',
alignItems: 'center',
},
adminContent: { flex: 1 },
adminTitle: { fontSize: FontSize.body, fontWeight: FontWeight.bold, color: Colors.black, marginBottom: 2 },
adminSubtitle: { fontSize: FontSize.xs, color: Colors.gray[500], lineHeight: 18 },
menuCard: { overflow: 'hidden' },
menuItem: { menuItem: {
flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 15, paddingHorizontal: 16, flexDirection: 'row',
borderBottomWidth: 1, borderBottomColor: Colors.gray[200], alignItems: 'center',
gap: Spacing.md,
minHeight: MIN_TOUCH,
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.lg,
borderBottomWidth: 1,
borderBottomColor: Colors.gray[200],
}, },
menuLabel: { flex: 1, fontSize: 15, color: Colors.black }, menuItemLast: { borderBottomWidth: 0 },
logoutBtn: { menuLabel: { flex: 1, fontSize: FontSize.body, color: Colors.black },
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, version: { textAlign: 'center', fontSize: FontSize.xs, color: Colors.gray[400] },
paddingVertical: 16, borderRadius: 4, backgroundColor: Colors.errorBg,
},
logoutText: { fontSize: 15, fontWeight: '600', color: Colors.error },
version: { textAlign: 'center', fontSize: 12, color: Colors.gray[400], marginTop: 20 },
}); });
+53 -34
View File
@@ -1,6 +1,8 @@
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Linking, Alert } from 'react-native'; import { View, Text, StyleSheet, ScrollView, Linking, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
import { AppBar, Button, Card } from '@/components/ui';
type Contact = { type Contact = {
nom: string; nom: string;
@@ -65,61 +67,78 @@ export default function UrgenceScreen() {
}; };
return ( return (
<View style={styles.screen}>
<AppBar title="Urgence" subtitle="Vous n'êtes pas seul(e)" />
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.header}> <Text style={styles.intro}>
<View style={styles.iconBadge}>
<Ionicons name="heart" size={20} color={Colors.error} />
</View>
<Text style={styles.headerTitle}>Vous n'êtes pas seul(e)</Text>
<Text style={styles.headerSubtitle}>
Si vous ou un proche traversez une période difficile, ces services sont là pour vous écouter. Si vous ou un proche traversez une période difficile, ces services sont là pour vous écouter.
</Text> </Text>
</View>
{CONTACTS.map((c) => ( {CONTACTS.map((c) => (
<TouchableOpacity key={c.numero} style={styles.card} onPress={() => appeler(c.numero, c.nom)}> <Card key={c.numero} style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.cardIcon}> <View style={styles.cardIcon}>
<Ionicons name="call" size={22} color={Colors.error} /> <Ionicons name="call" size={22} color={Colors.error} />
</View> </View>
<View style={styles.cardContent}> <View style={styles.cardHeaderText}>
<Text style={styles.cardName}>{c.nom}</Text> <Text style={styles.cardName}>{c.nom}</Text>
<Text style={styles.cardNumber}>{formatNumero(c.numero)}</Text> <Text style={styles.cardNumber}>{formatNumero(c.numero)}</Text>
</View>
</View>
<Text style={styles.cardDescription}>{c.description}</Text> <Text style={styles.cardDescription}>{c.description}</Text>
<View style={styles.cardAvailabilityRow}>
<Ionicons name="time-outline" size={14} color={Colors.gray[500]} />
<Text style={styles.cardAvailability}>{c.disponibilite}</Text> <Text style={styles.cardAvailability}>{c.disponibilite}</Text>
</View> </View>
<Ionicons name="chevron-forward" size={18} color={Colors.gray[300]} /> <Button
</TouchableOpacity> title={`Appeler le ${formatNumero(c.numero)}`}
variant="danger"
icon="call"
fullWidth
onPress={() => appeler(c.numero, c.nom)}
style={styles.cardButton}
/>
</Card>
))} ))}
<View style={styles.warning}> <Card style={styles.warning} padding={Spacing.lg}>
<Ionicons name="warning" size={18} color={Colors.error} /> <View style={styles.warningRow}>
<Ionicons name="warning" size={20} color={Colors.error} />
<Text style={styles.warningText}> <Text style={styles.warningText}>
En cas d'urgence vitale, appelez immédiatement le <Text style={styles.bold}>15</Text> (SAMU) ou le <Text style={styles.bold}>112</Text>. En cas d'urgence vitale, appelez immédiatement le <Text style={styles.bold}>15</Text> (SAMU) ou le{' '}
<Text style={styles.bold}>112</Text>.
</Text> </Text>
</View> </View>
</Card>
</ScrollView> </ScrollView>
</View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white }, screen: { flex: 1, backgroundColor: Colors.gray[50] },
content: { padding: 16, paddingBottom: 32 }, container: { flex: 1 },
header: { marginBottom: 20, alignItems: 'flex-start' }, content: { padding: Spacing.lg, paddingBottom: Spacing.xxl, gap: Spacing.md },
iconBadge: { width: 40, height: 40, borderRadius: 4, backgroundColor: Colors.errorBg, justifyContent: 'center', alignItems: 'center', marginBottom: 12 }, intro: { fontSize: FontSize.sm, color: Colors.gray[500], lineHeight: 20, marginBottom: Spacing.xs },
headerTitle: { fontSize: 22, fontWeight: '800', color: Colors.black, marginBottom: 6 }, card: { padding: Spacing.lg, gap: Spacing.sm },
headerSubtitle: { fontSize: 14, color: Colors.gray[500], lineHeight: 20 }, cardHeader: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md },
card: { cardIcon: {
flexDirection: 'row', alignItems: 'center', gap: 14, padding: 16, width: 44,
backgroundColor: Colors.white, borderRadius: 4, marginBottom: 10, height: 44,
borderWidth: 1, borderColor: Colors.gray[200], borderRadius: Radius.sm,
backgroundColor: Colors.errorBg,
justifyContent: 'center',
alignItems: 'center',
}, },
cardIcon: { width: 44, height: 44, borderRadius: 4, backgroundColor: Colors.errorBg, justifyContent: 'center', alignItems: 'center' }, cardHeaderText: { flex: 1 },
cardContent: { flex: 1 }, cardName: { fontSize: FontSize.md, fontWeight: FontWeight.bold, color: Colors.black, marginBottom: 2 },
cardName: { fontSize: 14, fontWeight: '700', color: Colors.black, marginBottom: 2 }, cardNumber: { fontSize: FontSize.xl, fontWeight: FontWeight.heavy, color: Colors.error },
cardNumber: { fontSize: 20, fontWeight: '800', color: Colors.error, marginBottom: 6 }, cardDescription: { fontSize: FontSize.xs, color: Colors.gray[600], lineHeight: 19 },
cardDescription: { fontSize: 12, color: Colors.gray[500], lineHeight: 17 }, cardAvailabilityRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.xs },
cardAvailability: { fontSize: 11, color: Colors.primary, marginTop: 4, fontWeight: '600' }, cardAvailability: { fontSize: FontSize.tiny, color: Colors.gray[500], fontWeight: FontWeight.semibold },
warning: { marginTop: 8, padding: 14, borderRadius: 4, backgroundColor: Colors.errorBg, borderWidth: 1, borderColor: Colors.error, flexDirection: 'row', gap: 10, alignItems: 'flex-start' }, cardButton: { marginTop: Spacing.xs },
warningText: { flex: 1, fontSize: 13, color: Colors.error, lineHeight: 19 }, warning: { backgroundColor: Colors.errorBg, borderColor: Colors.error },
bold: { fontWeight: '800' }, warningRow: { flexDirection: 'row', gap: Spacing.md, alignItems: 'flex-start' },
warningText: { flex: 1, fontSize: FontSize.xs, color: Colors.error, lineHeight: 19 },
bold: { fontWeight: FontWeight.heavy },
}); });
+23
View File
@@ -0,0 +1,23 @@
import { Stack } from 'expo-router';
import AdminGuard from '@/components/AdminGuard';
import { Colors } from '@/lib/colors';
/** Pile de navigation de l'espace d'administration (protégée par AdminGuard). */
export default function AdminLayout() {
return (
<AdminGuard>
<Stack
screenOptions={{
headerStyle: { backgroundColor: Colors.white },
headerTintColor: Colors.primary,
headerTitleStyle: { fontWeight: '700', color: Colors.black },
}}
>
<Stack.Screen name="index" options={{ title: 'Administration' }} />
<Stack.Screen name="utilisateurs" options={{ title: 'Utilisateurs' }} />
<Stack.Screen name="contenus" options={{ title: 'Contenus' }} />
<Stack.Screen name="emotions" options={{ title: 'Émotions' }} />
</Stack>
</AdminGuard>
);
}
+217
View File
@@ -0,0 +1,217 @@
import { useState } from 'react';
import {
Alert,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Badge, Button, Card, EmptyState, Loader, TextField } from '@/components/ui';
import { useAdminFeeds, useSaveFeed, useDeleteFeed } from '@/hooks/useAdmin';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, HIT_SLOP, Radius, Spacing } from '@/lib/theme';
import type { Feed } from '@/types';
interface FormState {
id?: number;
titre: string;
contenu: string;
ordre: string;
est_publie: boolean;
}
const FORM_VIDE: FormState = { titre: '', contenu: '', ordre: '0', est_publie: false };
function messageErreur(e: unknown, defaut: string): string {
const err = e as { response?: { data?: { message?: string } } };
return err?.response?.data?.message ?? defaut;
}
export default function AdminContenusScreen() {
const { data: feeds, isLoading } = useAdminFeeds();
const saveMut = useSaveFeed();
const deleteMut = useDeleteFeed();
const [modalVisible, setModalVisible] = useState(false);
const [form, setForm] = useState<FormState>(FORM_VIDE);
const ouvrirCreation = () => {
setForm(FORM_VIDE);
setModalVisible(true);
};
const ouvrirEdition = (f: Feed) => {
setForm({ id: f.id, titre: f.titre, contenu: f.contenu, ordre: String(f.ordre), est_publie: f.est_publie });
setModalVisible(true);
};
const set = (cle: 'titre' | 'contenu' | 'ordre') => (valeur: string) => setForm((f) => ({ ...f, [cle]: valeur }));
const soumettre = async () => {
if (!form.titre.trim() || !form.contenu.trim()) {
Alert.alert('Champs requis', 'Le titre et le contenu sont obligatoires.');
return;
}
try {
await saveMut.mutateAsync({
id: form.id,
titre: form.titre.trim(),
contenu: form.contenu.trim(),
ordre: Number.parseInt(form.ordre, 10) || 0,
est_publie: form.est_publie,
});
setModalVisible(false);
} catch (e) {
Alert.alert('Erreur', messageErreur(e, 'Enregistrement impossible.'));
}
};
const supprimer = (f: Feed) => {
Alert.alert('Supprimer', `Supprimer « ${f.titre} » ?`, [
{ text: 'Annuler', style: 'cancel' },
{
text: 'Supprimer',
style: 'destructive',
onPress: async () => {
try {
await deleteMut.mutateAsync(f.id);
} catch (e) {
Alert.alert('Erreur', messageErreur(e, 'Suppression impossible.'));
}
},
},
]);
};
const renderItem = ({ item }: { item: Feed }) => (
<Card style={styles.card}>
<Text style={styles.titre}>{item.titre}</Text>
<View style={styles.badges}>
<Badge label={item.est_publie ? 'Publié' : 'Brouillon'} tone={item.est_publie ? 'success' : 'warning'} />
<Badge label={`Ordre ${item.ordre}`} tone="neutral" />
</View>
<Text style={styles.extrait} numberOfLines={2}>
{item.contenu}
</Text>
<View style={styles.actions}>
<Button title="Modifier" variant="secondary" size="sm" icon="create-outline" onPress={() => ouvrirEdition(item)} />
<Button title="Supprimer" variant="danger" size="sm" icon="trash-outline" onPress={() => supprimer(item)} />
</View>
</Card>
);
if (isLoading) {
return <Loader label="Chargement des contenus…" />;
}
return (
<View style={styles.screen}>
<FlatList
data={feeds ?? []}
keyExtractor={(item) => String(item.id)}
renderItem={renderItem}
contentContainerStyle={styles.list}
ListHeaderComponent={
<View style={styles.headerAction}>
<Button title="Nouveau contenu" icon="add" fullWidth onPress={ouvrirCreation} />
</View>
}
ListEmptyComponent={
<EmptyState icon="newspaper-outline" title="Aucun contenu" message="Créez un premier article d’information." />
}
/>
<Modal visible={modalVisible} animationType="slide" transparent onRequestClose={() => setModalVisible(false)}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{form.id ? 'Modifier le contenu' : 'Nouveau contenu'}</Text>
<Pressable hitSlop={HIT_SLOP} onPress={() => setModalVisible(false)} accessibilityLabel="Fermer">
<Ionicons name="close" size={24} color={Colors.gray[600]} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody} keyboardShouldPersistTaps="handled">
<TextField label="Titre" value={form.titre} onChangeText={set('titre')} required autoCapitalize="sentences" />
<TextField
label="Contenu"
value={form.contenu}
onChangeText={set('contenu')}
required
multiline
numberOfLines={6}
autoCapitalize="sentences"
/>
<TextField label="Ordre d’affichage" value={form.ordre} onChangeText={set('ordre')} keyboardType="numeric" />
<View style={styles.switchRow}>
<View style={styles.switchText}>
<Text style={styles.fieldLabel}>Publié</Text>
<Text style={styles.switchHelper}>
{form.est_publie ? 'Visible par le public' : 'Brouillon (masqué)'}
</Text>
</View>
<Switch
value={form.est_publie}
onValueChange={(v) => setForm((f) => ({ ...f, est_publie: v }))}
trackColor={{ true: Colors.primary, false: Colors.gray[300] }}
thumbColor={Colors.white}
/>
</View>
<View style={styles.modalActions}>
<Button title="Annuler" variant="tertiary" onPress={() => setModalVisible(false)} />
<Button title="Enregistrer" icon="checkmark" loading={saveMut.isPending} onPress={soumettre} />
</View>
</ScrollView>
</View>
</KeyboardAvoidingView>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: Colors.gray[50] },
list: { padding: Spacing.lg, gap: Spacing.md },
headerAction: { marginBottom: Spacing.md },
card: { padding: Spacing.lg, gap: Spacing.sm },
titre: { fontSize: FontSize.md, fontWeight: FontWeight.bold, color: Colors.black },
badges: { flexDirection: 'row', gap: Spacing.sm, flexWrap: 'wrap' },
extrait: { fontSize: FontSize.sm, color: Colors.gray[500], lineHeight: 20 },
actions: { flexDirection: 'row', gap: Spacing.sm, marginTop: Spacing.xs },
modalOverlay: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(22,22,22,0.4)' },
modalSheet: {
backgroundColor: Colors.white,
borderTopLeftRadius: Radius.md,
borderTopRightRadius: Radius.md,
maxHeight: '90%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: Spacing.lg,
borderBottomWidth: 1,
borderBottomColor: Colors.gray[200],
},
modalTitle: { fontSize: FontSize.lg, fontWeight: FontWeight.bold, color: Colors.black },
modalBody: { padding: Spacing.lg },
fieldLabel: { fontSize: FontSize.sm, fontWeight: FontWeight.medium, color: Colors.black },
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: Spacing.sm,
marginBottom: Spacing.md,
},
switchText: { flex: 1, paddingRight: Spacing.md },
switchHelper: { fontSize: FontSize.xs, color: Colors.gray[500], marginTop: 2 },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: Spacing.sm, marginTop: Spacing.sm },
});
+361
View File
@@ -0,0 +1,361 @@
import { useMemo, useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Button, Card, EmptyState, Loader, SectionTitle, TextField } from '@/components/ui';
import { useAdminEmotions, useSaveEmotion, useDeleteEmotion } from '@/hooks/useAdmin';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, HIT_SLOP, Radius, Spacing } from '@/lib/theme';
import type { Emotion } from '@/types';
interface FormState {
id?: number;
nom: string;
couleur: string;
icone: string;
niveau: number;
parent_id: number | null;
est_actif: boolean;
}
const FORM_VIDE: FormState = { nom: '', couleur: '#000091', icone: '', niveau: 1, parent_id: null, est_actif: true };
function messageErreur(e: unknown, defaut: string): string {
const err = e as { response?: { data?: { message?: string } } };
return err?.response?.data?.message ?? defaut;
}
/** Regroupe une liste d'émotions (plate ou imbriquée) en niveau 1 + leurs enfants. */
function organiser(emotions: Emotion[] | undefined): { parent: Emotion; enfants: Emotion[] }[] {
if (!emotions) return [];
const racines = emotions.filter((e) => e.niveau === 1);
return racines.map((parent) => {
const depuisEnfants = parent.enfants ?? [];
const depuisPlat = emotions.filter((e) => e.niveau === 2 && e.parent_id === parent.id);
// On dédoublonne par id au cas où les deux sources coexistent.
const map = new Map<number, Emotion>();
[...depuisEnfants, ...depuisPlat].forEach((e) => map.set(e.id, e));
return { parent, enfants: Array.from(map.values()) };
});
}
export default function AdminEmotionsScreen() {
const { data: emotions, isLoading } = useAdminEmotions();
const saveMut = useSaveEmotion();
const deleteMut = useDeleteEmotion();
const [modalVisible, setModalVisible] = useState(false);
const [form, setForm] = useState<FormState>(FORM_VIDE);
const groupes = useMemo(() => organiser(emotions), [emotions]);
const parents = useMemo(() => (emotions ?? []).filter((e) => e.niveau === 1), [emotions]);
const ouvrirCreation = () => {
setForm(FORM_VIDE);
setModalVisible(true);
};
const ouvrirEdition = (e: Emotion) => {
setForm({
id: e.id,
nom: e.nom,
couleur: e.couleur,
icone: e.icone ?? '',
niveau: e.niveau,
parent_id: e.parent_id,
est_actif: e.est_actif,
});
setModalVisible(true);
};
const soumettre = async () => {
if (!form.nom.trim()) {
Alert.alert('Champs requis', 'Le nom est obligatoire.');
return;
}
if (!/^#([0-9A-Fa-f]{6})$/.test(form.couleur.trim())) {
Alert.alert('Couleur', 'Saisissez une couleur hexadécimale (ex. #000091).');
return;
}
if (form.niveau === 2 && !form.parent_id) {
Alert.alert('Émotion parente', 'Choisissez l’émotion parente pour une sous-émotion.');
return;
}
try {
await saveMut.mutateAsync({
id: form.id,
nom: form.nom.trim(),
couleur: form.couleur.trim(),
icone: form.icone.trim() || null,
niveau: form.niveau,
parent_id: form.niveau === 2 ? form.parent_id : null,
est_actif: form.est_actif,
});
setModalVisible(false);
} catch (e) {
Alert.alert('Erreur', messageErreur(e, 'Enregistrement impossible.'));
}
};
const supprimer = (e: Emotion) => {
Alert.alert('Supprimer', `Supprimer « ${e.nom} » ?`, [
{ text: 'Annuler', style: 'cancel' },
{
text: 'Supprimer',
style: 'destructive',
onPress: async () => {
try {
await deleteMut.mutateAsync(e.id);
} catch (err) {
Alert.alert('Erreur', messageErreur(err, 'Suppression impossible.'));
}
},
},
]);
};
const renderEmotion = (e: Emotion, estEnfant: boolean) => (
<Card key={e.id} style={[styles.emotionCard, estEnfant && styles.emotionEnfant]}>
<View style={styles.emotionRow}>
<View style={[styles.pastille, { backgroundColor: e.couleur }]}>
{e.icone ? <Text style={styles.pastilleIcone}>{e.icone}</Text> : null}
</View>
<View style={styles.emotionInfo}>
<Text style={styles.emotionNom}>{e.nom}</Text>
<Text style={styles.emotionMeta}>
Niveau {e.niveau} · {e.est_actif ? 'Actif' : 'Inactif'} · {e.couleur}
</Text>
</View>
</View>
<View style={styles.actions}>
<Button title="Modifier" variant="secondary" size="sm" icon="create-outline" onPress={() => ouvrirEdition(e)} />
<Button title="Supprimer" variant="danger" size="sm" icon="trash-outline" onPress={() => supprimer(e)} />
</View>
</Card>
);
if (isLoading) {
return <Loader label="Chargement des émotions…" />;
}
return (
<View style={styles.screen}>
<ScrollView contentContainerStyle={styles.list}>
<View style={styles.headerAction}>
<Button title="Nouvelle émotion" icon="add" fullWidth onPress={ouvrirCreation} />
</View>
{groupes.length === 0 ? (
<EmptyState icon="color-palette-outline" title="Aucune émotion" message="Créez la première émotion du référentiel." />
) : (
groupes.map(({ parent, enfants }) => (
<View key={parent.id} style={styles.groupe}>
<SectionTitle>{parent.nom}</SectionTitle>
{renderEmotion(parent, false)}
{enfants.map((enfant) => renderEmotion(enfant, true))}
</View>
))
)}
</ScrollView>
<Modal visible={modalVisible} animationType="slide" transparent onRequestClose={() => setModalVisible(false)}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{form.id ? 'Modifier l’émotion' : 'Nouvelle émotion'}</Text>
<Pressable hitSlop={HIT_SLOP} onPress={() => setModalVisible(false)} accessibilityLabel="Fermer">
<Ionicons name="close" size={24} color={Colors.gray[600]} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody} keyboardShouldPersistTaps="handled">
<TextField
label="Nom"
value={form.nom}
onChangeText={(v) => setForm((f) => ({ ...f, nom: v }))}
required
autoCapitalize="words"
/>
<View style={styles.couleurRow}>
<View style={styles.couleurField}>
<TextField
label="Couleur (hex)"
value={form.couleur}
onChangeText={(v) => setForm((f) => ({ ...f, couleur: v }))}
placeholder="#000091"
required
/>
</View>
<View style={[styles.couleurApercu, { backgroundColor: /^#([0-9A-Fa-f]{6})$/.test(form.couleur) ? form.couleur : Colors.gray[200] }]} />
</View>
<TextField
label="Icône (emoji, optionnel)"
value={form.icone}
onChangeText={(v) => setForm((f) => ({ ...f, icone: v }))}
placeholder="😊"
autoCapitalize="none"
/>
<Text style={styles.fieldLabel}>Niveau</Text>
<View style={styles.segment}>
<Pressable
style={[styles.segmentItem, form.niveau === 1 && styles.segmentItemActive]}
onPress={() => setForm((f) => ({ ...f, niveau: 1, parent_id: null }))}
>
<Text style={[styles.segmentText, form.niveau === 1 && styles.segmentTextActive]}>
1 · Émotion
</Text>
</Pressable>
<Pressable
style={[styles.segmentItem, form.niveau === 2 && styles.segmentItemActive]}
onPress={() => setForm((f) => ({ ...f, niveau: 2 }))}
>
<Text style={[styles.segmentText, form.niveau === 2 && styles.segmentTextActive]}>
2 · Sous-émotion
</Text>
</Pressable>
</View>
{form.niveau === 2 ? (
<>
<Text style={styles.fieldLabel}>Émotion parente</Text>
<View style={styles.parentList}>
{parents.length === 0 ? (
<Text style={styles.switchHelper}>Aucune émotion de niveau 1 disponible.</Text>
) : (
parents.map((p) => {
const actif = form.parent_id === p.id;
return (
<Pressable
key={p.id}
style={[styles.parentChip, actif && styles.parentChipActive]}
onPress={() => setForm((f) => ({ ...f, parent_id: p.id }))}
>
<View style={[styles.parentDot, { backgroundColor: p.couleur }]} />
<Text style={[styles.parentChipText, actif && styles.parentChipTextActive]}>{p.nom}</Text>
</Pressable>
);
})
)}
</View>
</>
) : null}
<View style={styles.switchRow}>
<View style={styles.switchText}>
<Text style={styles.fieldLabel}>Actif</Text>
<Text style={styles.switchHelper}>
{form.est_actif ? 'Disponible dans le tracker' : 'Masqué du tracker'}
</Text>
</View>
<Switch
value={form.est_actif}
onValueChange={(v) => setForm((f) => ({ ...f, est_actif: v }))}
trackColor={{ true: Colors.primary, false: Colors.gray[300] }}
thumbColor={Colors.white}
/>
</View>
<View style={styles.modalActions}>
<Button title="Annuler" variant="tertiary" onPress={() => setModalVisible(false)} />
<Button title="Enregistrer" icon="checkmark" loading={saveMut.isPending} onPress={soumettre} />
</View>
</ScrollView>
</View>
</KeyboardAvoidingView>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: Colors.gray[50] },
list: { padding: Spacing.lg, gap: Spacing.md },
headerAction: { marginBottom: Spacing.md },
groupe: { gap: Spacing.sm, marginBottom: Spacing.md },
emotionCard: { padding: Spacing.md, gap: Spacing.sm },
emotionEnfant: { marginLeft: Spacing.xl, borderColor: Colors.gray[100] },
emotionRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md },
pastille: { width: 36, height: 36, borderRadius: Radius.pill, alignItems: 'center', justifyContent: 'center' },
pastilleIcone: { fontSize: 18 },
emotionInfo: { flex: 1 },
emotionNom: { fontSize: FontSize.md, fontWeight: FontWeight.semibold, color: Colors.black },
emotionMeta: { fontSize: FontSize.xs, color: Colors.gray[500], marginTop: 2 },
actions: { flexDirection: 'row', gap: Spacing.sm },
modalOverlay: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(22,22,22,0.4)' },
modalSheet: {
backgroundColor: Colors.white,
borderTopLeftRadius: Radius.md,
borderTopRightRadius: Radius.md,
maxHeight: '92%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: Spacing.lg,
borderBottomWidth: 1,
borderBottomColor: Colors.gray[200],
},
modalTitle: { fontSize: FontSize.lg, fontWeight: FontWeight.bold, color: Colors.black },
modalBody: { padding: Spacing.lg },
fieldLabel: { fontSize: FontSize.sm, fontWeight: FontWeight.medium, color: Colors.black, marginBottom: Spacing.xs },
couleurRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.md },
couleurField: { flex: 1 },
couleurApercu: {
width: 44,
height: 44,
borderRadius: Radius.sm,
borderWidth: 1,
borderColor: Colors.gray[200],
marginTop: 24,
},
segment: {
flexDirection: 'row',
borderWidth: 1,
borderColor: Colors.primary,
borderRadius: Radius.sm,
overflow: 'hidden',
marginBottom: Spacing.lg,
},
segmentItem: { flex: 1, paddingVertical: Spacing.md, alignItems: 'center', backgroundColor: Colors.white },
segmentItemActive: { backgroundColor: Colors.primary },
segmentText: { fontSize: FontSize.sm, fontWeight: FontWeight.semibold, color: Colors.primary },
segmentTextActive: { color: Colors.white },
parentList: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.sm, marginBottom: Spacing.lg },
parentChip: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
borderRadius: Radius.sm,
borderWidth: 1,
borderColor: Colors.gray[300],
backgroundColor: Colors.white,
minHeight: 44,
},
parentChipActive: { borderColor: Colors.primary, backgroundColor: '#E3E3FD' },
parentDot: { width: 14, height: 14, borderRadius: Radius.pill },
parentChipText: { fontSize: FontSize.sm, color: Colors.gray[600] },
parentChipTextActive: { color: Colors.primary, fontWeight: FontWeight.semibold },
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: Spacing.sm,
marginBottom: Spacing.md,
},
switchText: { flex: 1, paddingRight: Spacing.md },
switchHelper: { fontSize: FontSize.xs, color: Colors.gray[500], marginTop: 2 },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: Spacing.sm, marginTop: Spacing.sm },
});
+104
View File
@@ -0,0 +1,104 @@
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Card, SectionTitle } from '@/components/ui';
import { useAdminUtilisateurs, useAdminFeeds, useAdminEmotions } from '@/hooks/useAdmin';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
interface HubItem {
href: '/admin/utilisateurs' | '/admin/contenus' | '/admin/emotions';
icon: keyof typeof Ionicons.glyphMap;
titre: string;
sousTitre: string;
compteur?: number;
}
/** Hub d'administration : accès aux trois sections de gestion. */
export default function AdminHubScreen() {
const router = useRouter();
const { data: utilisateurs } = useAdminUtilisateurs();
const { data: feeds } = useAdminFeeds();
const { data: emotions } = useAdminEmotions();
const items: HubItem[] = [
{
href: '/admin/utilisateurs',
icon: 'people-outline',
titre: 'Utilisateurs',
sousTitre: 'Comptes, rôles et activation',
compteur: utilisateurs?.length,
},
{
href: '/admin/contenus',
icon: 'newspaper-outline',
titre: 'Contenus',
sousTitre: 'Articles d’information santé mentale',
compteur: feeds?.length,
},
{
href: '/admin/emotions',
icon: 'color-palette-outline',
titre: 'Émotions',
sousTitre: 'Référentiel des émotions et sous-émotions',
compteur: emotions?.length,
},
];
return (
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
<SectionTitle>Espace d’administration</SectionTitle>
<Text style={styles.intro}>Gérez les utilisateurs, les contenus et le référentiel des émotions.</Text>
<View style={styles.list}>
{items.map((item) => (
<Card key={item.href} onPress={() => router.push(item.href)} style={styles.card}>
<View style={styles.row}>
<View style={styles.iconCircle}>
<Ionicons name={item.icon} size={26} color={Colors.primary} />
</View>
<View style={styles.cardBody}>
<Text style={styles.cardTitle}>{item.titre}</Text>
<Text style={styles.cardSubtitle}>{item.sousTitre}</Text>
</View>
<View style={styles.cardEnd}>
{typeof item.compteur === 'number' ? (
<Text style={styles.count}>{item.compteur}</Text>
) : null}
<Ionicons name="chevron-forward" size={20} color={Colors.gray[400]} />
</View>
</View>
</Card>
))}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: Colors.gray[50] },
content: { padding: Spacing.lg, gap: Spacing.md },
intro: { fontSize: FontSize.sm, color: Colors.gray[500], marginBottom: Spacing.sm, lineHeight: 20 },
list: { gap: Spacing.md },
card: { padding: Spacing.lg },
row: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md },
iconCircle: {
width: 48,
height: 48,
borderRadius: Radius.md,
backgroundColor: '#E3E3FD',
alignItems: 'center',
justifyContent: 'center',
},
cardBody: { flex: 1 },
cardTitle: { fontSize: FontSize.md, fontWeight: FontWeight.bold, color: Colors.black },
cardSubtitle: { fontSize: FontSize.xs, color: Colors.gray[500], marginTop: 2, lineHeight: 18 },
cardEnd: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm },
count: {
fontSize: FontSize.sm,
fontWeight: FontWeight.bold,
color: Colors.primary,
minWidth: 22,
textAlign: 'right',
},
});
+268
View File
@@ -0,0 +1,268 @@
import { useState } from 'react';
import {
Alert,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Badge, Button, Card, EmptyState, Loader, TextField } from '@/components/ui';
import {
useAdminUtilisateurs,
useCreateUtilisateur,
useToggleUtilisateurActif,
useDeleteUtilisateur,
} from '@/hooks/useAdmin';
import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, HIT_SLOP, Radius, Spacing } from '@/lib/theme';
import type { Utilisateur } from '@/types';
const ROLE_MEMBRE = 2;
const ROLE_ADMIN = 3;
interface FormState {
prenom: string;
nom: string;
email: string;
password: string;
password_confirmation: string;
role_id: number;
}
const FORM_VIDE: FormState = {
prenom: '',
nom: '',
email: '',
password: '',
password_confirmation: '',
role_id: ROLE_MEMBRE,
};
/** Extrait le message d'erreur d'une réponse API (422 backend) ou un texte par défaut. */
function messageErreur(e: unknown, defaut: string): string {
const err = e as { response?: { data?: { message?: string } } };
return err?.response?.data?.message ?? defaut;
}
export default function AdminUtilisateursScreen() {
const { data: utilisateurs, isLoading } = useAdminUtilisateurs();
const createMut = useCreateUtilisateur();
const toggleMut = useToggleUtilisateurActif();
const deleteMut = useDeleteUtilisateur();
const [modalVisible, setModalVisible] = useState(false);
const [form, setForm] = useState<FormState>(FORM_VIDE);
const ouvrirFormulaire = () => {
setForm(FORM_VIDE);
setModalVisible(true);
};
const set = (cle: keyof FormState) => (valeur: string) => setForm((f) => ({ ...f, [cle]: valeur }));
const soumettre = async () => {
if (!form.prenom.trim() || !form.nom.trim() || !form.email.trim() || !form.password) {
Alert.alert('Champs requis', 'Merci de renseigner tous les champs.');
return;
}
if (form.password !== form.password_confirmation) {
Alert.alert('Mots de passe', 'La confirmation ne correspond pas.');
return;
}
try {
await createMut.mutateAsync(form);
setModalVisible(false);
} catch (e) {
Alert.alert('Erreur', messageErreur(e, 'Impossible de créer l’utilisateur.'));
}
};
const basculerActif = async (u: Utilisateur) => {
try {
await toggleMut.mutateAsync(u.id);
} catch (e) {
Alert.alert('Erreur', messageErreur(e, 'Action impossible.'));
}
};
const supprimer = (u: Utilisateur) => {
Alert.alert('Supprimer', `Supprimer ${u.prenom} ${u.nom} ?`, [
{ text: 'Annuler', style: 'cancel' },
{
text: 'Supprimer',
style: 'destructive',
onPress: async () => {
try {
await deleteMut.mutateAsync(u.id);
} catch (e) {
Alert.alert('Erreur', messageErreur(e, 'Suppression impossible.'));
}
},
},
]);
};
const renderItem = ({ item }: { item: Utilisateur }) => (
<Card style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.cardHeaderText}>
<Text style={styles.nom}>
{item.prenom} {item.nom}
</Text>
<Text style={styles.email}>{item.email}</Text>
</View>
</View>
<View style={styles.badges}>
<Badge label={item.role?.nom ?? 'membre'} tone="primary" />
<Badge label={item.est_actif ? 'Actif' : 'Inactif'} tone={item.est_actif ? 'success' : 'error'} />
</View>
<View style={styles.actions}>
<Button
title={item.est_actif ? 'Désactiver' : 'Activer'}
variant="secondary"
size="sm"
icon={item.est_actif ? 'pause-outline' : 'play-outline'}
onPress={() => basculerActif(item)}
/>
<Button title="Supprimer" variant="danger" size="sm" icon="trash-outline" onPress={() => supprimer(item)} />
</View>
</Card>
);
if (isLoading) {
return <Loader label="Chargement des utilisateurs…" />;
}
return (
<View style={styles.screen}>
<FlatList
data={utilisateurs ?? []}
keyExtractor={(item) => String(item.id)}
renderItem={renderItem}
contentContainerStyle={styles.list}
ListHeaderComponent={
<View style={styles.headerAction}>
<Button title="Nouvel utilisateur" icon="person-add-outline" fullWidth onPress={ouvrirFormulaire} />
</View>
}
ListEmptyComponent={
<EmptyState
icon="people-outline"
title="Aucun utilisateur"
message="Créez le premier compte avec le bouton ci-dessus."
/>
}
/>
<Modal visible={modalVisible} animationType="slide" transparent onRequestClose={() => setModalVisible(false)}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.modalOverlay}
>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Nouvel utilisateur</Text>
<Pressable hitSlop={HIT_SLOP} onPress={() => setModalVisible(false)} accessibilityLabel="Fermer">
<Ionicons name="close" size={24} color={Colors.gray[600]} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody} keyboardShouldPersistTaps="handled">
<TextField label="Prénom" value={form.prenom} onChangeText={set('prenom')} required autoCapitalize="words" />
<TextField label="Nom" value={form.nom} onChangeText={set('nom')} required autoCapitalize="words" />
<TextField
label="Email"
value={form.email}
onChangeText={set('email')}
required
keyboardType="email-address"
/>
<TextField label="Mot de passe" value={form.password} onChangeText={set('password')} required secureTextEntry />
<TextField
label="Confirmation"
value={form.password_confirmation}
onChangeText={set('password_confirmation')}
required
secureTextEntry
/>
<Text style={styles.fieldLabel}>Rôle</Text>
<View style={styles.segment}>
<Pressable
style={[styles.segmentItem, form.role_id === ROLE_MEMBRE && styles.segmentItemActive]}
onPress={() => setForm((f) => ({ ...f, role_id: ROLE_MEMBRE }))}
>
<Text style={[styles.segmentText, form.role_id === ROLE_MEMBRE && styles.segmentTextActive]}>
Membre
</Text>
</Pressable>
<Pressable
style={[styles.segmentItem, form.role_id === ROLE_ADMIN && styles.segmentItemActive]}
onPress={() => setForm((f) => ({ ...f, role_id: ROLE_ADMIN }))}
>
<Text style={[styles.segmentText, form.role_id === ROLE_ADMIN && styles.segmentTextActive]}>
Administrateur
</Text>
</Pressable>
</View>
<View style={styles.modalActions}>
<Button title="Annuler" variant="tertiary" onPress={() => setModalVisible(false)} />
<Button title="Créer" icon="checkmark" loading={createMut.isPending} onPress={soumettre} />
</View>
</ScrollView>
</View>
</KeyboardAvoidingView>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: Colors.gray[50] },
list: { padding: Spacing.lg, gap: Spacing.md },
headerAction: { marginBottom: Spacing.md },
card: { padding: Spacing.lg, gap: Spacing.sm },
cardHeader: { flexDirection: 'row', alignItems: 'flex-start' },
cardHeaderText: { flex: 1 },
nom: { fontSize: FontSize.md, fontWeight: FontWeight.bold, color: Colors.black },
email: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: 2 },
badges: { flexDirection: 'row', gap: Spacing.sm, flexWrap: 'wrap' },
actions: { flexDirection: 'row', gap: Spacing.sm, marginTop: Spacing.xs },
modalOverlay: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(22,22,22,0.4)' },
modalSheet: {
backgroundColor: Colors.white,
borderTopLeftRadius: Radius.md,
borderTopRightRadius: Radius.md,
maxHeight: '90%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: Spacing.lg,
borderBottomWidth: 1,
borderBottomColor: Colors.gray[200],
},
modalTitle: { fontSize: FontSize.lg, fontWeight: FontWeight.bold, color: Colors.black },
modalBody: { padding: Spacing.lg },
fieldLabel: { fontSize: FontSize.sm, fontWeight: FontWeight.medium, color: Colors.black, marginBottom: Spacing.xs },
segment: {
flexDirection: 'row',
borderWidth: 1,
borderColor: Colors.primary,
borderRadius: Radius.sm,
overflow: 'hidden',
marginBottom: Spacing.lg,
},
segmentItem: { flex: 1, paddingVertical: Spacing.md, alignItems: 'center', backgroundColor: Colors.white },
segmentItemActive: { backgroundColor: Colors.primary },
segmentText: { fontSize: FontSize.sm, fontWeight: FontWeight.semibold, color: Colors.primary },
segmentTextActive: { color: Colors.white },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: Spacing.sm, marginTop: Spacing.sm },
});
+60 -51
View File
@@ -1,8 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; import { View, Text, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
import { Link, useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
import { AppBar, Button, Card, TextField } from '@/components/ui';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -28,20 +30,27 @@ export default function ForgotPasswordScreen() {
if (submitted) { if (submitted) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.success}> <AppBar title="Demande enregistrée" />
<View style={styles.successIcon}> <View style={styles.successWrap}>
<View style={styles.iconCircle}>
<Ionicons name="mail-open" size={32} color={Colors.primary} /> <Ionicons name="mail-open" size={32} color={Colors.primary} />
</View> </View>
<Text style={styles.successTitle}>Demande enregistrée</Text> <Text style={styles.successTitle}>Vérifiez votre boîte mail</Text>
<Text style={styles.successText}> <Text style={styles.successText}>
Si l'adresse <Text style={styles.bold}>{email}</Text> est associée à un compte CESIZen, un email contenant les instructions de réinitialisation sera envoyé. Si l'adresse <Text style={styles.bold}>{email}</Text> est associée à un compte CESIZen,
un email contenant les instructions de réinitialisation sera envoyé.
</Text> </Text>
<Text style={styles.successText}> <Text style={styles.successText}>
Si vous ne recevez rien sous 24h, contactez votre administrateur. Si vous ne recevez rien sous 24h, contactez votre administrateur.
</Text> </Text>
<TouchableOpacity style={styles.button} onPress={() => router.replace('/auth/login')}> <View style={styles.successAction}>
<Text style={styles.buttonText}>Retour à la connexion</Text> <Button
</TouchableOpacity> title="Retour à la connexion"
icon="arrow-back-outline"
onPress={() => router.replace('/auth/login')}
fullWidth
/>
</View>
</View> </View>
</View> </View>
); );
@@ -49,64 +58,64 @@ export default function ForgotPasswordScreen() {
return ( return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}> <KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<AppBar title="Mot de passe oublié" subtitle="Réinitialisez votre accès" />
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled"> <ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
<View style={styles.header}> <Text style={styles.intro}>
<Text style={styles.logo}>Mot de passe oublié ?</Text> Entrez votre adresse email. Si elle est associée à un compte, vous recevrez les
<Text style={styles.subtitle}> instructions pour réinitialiser votre mot de passe.
Entrez votre adresse email. Si elle est associée à un compte, vous recevrez les instructions pour réinitialiser votre mot de passe.
</Text> </Text>
</View>
<View style={styles.form}> <Card padding={Spacing.xl} elevated>
<View style={styles.inputGroup}> <TextField
<Text style={styles.label}>Email</Text> label="Email"
<TextInput
style={styles.input}
value={email} value={email}
onChangeText={(v) => { setEmail(v); setError(null); }} onChangeText={(v) => { setEmail(v); setError(null); }}
placeholder="votre@email.com" placeholder="votre@email.com"
keyboardType="email-address" keyboardType="email-address"
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoComplete="email"
error={error ?? undefined}
required
/> />
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
<TouchableOpacity style={styles.button} onPress={handleSubmit}> <Button
<Text style={styles.buttonText}>Envoyer les instructions</Text> title="Envoyer les instructions"
</TouchableOpacity> onPress={handleSubmit}
icon="paper-plane-outline"
fullWidth
/>
<View style={styles.footer}> <View style={styles.backRow}>
<Link href="/auth/login" asChild> <Button
<TouchableOpacity> title="Retour à la connexion"
<Text style={styles.link}>Retour à la connexion</Text> variant="tertiary"
</TouchableOpacity> size="sm"
</Link> onPress={() => router.replace('/auth/login')}
</View> />
</View> </View>
</Card>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white }, container: { flex: 1, backgroundColor: Colors.gray[50] },
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: 24 }, scrollContent: { flexGrow: 1, justifyContent: 'center', padding: Spacing.xl },
header: { alignItems: 'center', marginBottom: 32 }, intro: { fontSize: FontSize.sm, color: Colors.gray[600], lineHeight: 20, marginBottom: Spacing.xl, textAlign: 'center' },
logo: { fontSize: 26, fontWeight: '800', color: Colors.black, textAlign: 'center' }, backRow: { alignItems: 'center', marginTop: Spacing.sm },
subtitle: { fontSize: 14, color: Colors.gray[500], marginTop: 10, textAlign: 'center', lineHeight: 20 }, successWrap: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: Spacing.xl },
form: { backgroundColor: Colors.gray[50], borderRadius: 4, padding: 24, borderWidth: 1, borderColor: Colors.gray[200] }, iconCircle: {
inputGroup: { marginBottom: 16 }, width: 72,
label: { fontSize: 14, fontWeight: '600', color: Colors.black, marginBottom: 6 }, height: 72,
input: { backgroundColor: Colors.gray[100], borderTopLeftRadius: 4, borderTopRightRadius: 4, borderBottomWidth: 2, borderBottomColor: Colors.black, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black }, borderRadius: Radius.pill,
errorText: { color: Colors.error, marginTop: 6, fontSize: 12 }, backgroundColor: '#E3E3FD',
button: { backgroundColor: Colors.primary, borderRadius: 4, paddingVertical: 16, alignItems: 'center', marginTop: 8 }, alignItems: 'center',
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.white }, justifyContent: 'center',
footer: { alignItems: 'center', marginTop: 20 }, marginBottom: Spacing.lg,
link: { color: Colors.primary, fontSize: 14, fontWeight: '600', textDecorationLine: 'underline' }, },
success: { padding: 24, alignItems: 'center', flex: 1, justifyContent: 'center' }, successTitle: { fontSize: FontSize.xl, fontWeight: FontWeight.heavy, color: Colors.black, marginBottom: Spacing.md, textAlign: 'center' },
successIcon: { width: 72, height: 72, borderRadius: 999, backgroundColor: `${Colors.primary}15`, justifyContent: 'center', alignItems: 'center', marginBottom: 20 }, successText: { fontSize: FontSize.sm, color: Colors.gray[600], textAlign: 'center', lineHeight: 20, marginBottom: Spacing.md },
successTitle: { fontSize: 22, fontWeight: '800', color: Colors.black, marginBottom: 12 }, bold: { fontWeight: FontWeight.bold, color: Colors.black },
successText: { fontSize: 14, color: Colors.gray[600], textAlign: 'center', lineHeight: 20, marginBottom: 12 }, successAction: { alignSelf: 'stretch', marginTop: Spacing.lg },
bold: { fontWeight: '700', color: Colors.black },
}); });
+76 -76
View File
@@ -1,10 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; import { View, Text, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
import { Link, useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { getApiError } from '@/lib/api'; import { getApiError } from '@/lib/api';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import RepubliqueHeader from '@/components/RepubliqueHeader'; import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
import { AppBar, Button, Card, TextField } from '@/components/ui';
export default function LoginScreen() { export default function LoginScreen() {
const router = useRouter(); const router = useRouter();
@@ -12,18 +14,22 @@ export default function LoginScreen() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async () => { const handleLogin = async () => {
setError(null);
if (!email || !password) { if (!email || !password) {
Alert.alert('Erreur', 'Veuillez remplir tous les champs'); setError('Veuillez remplir tous les champs.');
return; return;
} }
setLoading(true); setLoading(true);
try { try {
await login(email, password); await login(email, password);
router.replace('/(tabs)'); router.replace('/(tabs)');
} catch (error) { } catch (err) {
Alert.alert('Erreur', getApiError(error).message || 'Identifiants incorrects'); const message = getApiError(err).message || 'Identifiants incorrects';
setError(message);
Alert.alert('Erreur', message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -31,62 +37,69 @@ export default function LoginScreen() {
return ( return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}> <KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<RepubliqueHeader /> <AppBar title="Connexion" subtitle="Accédez à votre espace personnel" />
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled"> <ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
<View style={styles.header}> <View style={styles.intro}>
<Text style={styles.logo}>CESIZen</Text> <Text style={styles.welcome}>Bienvenue sur CESIZen</Text>
<Text style={styles.subtitle}>Votre compagnon de santé mentale</Text> <Text style={styles.welcomeSub}>Votre compagnon de santé mentale.</Text>
</View> </View>
<View style={styles.form}> <Card padding={Spacing.xl} elevated>
<Text style={styles.title}>Connexion</Text> {error ? (
<View style={styles.errorBanner}>
<Ionicons name="alert-circle" size={18} color={Colors.error} />
<Text style={styles.errorBannerText}>{error}</Text>
</View>
) : null}
<View style={styles.inputGroup}> <TextField
<Text style={styles.label}>Email</Text> label="Email"
<TextInput
style={styles.input}
value={email} value={email}
onChangeText={setEmail} onChangeText={(v) => { setEmail(v); setError(null); }}
placeholder="votre@email.com" placeholder="votre@email.com"
keyboardType="email-address" keyboardType="email-address"
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoComplete="email"
required
/> />
</View>
<View style={styles.inputGroup}> <TextField
<Text style={styles.label}>Mot de passe</Text> label="Mot de passe"
<TextInput
style={styles.input}
value={password} value={password}
onChangeText={setPassword} onChangeText={(v) => { setPassword(v); setError(null); }}
placeholder="Votre mot de passe" placeholder="Votre mot de passe"
secureTextEntry secureTextEntry
autoComplete="password"
required
/>
<Button
title="Se connecter"
onPress={handleLogin}
loading={loading}
icon="log-in-outline"
fullWidth
/>
<View style={styles.forgotRow}>
<Button
title="Mot de passe oublié ?"
variant="tertiary"
size="sm"
onPress={() => router.push('/auth/forgot-password')}
/> />
</View> </View>
</Card>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
<Text style={styles.buttonText}>{loading ? 'Connexion...' : 'Se connecter'}</Text>
</TouchableOpacity>
<Link href="/auth/forgot-password" asChild>
<TouchableOpacity style={styles.forgotLink}>
<Text style={styles.link}>Mot de passe oublié ?</Text>
</TouchableOpacity>
</Link>
<View style={styles.footer}> <View style={styles.footer}>
<Text style={styles.footerText}>Pas encore de compte ? </Text> <Text style={styles.footerText}>Pas encore de compte ?</Text>
<Link href="/auth/register" asChild> <Button
<TouchableOpacity> title="Créer un compte"
<Text style={styles.link}>S'inscrire</Text> variant="secondary"
</TouchableOpacity> onPress={() => router.push('/auth/register')}
</Link> icon="person-add-outline"
</View> fullWidth
/>
</View> </View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
@@ -94,37 +107,24 @@ export default function LoginScreen() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white }, container: { flex: 1, backgroundColor: Colors.gray[50] },
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: 24 }, scrollContent: { flexGrow: 1, justifyContent: 'center', padding: Spacing.xl },
header: { alignItems: 'center', marginBottom: 40 }, intro: { alignItems: 'center', marginBottom: Spacing.xl },
logo: { fontSize: 36, fontWeight: '800', color: Colors.primary }, welcome: { fontSize: FontSize.xxl, fontWeight: FontWeight.heavy, color: Colors.black, textAlign: 'center' },
subtitle: { fontSize: 16, color: Colors.gray[500], marginTop: 8 }, welcomeSub: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: Spacing.xs, textAlign: 'center' },
form: { backgroundColor: Colors.gray[50], borderRadius: 4, padding: 24, borderWidth: 1, borderColor: Colors.gray[200] }, errorBanner: {
title: { fontSize: 24, fontWeight: '700', color: Colors.black, marginBottom: 24 }, flexDirection: 'row',
inputGroup: { marginBottom: 16 },
label: { fontSize: 14, fontWeight: '600', color: Colors.black, marginBottom: 6 },
input: {
backgroundColor: Colors.gray[100],
borderTopLeftRadius: 4,
borderTopRightRadius: 4,
borderBottomWidth: 2,
borderBottomColor: Colors.black,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 16,
color: Colors.black,
},
button: {
backgroundColor: Colors.primary,
borderRadius: 4,
paddingVertical: 16,
alignItems: 'center', alignItems: 'center',
marginTop: 8, gap: Spacing.sm,
backgroundColor: Colors.errorBg,
borderRadius: Radius.sm,
borderLeftWidth: 3,
borderLeftColor: Colors.error,
padding: Spacing.md,
marginBottom: Spacing.lg,
}, },
buttonDisabled: { opacity: 0.6 }, errorBannerText: { flex: 1, fontSize: FontSize.xs, color: Colors.error, fontWeight: FontWeight.medium },
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.white }, forgotRow: { alignItems: 'center', marginTop: Spacing.sm },
forgotLink: { alignItems: 'center', marginTop: 12 }, footer: { marginTop: Spacing.xl, alignItems: 'center' },
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 16 }, footerText: { fontSize: FontSize.sm, color: Colors.gray[500], marginBottom: Spacing.sm },
footerText: { color: Colors.gray[500], fontSize: 14 },
link: { color: Colors.primary, fontSize: 14, fontWeight: '600', textDecorationLine: 'underline' },
}); });
+116 -77
View File
@@ -1,11 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; import { View, Text, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView, Pressable } from 'react-native';
import { Link, useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { getApiError } from '@/lib/api'; import { getApiError } from '@/lib/api';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import RepubliqueHeader from '@/components/RepubliqueHeader'; import { FontSize, FontWeight, MIN_TOUCH, Radius, Spacing } from '@/lib/theme';
import { AppBar, Button, Card, TextField } from '@/components/ui';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -60,70 +61,112 @@ export default function RegisterScreen() {
} }
}; };
const consentError = errors.consentement || errors.consentement_rgpd;
return ( return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}> <KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<RepubliqueHeader /> <AppBar title="Inscription" subtitle="Créez votre compte CESIZen" />
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled"> <ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
<View style={styles.header}> <Card padding={Spacing.xl} elevated>
<Text style={styles.logo}>CESIZen</Text> <View style={styles.nameRow}>
<Text style={styles.subtitle}>Créez votre compte</Text> <TextField
label="Nom"
value={nom}
onChangeText={setNom}
placeholder="Dupont"
autoCapitalize="words"
error={errors.nom}
required
style={styles.nameField}
/>
<TextField
label="Prénom"
value={prenom}
onChangeText={setPrenom}
placeholder="Jean"
autoCapitalize="words"
error={errors.prenom}
required
style={styles.nameField}
/>
</View> </View>
<View style={styles.form}> <TextField
<Text style={styles.title}>Inscription</Text> label="Email"
value={email}
onChangeText={setEmail}
placeholder="votre@email.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
error={errors.email}
required
/>
<View style={styles.row}> <TextField
<View style={[styles.inputGroup, { flex: 1, marginRight: 8 }]}> label="Mot de passe"
<Text style={styles.label}>Nom</Text> value={password}
<TextInput style={styles.input} value={nom} onChangeText={setNom} placeholder="Dupont" /> onChangeText={setPassword}
{errors.nom && <Text style={styles.errorText}>{errors.nom}</Text>} placeholder="Mot de passe"
</View> secureTextEntry
<View style={[styles.inputGroup, { flex: 1, marginLeft: 8 }]}> error={errors.password}
<Text style={styles.label}>Prénom</Text> helper="Au moins 8 caractères"
<TextInput style={styles.input} value={prenom} onChangeText={setPrenom} placeholder="Jean" /> required
{errors.prenom && <Text style={styles.errorText}>{errors.prenom}</Text>} />
</View>
</View>
<View style={styles.inputGroup}> <TextField
<Text style={styles.label}>Email</Text> label="Confirmer le mot de passe"
<TextInput style={styles.input} value={email} onChangeText={setEmail} placeholder="votre@email.com" keyboardType="email-address" autoCapitalize="none" autoCorrect={false} /> value={passwordConfirmation}
{errors.email && <Text style={styles.errorText}>{errors.email}</Text>} onChangeText={setPasswordConfirmation}
</View> placeholder="Confirmer"
secureTextEntry
error={errors.passwordConfirmation}
required
/>
<View style={styles.inputGroup}> <Pressable
<Text style={styles.label}>Mot de passe (min. 8 caractères)</Text> style={styles.consentRow}
<TextInput style={styles.input} value={password} onChangeText={setPassword} placeholder="Mot de passe" secureTextEntry /> onPress={() => setConsentement(!consentement)}
{errors.password && <Text style={styles.errorText}>{errors.password}</Text>} accessibilityRole="checkbox"
</View> accessibilityState={{ checked: consentement }}
>
<View style={styles.inputGroup}> <Ionicons
<Text style={styles.label}>Confirmer le mot de passe</Text> name={consentement ? 'checkbox' : 'square-outline'}
<TextInput style={styles.input} value={passwordConfirmation} onChangeText={setPasswordConfirmation} placeholder="Confirmer" secureTextEntry /> size={24}
{errors.passwordConfirmation && <Text style={styles.errorText}>{errors.passwordConfirmation}</Text>} color={consentement ? Colors.primary : Colors.gray[500]}
</View> style={styles.consentIcon}
/>
<TouchableOpacity style={styles.consentRow} onPress={() => setConsentement(!consentement)} activeOpacity={0.7}>
<View style={[styles.checkbox, consentement && styles.checkboxChecked]}>
{consentement && <Ionicons name="checkmark" size={16} color={Colors.white} />}
</View>
<Text style={styles.consentText}> <Text style={styles.consentText}>
J'accepte le traitement de mes données de bien-être conformément à la <Text style={styles.consentLink}>politique de confidentialité</Text> (RGPD). J'accepte le traitement de mes données de bien-être conformément à la{' '}
<Text style={styles.consentLink}>politique de confidentialité</Text> (RGPD).
</Text> </Text>
</TouchableOpacity> </Pressable>
{errors.consentement && <Text style={styles.errorText}>{errors.consentement}</Text>} {consentError ? (
{errors.consentement_rgpd && <Text style={styles.errorText}>{errors.consentement_rgpd}</Text>} <View style={styles.consentErrorRow}>
<Ionicons name="alert-circle" size={14} color={Colors.error} />
<Text style={styles.consentErrorText}>{consentError}</Text>
</View>
) : null}
<TouchableOpacity style={[styles.button, loading && styles.buttonDisabled]} onPress={handleRegister} disabled={loading}> <View style={styles.submit}>
<Text style={styles.buttonText}>{loading ? 'Inscription...' : 'S\'inscrire'}</Text> <Button
</TouchableOpacity> title="Créer mon compte"
onPress={handleRegister}
loading={loading}
icon="person-add-outline"
fullWidth
/>
</View>
</Card>
<View style={styles.footer}> <View style={styles.footer}>
<Text style={styles.footerText}>Déjà un compte ? </Text> <Text style={styles.footerText}>Déjà un compte ?</Text>
<Link href="/auth/login" asChild> <Button
<TouchableOpacity><Text style={styles.link}>Se connecter</Text></TouchableOpacity> title="Se connecter"
</Link> variant="tertiary"
</View> size="sm"
onPress={() => router.push('/auth/login')}
/>
</View> </View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
@@ -131,27 +174,23 @@ export default function RegisterScreen() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white }, container: { flex: 1, backgroundColor: Colors.gray[50] },
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: 24 }, scrollContent: { flexGrow: 1, justifyContent: 'center', padding: Spacing.xl },
header: { alignItems: 'center', marginBottom: 32 }, nameRow: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.md },
logo: { fontSize: 36, fontWeight: '800', color: Colors.primary }, nameField: { flexGrow: 1, flexBasis: 130 },
subtitle: { fontSize: 16, color: Colors.gray[500], marginTop: 8 }, consentRow: {
form: { backgroundColor: Colors.gray[50], borderRadius: 4, padding: 24, borderWidth: 1, borderColor: Colors.gray[200] }, flexDirection: 'row',
title: { fontSize: 24, fontWeight: '700', color: Colors.black, marginBottom: 24 }, alignItems: 'flex-start',
row: { flexDirection: 'row' }, gap: Spacing.sm,
inputGroup: { marginBottom: 16 }, paddingVertical: Spacing.sm,
label: { fontSize: 14, fontWeight: '600', color: Colors.black, marginBottom: 6 }, minHeight: MIN_TOUCH,
input: { backgroundColor: Colors.gray[100], borderTopLeftRadius: 4, borderTopRightRadius: 4, borderBottomWidth: 2, borderBottomColor: Colors.black, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black }, },
errorText: { color: Colors.error, marginTop: 4, fontSize: 12 }, consentIcon: { marginTop: 1 },
consentRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 10, marginVertical: 12 }, consentText: { flex: 1, fontSize: FontSize.xs, color: Colors.black, lineHeight: 19 },
checkbox: { width: 22, height: 22, borderRadius: 4, borderWidth: 2, borderColor: Colors.black, backgroundColor: Colors.white, justifyContent: 'center', alignItems: 'center', marginTop: 1 }, consentLink: { color: Colors.primary, fontWeight: FontWeight.semibold, textDecorationLine: 'underline' },
checkboxChecked: { backgroundColor: Colors.primary, borderColor: Colors.primary }, consentErrorRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.xs, marginTop: Spacing.xs },
consentText: { flex: 1, fontSize: 13, color: Colors.black, lineHeight: 18 }, consentErrorText: { fontSize: FontSize.xs, color: Colors.error },
consentLink: { color: Colors.primary, fontWeight: '600', textDecorationLine: 'underline' }, submit: { marginTop: Spacing.lg },
button: { backgroundColor: Colors.primary, borderRadius: 4, paddingVertical: 16, alignItems: 'center', marginTop: 12 }, footer: { marginTop: Spacing.xl, alignItems: 'center' },
buttonDisabled: { opacity: 0.6 }, footerText: { fontSize: FontSize.sm, color: Colors.gray[500] },
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.white },
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 },
footerText: { color: Colors.gray[500], fontSize: 14 },
link: { color: Colors.primary, fontSize: 14, fontWeight: '600', textDecorationLine: 'underline' },
}); });
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { useRouter } from 'expo-router';
import { useAuthStore } from '@/lib/auth-store';
/**
* Garde d'affichage pour les écrans d'administration.
* Si l'utilisateur connecté n'est pas administrateur, on le renvoie vers
* l'espace principal. La sécurité réelle reste assurée côté serveur (middleware
* role:administrateur) ; ce composant évite simplement d'afficher l'UI admin.
*/
export default function AdminGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const user = useAuthStore((state) => state.user);
const estAdmin = user?.role?.nom === 'administrateur';
useEffect(() => {
if (!estAdmin) {
router.replace('/(tabs)');
}
}, [estAdmin, router]);
if (!estAdmin) {
return null;
}
return <>{children}</>;
}
+64 -46
View File
@@ -1,7 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useEmotions } from '@/hooks/useEmotions'; import { useEmotions } from '@/hooks/useEmotions';
import { Colors } from '@/lib/colors'; import { Colors } from '@/lib/colors';
import { Spacing, Radius, FontSize, FontWeight, MIN_TOUCH, HIT_SLOP } from '@/lib/theme';
import { Loader } from '@/components/ui';
import type { Emotion } from '@/types'; import type { Emotion } from '@/types';
interface EmotionPickerProps { interface EmotionPickerProps {
@@ -14,40 +17,29 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
const [selectedParent, setSelectedParent] = useState<Emotion | null>(null); const [selectedParent, setSelectedParent] = useState<Emotion | null>(null);
if (isLoading) { if (isLoading) {
return ( return <Loader label="Chargement des émotions…" />;
<View style={styles.loading}>
<ActivityIndicator size="large" color={Colors.primary} />
</View>
);
} }
if (selectedParent?.enfants && selectedParent.enfants.length > 0) { if (selectedParent?.enfants && selectedParent.enfants.length > 0) {
return ( return (
<View> <View>
<TouchableOpacity style={styles.backBtn} onPress={() => setSelectedParent(null)}> <TouchableOpacity style={styles.backBtn} hitSlop={HIT_SLOP} onPress={() => setSelectedParent(null)}>
<Text style={styles.backText}>← Retour aux émotions</Text> <Ionicons name="arrow-back" size={16} color={Colors.primary} />
<Text style={styles.backText}>Retour aux émotions</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.subTitle}> <Text style={styles.subTitle}>
Précisez votre émotion de{' '} Précisez votre émotion de{' '}
<Text style={{ color: selectedParent.couleur, fontWeight: '700' }}>{selectedParent.nom}</Text> : <Text style={[styles.subTitleAccent, { color: selectedParent.couleur }]}>{selectedParent.nom}</Text> :
</Text> </Text>
<View style={styles.grid}> <View style={styles.grid}>
{selectedParent.enfants.map((child) => { {selectedParent.enfants.map((child) => (
const isSelected = value === child.id; <EmotionTile
return (
<TouchableOpacity
key={child.id} key={child.id}
style={[ emotion={child}
styles.emotionCard, selected={value === child.id}
isSelected && { borderColor: child.couleur, borderWidth: 2.5 },
]}
onPress={() => onChange(child.id)} onPress={() => onChange(child.id)}
> />
<Text style={styles.emotionEmoji}>{child.icone || '🔵'}</Text> ))}
<Text style={styles.emotionName}>{child.nom}</Text>
</TouchableOpacity>
);
})}
</View> </View>
</View> </View>
); );
@@ -58,14 +50,12 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
{emotions?.map((emotion) => { {emotions?.map((emotion) => {
const hasChildren = emotion.enfants && emotion.enfants.length > 0; const hasChildren = emotion.enfants && emotion.enfants.length > 0;
const isSelected = value === emotion.id || emotion.enfants?.some((e) => e.id === value); const isSelected = value === emotion.id || emotion.enfants?.some((e) => e.id === value);
return ( return (
<TouchableOpacity <EmotionTile
key={emotion.id} key={emotion.id}
style={[ emotion={emotion}
styles.emotionCard, selected={!!isSelected}
isSelected && { borderColor: emotion.couleur, borderWidth: 2.5 }, hasChildren={hasChildren}
]}
onPress={() => { onPress={() => {
if (hasChildren) { if (hasChildren) {
setSelectedParent(emotion); setSelectedParent(emotion);
@@ -73,34 +63,62 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
onChange(emotion.id); onChange(emotion.id);
} }
}} }}
> />
<Text style={styles.emotionEmoji}>{emotion.icone || '🔵'}</Text>
<Text style={styles.emotionName}>{emotion.nom}</Text>
{hasChildren && <Text style={styles.arrow}>▼</Text>}
</TouchableOpacity>
); );
})} })}
</View> </View>
); );
} }
function EmotionTile({
emotion,
selected,
hasChildren,
onPress,
}: {
emotion: Emotion;
selected: boolean;
hasChildren?: boolean;
onPress: () => void;
}) {
return (
<TouchableOpacity
style={[
styles.tile,
selected && { borderColor: emotion.couleur, borderWidth: 2.5, backgroundColor: `${emotion.couleur}12` },
]}
onPress={onPress}
accessibilityLabel={emotion.nom}
>
<Text style={styles.tileEmoji}>{emotion.icone || '🔵'}</Text>
<Text style={styles.tileName} numberOfLines={2}>
{emotion.nom}
</Text>
{hasChildren && <Ionicons name="chevron-down" size={12} color={Colors.gray[400]} style={styles.chevron} />}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
loading: { padding: 40, alignItems: 'center' }, backBtn: { flexDirection: 'row', alignItems: 'center', gap: Spacing.xs, marginBottom: Spacing.md, minHeight: MIN_TOUCH },
backBtn: { marginBottom: 12 }, backText: { fontSize: FontSize.sm, fontWeight: FontWeight.semibold, color: Colors.primary },
backText: { fontSize: 14, color: Colors.primary, textDecorationLine: 'underline' }, subTitle: { fontSize: FontSize.sm, color: Colors.gray[600], marginBottom: Spacing.md },
subTitle: { fontSize: 14, color: Colors.gray[600], marginBottom: 12 }, subTitleAccent: { fontWeight: FontWeight.bold },
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, grid: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.md, justifyContent: 'space-between' },
emotionCard: { tile: {
width: '30%', width: '30%',
minHeight: 92,
backgroundColor: Colors.white, backgroundColor: Colors.white,
borderRadius: 4, borderRadius: Radius.sm,
padding: 14, paddingVertical: Spacing.md,
paddingHorizontal: Spacing.sm,
alignItems: 'center', alignItems: 'center',
gap: 6, justifyContent: 'center',
gap: Spacing.xs,
borderWidth: 1.5, borderWidth: 1.5,
borderColor: Colors.gray[200], borderColor: Colors.gray[200],
}, },
emotionEmoji: { fontSize: 30 }, tileEmoji: { fontSize: 30 },
emotionName: { fontSize: 12, fontWeight: '600', color: Colors.black, textAlign: 'center' }, tileName: { fontSize: FontSize.xs, fontWeight: FontWeight.semibold, color: Colors.black, textAlign: 'center' },
arrow: { fontSize: 10, color: Colors.gray[400] }, chevron: { marginTop: 2 },
}); });