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} />,
}} }}
/> />
+126 -92
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> {/* Actions rapides */}
<Text style={styles.subtitle}>Comment allez-vous aujourd'hui ?</Text> <View style={styles.actionsRow}>
<Card onPress={() => router.push('/(tabs)/journal/new')} padding={Spacing.lg} style={styles.actionPrimary}>
{/* Actions rapides */} <View style={[styles.actionIcon, styles.actionIconPrimary]}>
<View style={styles.actionsRow}> <Ionicons name="add-circle" size={28} color={Colors.white} />
<TouchableOpacity style={styles.actionCard} onPress={() => router.push('/(tabs)/journal/new')}>
<View style={[styles.actionIcon, { backgroundColor: `${Colors.primary}30` }]}>
<Ionicons name="add-circle" size={28} color={Colors.primary} />
</View>
<Text style={styles.actionText}>Nouvelle saisie</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionCard} onPress={() => router.push('/(tabs)/journal/reports')}>
<View style={[styles.actionIcon, { backgroundColor: `${Colors.secondary}30` }]}>
<Ionicons name="bar-chart" size={28} color={Colors.secondary} />
</View>
<Text style={styles.actionText}>Mes rapports</Text>
</TouchableOpacity>
</View>
{/* Stats du mois */}
{rapport && (
<View style={styles.statsContainer}>
<Text style={styles.sectionTitle}>Ce mois-ci</Text>
<View style={styles.statsRow}>
<View style={styles.statCard}>
<Text style={styles.statValue}>{rapport.stats.total_saisies}</Text>
<Text style={styles.statLabel}>Saisies</Text>
</View> </View>
<View style={styles.statCard}> <Text style={styles.actionTextPrimary}>Nouvelle saisie</Text>
<Text style={styles.statValue}>{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}</Text> </Card>
<Text style={styles.statLabel}>Intensité moy.</Text> <Card onPress={() => router.push('/(tabs)/journal/reports')} padding={Spacing.lg} style={styles.actionCard}>
<View style={[styles.actionIcon, styles.actionIconSecondary]}>
<Ionicons name="bar-chart" size={28} color={Colors.primary} />
</View> </View>
<View style={styles.statCard}> <Text style={styles.actionText}>Mes rapports</Text>
<Text style={styles.statValue}>{rapport.stats.emotion_dominante?.icone || '—'}</Text> </Card>
<Text style={styles.statLabel}>{rapport.stats.emotion_dominante?.nom || 'Aucune'}</Text>
</View>
</View>
</View> </View>
)}
{/* Dernieres saisies */} {/* Stats du mois */}
{recentSaisies.length > 0 && ( {rapport && (
<View style={styles.recentContainer}> <View style={styles.block}>
<SectionTitle>Ce mois-ci</SectionTitle>
<Card padding={Spacing.lg}>
<View style={styles.statsRow}>
<View style={styles.statCell}>
<Text style={styles.statValue}>{rapport.stats.total_saisies}</Text>
<Text style={styles.statLabel}>Saisies</Text>
</View>
<View style={styles.statDivider} />
<View style={styles.statCell}>
<Text style={styles.statValue}>
{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}
</Text>
<Text style={styles.statLabel}>Intensité moy.</Text>
</View>
<View style={styles.statDivider} />
<View style={styles.statCell}>
<Text style={styles.statValue}>{rapport.stats.emotion_dominante?.icone || '—'}</Text>
<Text style={styles.statLabel} numberOfLines={1}>
{rapport.stats.emotion_dominante?.nom || 'Aucune'}
</Text>
</View>
</View>
</Card>
</View>
)}
{/* Dernières saisies */}
<View style={styles.block}>
<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 ? (
<View style={[styles.emotionIcon, { backgroundColor: `${saisie.emotion?.couleur}20` }]}> <Loader label="Chargement…" />
<Text style={styles.emotionEmoji}>{saisie.emotion?.icone || '🔵'}</Text> ) : recentSaisies.length === 0 ? (
</View> <EmptyState
<View style={styles.saisieContent}> icon="sparkles-outline"
<Text style={styles.saisieEmotion}>{saisie.emotion?.nom}</Text> title="Aucune saisie pour l'instant"
<Text style={styles.saisieDate}> message="Commencez par enregistrer votre première émotion du jour."
{new Date(saisie.date_saisie).toLocaleDateString('fr-FR')} — Intensité: {saisie.intensite}/10 actionLabel="Nouvelle saisie"
</Text> onAction={() => router.push('/(tabs)/journal/new')}
</View> />
</View> ) : (
))} 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` }]}>
<Text style={styles.emotionEmoji}>{saisie.emotion?.icone || '🔵'}</Text>
</View>
<View style={styles.saisieContent}>
{saisie.emotion?.couleur ? (
<Badge label={saisie.emotion?.nom || ''} color={saisie.emotion.couleur} />
) : (
<Badge label={saisie.emotion?.nom || '—'} tone="neutral" />
)}
<Text style={styles.saisieDate}>
{new Date(saisie.date_saisie).toLocaleDateString('fr-FR')} — Intensité {saisie.intensite}/10
</Text>
</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>
); );
} }
+63 -60
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>
<Text style={styles.cardExcerpt} numberOfLines={2}> {extrait ? (
{item.contenu?.replace(/<[^>]*>/g, '').substring(0, 120)}... <Text style={styles.cardExcerpt} numberOfLines={2}>
</Text> {extrait}
<Text style={styles.cardDate}> </Text>
{new Date(item.created_at).toLocaleDateString('fr-FR')} ) : null}
</Text> <Text style={styles.cardDate}>
</View> {new Date(item.created_at).toLocaleDateString('fr-FR', {
<Ionicons name="chevron-forward" size={20} color={Colors.gray[300]} /> day: 'numeric',
</TouchableOpacity> month: 'long',
); year: 'numeric',
})}
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={Colors.gray[400]} />
</View>
</Card>
);
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
<FlatList <AppBar title="Informations" subtitle="Ressources pour votre santé mentale" />
data={feeds || []} {isLoading ? (
keyExtractor={(item) => String(item.id)} <Loader label="Chargement des articles…" />
renderItem={renderItem} ) : (
contentContainerStyle={styles.listContent} <FlatList
ListHeaderComponent={ data={feeds ?? []}
<View style={styles.header}> keyExtractor={(item) => String(item.id)}
<Text style={styles.headerTitle}>Santé mentale</Text> renderItem={renderItem}
<Text style={styles.headerSubtitle}>Ressources et informations pour votre bien-être</Text> contentContainerStyle={styles.listContent}
</View> ListEmptyComponent={
} <EmptyState
ListEmptyComponent={ icon="newspaper-outline"
isLoading ? ( title="Aucun article disponible"
<View style={styles.loading}><Text style={styles.loadingText}>Chargement...</Text></View> message="Les ressources d'information apparaîtront ici dès leur publication."
) : ( />
<View style={styles.empty}> }
<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 },
}); });
+100 -56
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}>
<FlatList <AppBar title="Mon journal" subtitle="Vos émotions au fil du temps" />
data={saisies || []} {isLoading ? (
keyExtractor={(item) => String(item.id)} <Loader label="Chargement du journal…" />
renderItem={renderItem} ) : (
contentContainerStyle={styles.listContent} <FlatList
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={Colors.primary} />} data={saisies || []}
ListEmptyComponent={ keyExtractor={(item) => String(item.id)}
<View style={styles.empty}> renderItem={renderItem}
<Ionicons name="book-outline" size={48} color={Colors.gray[300]} /> contentContainerStyle={styles.listContent}
<Text style={styles.emptyTitle}>Aucune saisie</Text> refreshControl={
<Text style={styles.emptyText}>Commencez par enregistrer votre première émotion</Text> <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={Colors.primary} />
</View> }
} ListEmptyComponent={
/> <EmptyState
<TouchableOpacity style={styles.fab} onPress={() => router.push('/(tabs)/journal/new')}> icon="book-outline"
<Ionicons name="add" size={28} color={Colors.white} /> title="Aucune saisie"
message="Commencez par enregistrer votre première émotion."
actionLabel="Nouvelle saisie"
onAction={() => router.push('/(tabs)/journal/new')}
/>
}
/>
)}
<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,
}, },
}); });
+89 -95
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
onPress={() => emotionId && setStep(2)} title="Suivant"
disabled={!emotionId} icon="arrow-forward"
> onPress={() => emotionId && setStep(2)}
<Text style={styles.nextBtnText}>Suivant</Text> disabled={!emotionId}
<Ionicons name="arrow-forward" size={20} color={Colors.white} /> />
</TouchableOpacity> </View>
</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 },
}); });
+130 -119
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,130 +21,139 @@ export default function ReportsScreen() {
const { data: rapport, isLoading } = useRapport(period); const { data: rapport, isLoading } = useRapport(period);
return ( return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <View style={styles.screen}>
{/* Period selector */} <AppBar title="Mes rapports" subtitle="Analyse de vos émotions" />
<View style={styles.periodRow}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
{PERIODS.map((p) => ( {/* Sélecteur de période — boutons segmentés */}
<TouchableOpacity <View style={styles.segment}>
key={p.value} {PERIODS.map((p) => {
style={[styles.periodBtn, period === p.value && styles.periodBtnActive]} const active = period === p.value;
onPress={() => setPeriod(p.value)} return (
> <TouchableOpacity
<Text style={[styles.periodText, period === p.value && styles.periodTextActive]}> key={p.value}
{p.label} style={[styles.segmentBtn, active && styles.segmentBtnActive]}
</Text> onPress={() => setPeriod(p.value)}
</TouchableOpacity> accessibilityLabel={p.label}
))} >
</View> <Text style={[styles.segmentText, active && styles.segmentTextActive]}>{p.label}</Text>
</TouchableOpacity>
{isLoading ? ( );
<View style={styles.loading}> })}
<Text style={styles.loadingText}>Chargement...</Text>
</View> </View>
) : rapport ? (
<>
{/* Stats cards */}
<View style={styles.statsRow}>
<View style={styles.statCard}>
<Ionicons name="bar-chart" size={20} color={Colors.primary} />
<Text style={styles.statValue}>{rapport.stats.total_saisies}</Text>
<Text style={styles.statLabel}>Saisies</Text>
</View>
<View style={styles.statCard}>
<Ionicons name="trending-up" size={20} color={Colors.secondary} />
<Text style={styles.statValue}>{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}</Text>
<Text style={styles.statLabel}>Intensité moy.</Text>
</View>
<View style={styles.statCard}>
<Ionicons name="heart" size={20} color={rapport.stats.emotion_dominante?.couleur || Colors.blueFranceSun} />
<Text style={styles.statValue}>{rapport.stats.emotion_dominante?.icone || '—'}</Text>
<Text style={styles.statLabel}>{rapport.stats.emotion_dominante?.nom || 'Aucune'}</Text>
</View>
</View>
{/* Period info */} {isLoading ? (
<View style={styles.periodInfo}> <Loader label="Chargement du rapport…" />
<Text style={styles.periodInfoText}> ) : rapport && rapport.stats.total_saisies > 0 ? (
Du {new Date(rapport.date_debut).toLocaleDateString('fr-FR')} au {new Date(rapport.date_fin).toLocaleDateString('fr-FR')} <>
</Text> {/* Stats */}
</View> <Card padding={Spacing.lg} style={styles.statsCard}>
<View style={styles.statsRow}>
{/* Repartition */} <View style={styles.statCell}>
{rapport.repartition.length > 0 && ( <Ionicons name="bar-chart" size={20} color={Colors.primary} />
<View style={styles.section}> <Text style={styles.statValue}>{rapport.stats.total_saisies}</Text>
<Text style={styles.sectionTitle}>Répartition des émotions</Text> <Text style={styles.statLabel}>Saisies</Text>
{/* Camembert (donut) — équivalent mobile du graphique Recharts du web */}
<EmotionPieChart
data={rapport.repartition.map((item) => ({
nom: item.nom,
couleur: item.couleur,
count: item.count,
pourcentage: item.pourcentage,
}))}
/>
<Text style={styles.subSectionTitle}>Détail par émotion</Text>
{rapport.repartition.map((item) => (
<View key={item.nom} style={styles.repartitionItem}>
<View style={styles.repartitionHeader}>
<View style={[styles.colorDot, { backgroundColor: item.couleur }]} />
<Text style={styles.repartitionName}>{item.nom}</Text>
<Text style={styles.repartitionPct}>{item.pourcentage}%</Text>
</View>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${item.pourcentage}%`, backgroundColor: item.couleur }]} />
</View>
<View style={styles.repartitionFooter}>
<Text style={styles.repartitionDetail}>{item.count} saisies</Text>
<Text style={styles.repartitionDetail}>Moy: {item.intensite_moyenne.toFixed(1)}/10</Text>
</View>
</View> </View>
))} <View style={styles.statDivider} />
</View> <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.statLabel}>Intensité moy.</Text>
<View style={styles.empty}> </View>
<Ionicons name="bar-chart-outline" size={48} color={Colors.gray[300]} /> <View style={styles.statDivider} />
<Text style={styles.emptyText}>Pas de données pour cette période</Text> <View style={styles.statCell}>
</View> <Text style={styles.statEmoji}>{rapport.stats.emotion_dominante?.icone || '—'}</Text>
)} <Text style={styles.statLabel} numberOfLines={1}>
</ScrollView> {rapport.stats.emotion_dominante?.nom || 'Aucune'}
</Text>
</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>
{/* Répartition */}
{rapport.repartition.length > 0 && (
<View style={styles.block}>
<SectionTitle>Répartition des émotions</SectionTitle>
<Card padding={Spacing.lg}>
<EmotionPieChart
data={rapport.repartition.map((item) => ({
nom: item.nom,
couleur: item.couleur,
count: item.count,
pourcentage: item.pourcentage,
}))}
/>
</Card>
<View style={styles.detailHeader}>
<SectionTitle>Détail par émotion</SectionTitle>
</View>
<Card padding={Spacing.lg}>
{rapport.repartition.map((item, idx) => (
<View
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 style={styles.progressBar}>
<View
style={[styles.progressFill, { width: `${item.pourcentage}%`, backgroundColor: item.couleur }]}
/>
</View>
<View style={styles.repFooter}>
<Text style={styles.repDetail}>{item.count} saisies</Text>
<Text style={styles.repDetail}>Moy. {item.intensite_moyenne.toFixed(1)}/10</Text>
</View>
</View>
))}
</Card>
</View>
)}
</>
) : (
<EmptyState
icon="bar-chart-outline"
title="Aucune donnée"
message="Pas de saisie pour cette période. Changez de période ou ajoutez une émotion."
/>
)}
</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 },
}); });
+109 -59
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 (
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <View style={styles.screen}>
{/* Avatar & Name */} <AppBar title="Profil" />
<View style={styles.profileHeader}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.avatar}> {/* Avatar & identité */}
<Text style={styles.avatarText}> <Card style={styles.profileCard}>
{user?.prenom?.[0]}{user?.nom?.[0]} <View style={styles.avatar}>
<Text style={styles.avatarText}>{initiales}</Text>
</View>
<Text style={styles.name}>
{user?.prenom} {user?.nom}
</Text> </Text>
</View> <Text style={styles.email}>{user?.email}</Text>
<Text style={styles.name}>{user?.prenom} {user?.nom}</Text> <View style={styles.roleBadge}>
<Text style={styles.email}>{user?.email}</Text> <Badge label={user?.role?.nom ?? 'Membre'} tone="primary" />
<View style={styles.roleBadge}> </View>
<Text style={styles.roleText}>{user?.role?.nom || 'Membre'}</Text> </Card>
</View>
</View>
{/* 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>
<MenuItem icon="person-outline" label="Informations personnelles" /> <Card onPress={() => router.push('/admin')} style={styles.adminCard}>
<MenuItem icon="lock-closed-outline" label="Changer le mot de passe" /> <View style={styles.adminIcon}>
<MenuItem icon="shield-checkmark-outline" label="Données personnelles (RGPD)" /> <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="lock-closed-outline" label="Changer le mot de passe" />
<MenuItem icon="shield-checkmark-outline" label="Données personnelles (RGPD)" last />
</Card>
</View> </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>
<MenuItem icon="information-circle-outline" label="À propos de CESIZen" /> <Card padding={0} style={styles.menuCard}>
<MenuItem icon="document-text-outline" label="Conditions d'utilisation" /> <MenuItem icon="information-circle-outline" label="À propos de CESIZen" />
<MenuItem icon="document-text-outline" label="Conditions d'utilisation" last />
</Card>
</View> </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 },
}); });
+67 -48
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 (
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <View style={styles.screen}>
<View style={styles.header}> <AppBar title="Urgence" subtitle="Vous n'êtes pas seul(e)" />
<View style={styles.iconBadge}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Ionicons name="heart" size={20} color={Colors.error} /> <Text style={styles.intro}>
</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.cardIcon}> <View style={styles.cardHeader}>
<Ionicons name="call" size={22} color={Colors.error} /> <View style={styles.cardIcon}>
</View> <Ionicons name="call" size={22} color={Colors.error} />
<View style={styles.cardContent}> </View>
<Text style={styles.cardName}>{c.nom}</Text> <View style={styles.cardHeaderText}>
<Text style={styles.cardNumber}>{formatNumero(c.numero)}</Text> <Text style={styles.cardName}>{c.nom}</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>
<Text style={styles.cardAvailability}>{c.disponibilite}</Text> <View style={styles.cardAvailabilityRow}>
</View> <Ionicons name="time-outline" size={14} color={Colors.gray[500]} />
<Ionicons name="chevron-forward" size={18} color={Colors.gray[300]} /> <Text style={styles.cardAvailability}>{c.disponibilite}</Text>
</TouchableOpacity> </View>
))} <Button
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}>
<Text style={styles.warningText}> <Ionicons name="warning" size={20} color={Colors.error} />
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 style={styles.warningText}>
</Text> En cas d'urgence vitale, appelez immédiatement le <Text style={styles.bold}>15</Text> (SAMU) ou le{' '}
</View> <Text style={styles.bold}>112</Text>.
</ScrollView> </Text>
</View>
</Card>
</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 },
});
+68 -59
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 value={email}
style={styles.input} onChangeText={(v) => { setEmail(v); setError(null); }}
value={email} placeholder="votre@email.com"
onChangeText={(v) => { setEmail(v); setError(null); }} keyboardType="email-address"
placeholder="votre@email.com" autoCapitalize="none"
keyboardType="email-address" autoComplete="email"
autoCapitalize="none" error={error ?? undefined}
autoCorrect={false} required
/>
<Button
title="Envoyer les instructions"
onPress={handleSubmit}
icon="paper-plane-outline"
fullWidth
/>
<View style={styles.backRow}>
<Button
title="Retour à la connexion"
variant="tertiary"
size="sm"
onPress={() => router.replace('/auth/login')}
/> />
{error && <Text style={styles.errorText}>{error}</Text>}
</View> </View>
</Card>
<TouchableOpacity style={styles.button} onPress={handleSubmit}>
<Text style={styles.buttonText}>Envoyer les instructions</Text>
</TouchableOpacity>
<View style={styles.footer}>
<Link href="/auth/login" asChild>
<TouchableOpacity>
<Text style={styles.link}>Retour à la connexion</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</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 },
}); });
+84 -84
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 value={email}
style={styles.input} onChangeText={(v) => { setEmail(v); setError(null); }}
value={email} placeholder="votre@email.com"
onChangeText={setEmail} keyboardType="email-address"
placeholder="votre@email.com" autoCapitalize="none"
keyboardType="email-address" autoComplete="email"
autoCapitalize="none" required
autoCorrect={false} />
/>
</View>
<View style={styles.inputGroup}> <TextField
<Text style={styles.label}>Mot de passe</Text> label="Mot de passe"
<TextInput value={password}
style={styles.input} onChangeText={(v) => { setPassword(v); setError(null); }}
value={password} placeholder="Votre mot de passe"
onChangeText={setPassword} secureTextEntry
placeholder="Votre mot de passe" autoComplete="password"
secureTextEntry required
/> />
</View>
<TouchableOpacity <Button
style={[styles.button, loading && styles.buttonDisabled]} title="Se connecter"
onPress={handleLogin} onPress={handleLogin}
disabled={loading} loading={loading}
> icon="log-in-outline"
<Text style={styles.buttonText}>{loading ? 'Connexion...' : 'Se connecter'}</Text> fullWidth
</TouchableOpacity> />
<Link href="/auth/forgot-password" asChild> <View style={styles.forgotRow}>
<TouchableOpacity style={styles.forgotLink}> <Button
<Text style={styles.link}>Mot de passe oublié ?</Text> title="Mot de passe oublié ?"
</TouchableOpacity> variant="tertiary"
</Link> size="sm"
onPress={() => router.push('/auth/forgot-password')}
<View style={styles.footer}> />
<Text style={styles.footerText}>Pas encore de compte ? </Text>
<Link href="/auth/register" asChild>
<TouchableOpacity>
<Text style={styles.link}>S'inscrire</Text>
</TouchableOpacity>
</Link>
</View> </View>
</Card>
<View style={styles.footer}>
<Text style={styles.footerText}>Pas encore de compte ?</Text>
<Button
title="Créer un compte"
variant="secondary"
onPress={() => router.push('/auth/register')}
icon="person-add-outline"
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' },
}); });
+117 -78
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
</View> label="Nom"
value={nom}
<View style={styles.form}> onChangeText={setNom}
<Text style={styles.title}>Inscription</Text> placeholder="Dupont"
autoCapitalize="words"
<View style={styles.row}> error={errors.nom}
<View style={[styles.inputGroup, { flex: 1, marginRight: 8 }]}> required
<Text style={styles.label}>Nom</Text> style={styles.nameField}
<TextInput style={styles.input} value={nom} onChangeText={setNom} placeholder="Dupont" /> />
{errors.nom && <Text style={styles.errorText}>{errors.nom}</Text>} <TextField
</View> label="Prénom"
<View style={[styles.inputGroup, { flex: 1, marginLeft: 8 }]}> value={prenom}
<Text style={styles.label}>Prénom</Text> onChangeText={setPrenom}
<TextInput style={styles.input} value={prenom} onChangeText={setPrenom} placeholder="Jean" /> placeholder="Jean"
{errors.prenom && <Text style={styles.errorText}>{errors.prenom}</Text>} autoCapitalize="words"
</View> error={errors.prenom}
required
style={styles.nameField}
/>
</View> </View>
<View style={styles.inputGroup}> <TextField
<Text style={styles.label}>Email</Text> label="Email"
<TextInput style={styles.input} value={email} onChangeText={setEmail} placeholder="votre@email.com" keyboardType="email-address" autoCapitalize="none" autoCorrect={false} /> value={email}
{errors.email && <Text style={styles.errorText}>{errors.email}</Text>} onChangeText={setEmail}
</View> placeholder="votre@email.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
error={errors.email}
required
/>
<View style={styles.inputGroup}> <TextField
<Text style={styles.label}>Mot de passe (min. 8 caractères)</Text> label="Mot de passe"
<TextInput style={styles.input} value={password} onChangeText={setPassword} placeholder="Mot de passe" secureTextEntry /> value={password}
{errors.password && <Text style={styles.errorText}>{errors.password}</Text>} onChangeText={setPassword}
</View> placeholder="Mot de passe"
secureTextEntry
error={errors.password}
helper="Au moins 8 caractères"
required
/>
<View style={styles.inputGroup}> <TextField
<Text style={styles.label}>Confirmer le mot de passe</Text> label="Confirmer le mot de passe"
<TextInput style={styles.input} value={passwordConfirmation} onChangeText={setPasswordConfirmation} placeholder="Confirmer" secureTextEntry /> value={passwordConfirmation}
{errors.passwordConfirmation && <Text style={styles.errorText}>{errors.passwordConfirmation}</Text>} onChangeText={setPasswordConfirmation}
</View> placeholder="Confirmer"
secureTextEntry
error={errors.passwordConfirmation}
required
/>
<TouchableOpacity style={styles.consentRow} onPress={() => setConsentement(!consentement)} activeOpacity={0.7}> <Pressable
<View style={[styles.checkbox, consentement && styles.checkboxChecked]}> style={styles.consentRow}
{consentement && <Ionicons name="checkmark" size={16} color={Colors.white} />} onPress={() => setConsentement(!consentement)}
</View> accessibilityRole="checkbox"
accessibilityState={{ checked: consentement }}
>
<Ionicons
name={consentement ? 'checkbox' : 'square-outline'}
size={24}
color={consentement ? Colors.primary : Colors.gray[500]}
style={styles.consentIcon}
/>
<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}
<View style={styles.footer}> loading={loading}
<Text style={styles.footerText}>Déjà un compte ? </Text> icon="person-add-outline"
<Link href="/auth/login" asChild> fullWidth
<TouchableOpacity><Text style={styles.link}>Se connecter</Text></TouchableOpacity> />
</Link>
</View> </View>
</Card>
<View style={styles.footer}>
<Text style={styles.footerText}>Déjà un compte ?</Text>
<Button
title="Se connecter"
variant="tertiary"
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}</>;
}
+66 -48
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 ( key={child.id}
<TouchableOpacity emotion={child}
key={child.id} selected={value === child.id}
style={[ onPress={() => onChange(child.id)}
styles.emotionCard, />
isSelected && { borderColor: child.couleur, borderWidth: 2.5 }, ))}
]}
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 },
}); });