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:
@@ -47,6 +47,7 @@ export default function TabLayout() {
|
|||||||
name="urgence"
|
name="urgence"
|
||||||
options={{
|
options={{
|
||||||
title: 'Urgence',
|
title: 'Urgence',
|
||||||
|
headerShown: false,
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="call" size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Ionicons name="call" size={size} color={color} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -54,6 +55,7 @@ export default function TabLayout() {
|
|||||||
name="profile"
|
name="profile"
|
||||||
options={{
|
options={{
|
||||||
title: 'Profil',
|
title: 'Profil',
|
||||||
|
headerShown: false,
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="person" size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Ionicons name="person" size={size} color={color} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+102
-68
@@ -1,126 +1,160 @@
|
|||||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { useRapport } from '@/hooks/useRapport';
|
import { useRapport } from '@/hooks/useRapport';
|
||||||
import { useSaisies } from '@/hooks/useTracker';
|
import { useSaisies } from '@/hooks/useTracker';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
import RepubliqueHeader from '@/components/RepubliqueHeader';
|
import { Spacing, Radius, FontSize, FontWeight, MIN_TOUCH } from '@/lib/theme';
|
||||||
|
import { AppBar, Card, Badge, SectionTitle, EmptyState, Loader } from '@/components/ui';
|
||||||
|
|
||||||
export default function DashboardScreen() {
|
export default function DashboardScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const { data: rapport } = useRapport('month');
|
const { data: rapport } = useRapport('month');
|
||||||
const { data: saisies } = useSaisies();
|
const { data: saisies, isLoading } = useSaisies();
|
||||||
|
|
||||||
const recentSaisies = (saisies || []).slice(0, 3);
|
const recentSaisies = (saisies || []).slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.screen}>
|
<View style={styles.screen}>
|
||||||
<RepubliqueHeader />
|
<AppBar
|
||||||
|
title={`Bonjour ${user?.prenom || ''}`.trim()}
|
||||||
|
subtitle="Comment allez-vous aujourd'hui ?"
|
||||||
|
/>
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.greeting}>Bonjour, {user?.prenom} 👋</Text>
|
|
||||||
<Text style={styles.subtitle}>Comment allez-vous aujourd'hui ?</Text>
|
|
||||||
|
|
||||||
{/* Actions rapides */}
|
{/* Actions rapides */}
|
||||||
<View style={styles.actionsRow}>
|
<View style={styles.actionsRow}>
|
||||||
<TouchableOpacity style={styles.actionCard} onPress={() => router.push('/(tabs)/journal/new')}>
|
<Card onPress={() => router.push('/(tabs)/journal/new')} padding={Spacing.lg} style={styles.actionPrimary}>
|
||||||
<View style={[styles.actionIcon, { backgroundColor: `${Colors.primary}30` }]}>
|
<View style={[styles.actionIcon, styles.actionIconPrimary]}>
|
||||||
<Ionicons name="add-circle" size={28} color={Colors.primary} />
|
<Ionicons name="add-circle" size={28} color={Colors.white} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.actionText}>Nouvelle saisie</Text>
|
<Text style={styles.actionTextPrimary}>Nouvelle saisie</Text>
|
||||||
</TouchableOpacity>
|
</Card>
|
||||||
<TouchableOpacity style={styles.actionCard} onPress={() => router.push('/(tabs)/journal/reports')}>
|
<Card onPress={() => router.push('/(tabs)/journal/reports')} padding={Spacing.lg} style={styles.actionCard}>
|
||||||
<View style={[styles.actionIcon, { backgroundColor: `${Colors.secondary}30` }]}>
|
<View style={[styles.actionIcon, styles.actionIconSecondary]}>
|
||||||
<Ionicons name="bar-chart" size={28} color={Colors.secondary} />
|
<Ionicons name="bar-chart" size={28} color={Colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.actionText}>Mes rapports</Text>
|
<Text style={styles.actionText}>Mes rapports</Text>
|
||||||
</TouchableOpacity>
|
</Card>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Stats du mois */}
|
{/* Stats du mois */}
|
||||||
{rapport && (
|
{rapport && (
|
||||||
<View style={styles.statsContainer}>
|
<View style={styles.block}>
|
||||||
<Text style={styles.sectionTitle}>Ce mois-ci</Text>
|
<SectionTitle>Ce mois-ci</SectionTitle>
|
||||||
|
<Card padding={Spacing.lg}>
|
||||||
<View style={styles.statsRow}>
|
<View style={styles.statsRow}>
|
||||||
<View style={styles.statCard}>
|
<View style={styles.statCell}>
|
||||||
<Text style={styles.statValue}>{rapport.stats.total_saisies}</Text>
|
<Text style={styles.statValue}>{rapport.stats.total_saisies}</Text>
|
||||||
<Text style={styles.statLabel}>Saisies</Text>
|
<Text style={styles.statLabel}>Saisies</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.statCard}>
|
<View style={styles.statDivider} />
|
||||||
<Text style={styles.statValue}>{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}</Text>
|
<View style={styles.statCell}>
|
||||||
|
<Text style={styles.statValue}>
|
||||||
|
{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}
|
||||||
|
</Text>
|
||||||
<Text style={styles.statLabel}>Intensité moy.</Text>
|
<Text style={styles.statLabel}>Intensité moy.</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.statCard}>
|
<View style={styles.statDivider} />
|
||||||
|
<View style={styles.statCell}>
|
||||||
<Text style={styles.statValue}>{rapport.stats.emotion_dominante?.icone || '—'}</Text>
|
<Text style={styles.statValue}>{rapport.stats.emotion_dominante?.icone || '—'}</Text>
|
||||||
<Text style={styles.statLabel}>{rapport.stats.emotion_dominante?.nom || 'Aucune'}</Text>
|
<Text style={styles.statLabel} numberOfLines={1}>
|
||||||
|
{rapport.stats.emotion_dominante?.nom || 'Aucune'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</Card>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dernieres saisies */}
|
{/* Dernières saisies */}
|
||||||
{recentSaisies.length > 0 && (
|
<View style={styles.block}>
|
||||||
<View style={styles.recentContainer}>
|
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Text style={styles.sectionTitle}>Dernières émotions</Text>
|
<SectionTitle>Dernières saisies</SectionTitle>
|
||||||
<TouchableOpacity onPress={() => router.push('/(tabs)/journal')}>
|
{recentSaisies.length > 0 && (
|
||||||
<Text style={styles.seeAll}>Tout voir</Text>
|
<Text style={styles.seeAll} onPress={() => router.push('/(tabs)/journal')}>
|
||||||
</TouchableOpacity>
|
Tout voir
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{recentSaisies.map((saisie) => (
|
|
||||||
<View key={saisie.id} style={styles.saisieItem}>
|
{isLoading ? (
|
||||||
|
<Loader label="Chargement…" />
|
||||||
|
) : recentSaisies.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="sparkles-outline"
|
||||||
|
title="Aucune saisie pour l'instant"
|
||||||
|
message="Commencez par enregistrer votre première émotion du jour."
|
||||||
|
actionLabel="Nouvelle saisie"
|
||||||
|
onAction={() => router.push('/(tabs)/journal/new')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
recentSaisies.map((saisie) => (
|
||||||
|
<Card
|
||||||
|
key={saisie.id}
|
||||||
|
onPress={() => router.push('/(tabs)/journal')}
|
||||||
|
padding={Spacing.md}
|
||||||
|
style={styles.saisieCard}
|
||||||
|
>
|
||||||
<View style={[styles.emotionIcon, { backgroundColor: `${saisie.emotion?.couleur}20` }]}>
|
<View style={[styles.emotionIcon, { backgroundColor: `${saisie.emotion?.couleur}20` }]}>
|
||||||
<Text style={styles.emotionEmoji}>{saisie.emotion?.icone || '🔵'}</Text>
|
<Text style={styles.emotionEmoji}>{saisie.emotion?.icone || '🔵'}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.saisieContent}>
|
<View style={styles.saisieContent}>
|
||||||
<Text style={styles.saisieEmotion}>{saisie.emotion?.nom}</Text>
|
{saisie.emotion?.couleur ? (
|
||||||
|
<Badge label={saisie.emotion?.nom || ''} color={saisie.emotion.couleur} />
|
||||||
|
) : (
|
||||||
|
<Badge label={saisie.emotion?.nom || '—'} tone="neutral" />
|
||||||
|
)}
|
||||||
<Text style={styles.saisieDate}>
|
<Text style={styles.saisieDate}>
|
||||||
{new Date(saisie.date_saisie).toLocaleDateString('fr-FR')} — Intensité: {saisie.intensite}/10
|
{new Date(saisie.date_saisie).toLocaleDateString('fr-FR')} — Intensité {saisie.intensite}/10
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Card>
|
||||||
))}
|
))
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
screen: { flex: 1, backgroundColor: Colors.white },
|
screen: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||||
container: { flex: 1, backgroundColor: Colors.white },
|
container: { flex: 1 },
|
||||||
content: { padding: 20 },
|
content: { padding: Spacing.lg, paddingBottom: Spacing.xxxl },
|
||||||
greeting: { fontSize: 28, fontWeight: '800', color: Colors.black, marginTop: 8 },
|
actionsRow: { flexDirection: 'row', gap: Spacing.md, marginBottom: Spacing.xl },
|
||||||
subtitle: { fontSize: 16, color: Colors.gray[500], marginTop: 4, marginBottom: 24 },
|
actionCard: { flex: 1, alignItems: 'center', gap: Spacing.md },
|
||||||
actionsRow: { flexDirection: 'row', gap: 12, marginBottom: 24 },
|
actionPrimary: {
|
||||||
actionCard: {
|
flex: 1,
|
||||||
flex: 1, backgroundColor: Colors.white, borderRadius: 4, padding: 20,
|
alignItems: 'center',
|
||||||
alignItems: 'center', gap: 12, borderWidth: 1, borderColor: Colors.gray[200],
|
gap: Spacing.md,
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
borderColor: Colors.primary,
|
||||||
},
|
},
|
||||||
actionIcon: { width: 56, height: 56, borderRadius: 4, justifyContent: 'center', alignItems: 'center' },
|
actionIcon: { width: 56, height: 56, borderRadius: Radius.sm, justifyContent: 'center', alignItems: 'center' },
|
||||||
actionText: { fontSize: 14, fontWeight: '600', color: Colors.black },
|
actionIconPrimary: { backgroundColor: Colors.primaryDark },
|
||||||
statsContainer: { marginBottom: 24 },
|
actionIconSecondary: { backgroundColor: '#E3E3FD' },
|
||||||
sectionTitle: { fontSize: 18, fontWeight: '700', color: Colors.black, marginBottom: 12 },
|
actionText: { fontSize: FontSize.sm, fontWeight: FontWeight.semibold, color: Colors.black },
|
||||||
statsRow: { flexDirection: 'row', gap: 12 },
|
actionTextPrimary: { fontSize: FontSize.sm, fontWeight: FontWeight.bold, color: Colors.white },
|
||||||
statCard: {
|
block: { marginBottom: Spacing.xl },
|
||||||
flex: 1, backgroundColor: Colors.gray[50], borderRadius: 4, padding: 16,
|
statsRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
alignItems: 'center', borderWidth: 1, borderColor: Colors.gray[200],
|
statCell: { flex: 1, alignItems: 'center', gap: Spacing.xs },
|
||||||
|
statDivider: { width: 1, alignSelf: 'stretch', backgroundColor: Colors.gray[200], marginVertical: Spacing.xs },
|
||||||
|
statValue: { fontSize: FontSize.xxl, fontWeight: FontWeight.heavy, color: Colors.primary },
|
||||||
|
statLabel: { fontSize: FontSize.tiny, color: Colors.gray[500], textAlign: 'center' },
|
||||||
|
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
|
seeAll: {
|
||||||
|
fontSize: FontSize.sm,
|
||||||
|
fontWeight: FontWeight.semibold,
|
||||||
|
color: Colors.primary,
|
||||||
|
textDecorationLine: 'underline',
|
||||||
|
minHeight: MIN_TOUCH,
|
||||||
|
paddingTop: Spacing.xs,
|
||||||
},
|
},
|
||||||
statValue: { fontSize: 24, fontWeight: '800', color: Colors.primary },
|
saisieCard: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md, marginBottom: Spacing.sm },
|
||||||
statLabel: { fontSize: 12, color: Colors.gray[500], marginTop: 4 },
|
emotionIcon: { width: 44, height: 44, borderRadius: Radius.sm, justifyContent: 'center', alignItems: 'center' },
|
||||||
recentContainer: { marginBottom: 24 },
|
|
||||||
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
|
|
||||||
seeAll: { fontSize: 14, fontWeight: '600', color: Colors.primary, textDecorationLine: 'underline' },
|
|
||||||
saisieItem: {
|
|
||||||
flexDirection: 'row', alignItems: 'center', gap: 12, padding: 14,
|
|
||||||
backgroundColor: Colors.white, borderRadius: 4, marginBottom: 8,
|
|
||||||
borderWidth: 1, borderColor: Colors.gray[200],
|
|
||||||
},
|
|
||||||
emotionIcon: { width: 44, height: 44, borderRadius: 4, justifyContent: 'center', alignItems: 'center' },
|
|
||||||
emotionEmoji: { fontSize: 22 },
|
emotionEmoji: { fontSize: 22 },
|
||||||
saisieContent: { flex: 1 },
|
saisieContent: { flex: 1, gap: Spacing.xs, alignItems: 'flex-start' },
|
||||||
saisieEmotion: { fontSize: 15, fontWeight: '600', color: Colors.black },
|
saisieDate: { fontSize: FontSize.xs, color: Colors.gray[500] },
|
||||||
saisieDate: { fontSize: 13, color: Colors.gray[500], marginTop: 2 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,84 @@
|
|||||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, FlatList } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useFeeds } from '@/hooks/useFeeds';
|
import { useFeeds } from '@/hooks/useFeeds';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
|
||||||
|
import { AppBar, Card, EmptyState, Loader } from '@/components/ui';
|
||||||
import type { Feed } from '@/types';
|
import type { Feed } from '@/types';
|
||||||
|
|
||||||
export default function InfoListScreen() {
|
export default function InfoListScreen() {
|
||||||
const { data: feeds, isLoading } = useFeeds();
|
const { data: feeds, isLoading } = useFeeds();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: Feed }) => (
|
const renderItem = ({ item }: { item: Feed }) => {
|
||||||
<TouchableOpacity
|
const extrait = (item.contenu ?? '').replace(/<[^>]*>/g, '').trim();
|
||||||
style={styles.card}
|
return (
|
||||||
onPress={() => router.push(`/(tabs)/info/${item.slug}`)}
|
<Card onPress={() => router.push(`/(tabs)/info/${item.slug}`)} style={styles.card}>
|
||||||
>
|
<View style={styles.cardRow}>
|
||||||
<View style={styles.cardIcon}>
|
<View style={styles.cardIcon}>
|
||||||
<Ionicons name="document-text" size={24} color={Colors.primary} />
|
<Ionicons name="document-text" size={24} color={Colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.cardContent}>
|
<View style={styles.cardContent}>
|
||||||
<Text style={styles.cardTitle}>{item.titre}</Text>
|
<Text style={styles.cardTitle}>{item.titre}</Text>
|
||||||
|
{extrait ? (
|
||||||
<Text style={styles.cardExcerpt} numberOfLines={2}>
|
<Text style={styles.cardExcerpt} numberOfLines={2}>
|
||||||
{item.contenu?.replace(/<[^>]*>/g, '').substring(0, 120)}...
|
{extrait}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : null}
|
||||||
<Text style={styles.cardDate}>
|
<Text style={styles.cardDate}>
|
||||||
{new Date(item.created_at).toLocaleDateString('fr-FR')}
|
{new Date(item.created_at).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={20} color={Colors.gray[300]} />
|
<Ionicons name="chevron-forward" size={20} color={Colors.gray[400]} />
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
<AppBar title="Informations" subtitle="Ressources pour votre santé mentale" />
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader label="Chargement des articles…" />
|
||||||
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={feeds || []}
|
data={feeds ?? []}
|
||||||
keyExtractor={(item) => String(item.id)}
|
keyExtractor={(item) => String(item.id)}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
ListHeaderComponent={
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Text style={styles.headerTitle}>Santé mentale</Text>
|
|
||||||
<Text style={styles.headerSubtitle}>Ressources et informations pour votre bien-être</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
isLoading ? (
|
<EmptyState
|
||||||
<View style={styles.loading}><Text style={styles.loadingText}>Chargement...</Text></View>
|
icon="newspaper-outline"
|
||||||
) : (
|
title="Aucun article disponible"
|
||||||
<View style={styles.empty}>
|
message="Les ressources d'information apparaîtront ici dès leur publication."
|
||||||
<Ionicons name="newspaper-outline" size={48} color={Colors.gray[300]} />
|
/>
|
||||||
<Text style={styles.emptyText}>Aucun article disponible</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: Colors.white },
|
container: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||||
listContent: { padding: 16 },
|
listContent: { padding: Spacing.lg, gap: Spacing.md },
|
||||||
header: { marginBottom: 20 },
|
card: { padding: Spacing.lg },
|
||||||
headerTitle: { fontSize: 14, fontWeight: '700', color: Colors.primary, textTransform: 'uppercase', letterSpacing: 1 },
|
cardRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md },
|
||||||
headerSubtitle: { fontSize: 15, color: Colors.gray[500], marginTop: 4 },
|
cardIcon: {
|
||||||
card: {
|
width: 48,
|
||||||
flexDirection: 'row', alignItems: 'center', gap: 14, padding: 16,
|
height: 48,
|
||||||
backgroundColor: Colors.white, borderRadius: 4, marginBottom: 10,
|
borderRadius: Radius.sm,
|
||||||
borderWidth: 1, borderColor: Colors.gray[200],
|
backgroundColor: '#E3E3FD',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
cardIcon: { width: 48, height: 48, borderRadius: 4, backgroundColor: `${Colors.primary}15`, justifyContent: 'center', alignItems: 'center' },
|
|
||||||
cardContent: { flex: 1 },
|
cardContent: { flex: 1 },
|
||||||
cardTitle: { fontSize: 15, fontWeight: '700', color: Colors.black, marginBottom: 4 },
|
cardTitle: { fontSize: FontSize.body, fontWeight: FontWeight.bold, color: Colors.black, marginBottom: Spacing.xs },
|
||||||
cardExcerpt: { fontSize: 13, color: Colors.gray[500], lineHeight: 18 },
|
cardExcerpt: { fontSize: FontSize.xs, color: Colors.gray[500], lineHeight: 18 },
|
||||||
cardDate: { fontSize: 11, color: Colors.gray[400], marginTop: 4 },
|
cardDate: { fontSize: FontSize.tiny, color: Colors.gray[400], marginTop: Spacing.xs },
|
||||||
loading: { padding: 40, alignItems: 'center' },
|
|
||||||
loadingText: { color: Colors.gray[400] },
|
|
||||||
empty: { alignItems: 'center', paddingTop: 60 },
|
|
||||||
emptyText: { fontSize: 16, color: Colors.gray[400], marginTop: 12 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useRouter } from 'expo-router';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
|
import { useSaisies, useDeleteSaisie } from '@/hooks/useTracker';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { Spacing, Radius, FontSize, FontWeight, Shadow, MIN_TOUCH, HIT_SLOP } from '@/lib/theme';
|
||||||
|
import { AppBar, Card, Badge, EmptyState, Loader } from '@/components/ui';
|
||||||
import type { SaisieTracker } from '@/types';
|
import type { SaisieTracker } from '@/types';
|
||||||
|
|
||||||
export default function JournalScreen() {
|
export default function JournalScreen() {
|
||||||
@@ -36,86 +38,128 @@ export default function JournalScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: SaisieTracker }) => (
|
const renderItem = ({ item }: { item: SaisieTracker }) => (
|
||||||
<View style={styles.saisieCard}>
|
<Card padding={Spacing.lg} style={styles.saisieCard}>
|
||||||
<View style={[styles.emotionIcon, { backgroundColor: `${item.emotion?.couleur}20` }]}>
|
<View style={[styles.emotionIcon, { backgroundColor: `${item.emotion?.couleur}20` }]}>
|
||||||
<Text style={styles.emotionEmoji}>{item.emotion?.icone || '🔵'}</Text>
|
<Text style={styles.emotionEmoji}>{item.emotion?.icone || '🔵'}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.saisieContent}>
|
<View style={styles.saisieContent}>
|
||||||
<View style={styles.saisieHeader}>
|
<View style={styles.saisieHeader}>
|
||||||
<View style={[styles.badge, { backgroundColor: `${item.emotion?.couleur}20` }]}>
|
{item.emotion?.couleur ? (
|
||||||
<Text style={[styles.badgeText, { color: item.emotion?.couleur }]}>{item.emotion?.nom}</Text>
|
<Badge label={item.emotion?.nom || ''} color={item.emotion.couleur} />
|
||||||
</View>
|
) : (
|
||||||
<Text style={styles.intensityText}>Intensité: {item.intensite}/10</Text>
|
<Badge label={item.emotion?.nom || '—'} tone="neutral" />
|
||||||
|
)}
|
||||||
|
<Text style={styles.intensityText}>Intensité {item.intensite}/10</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.intensityBar}>
|
<View style={styles.intensityBar}>
|
||||||
<View style={[styles.intensityFill, { width: `${item.intensite * 10}%`, backgroundColor: item.emotion?.couleur || Colors.primary }]} />
|
<View
|
||||||
|
style={[
|
||||||
|
styles.intensityFill,
|
||||||
|
{ width: `${item.intensite * 10}%`, backgroundColor: item.emotion?.couleur || Colors.primary },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
{item.note && <Text style={styles.note} numberOfLines={2}>{item.note}</Text>}
|
{item.note ? (
|
||||||
<Text style={styles.date}>{new Date(item.date_saisie).toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}</Text>
|
<Text style={styles.note} numberOfLines={2}>
|
||||||
|
{item.note}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text style={styles.date}>
|
||||||
|
{new Date(item.date_saisie).toLocaleDateString('fr-FR', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<TouchableOpacity style={styles.actionBtn} onPress={() => router.push({ pathname: '/(tabs)/journal/edit', params: { id: String(item.id) } })}>
|
<TouchableOpacity
|
||||||
<Ionicons name="create-outline" size={18} color={Colors.gray[400]} />
|
style={styles.actionBtn}
|
||||||
|
hitSlop={HIT_SLOP}
|
||||||
|
accessibilityLabel="Modifier la saisie"
|
||||||
|
onPress={() => router.push({ pathname: '/(tabs)/journal/edit', params: { id: String(item.id) } })}
|
||||||
|
>
|
||||||
|
<Ionicons name="create-outline" size={20} color={Colors.primary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.actionBtn} onPress={() => handleDelete(item.id)}>
|
<TouchableOpacity
|
||||||
<Ionicons name="trash-outline" size={18} color={Colors.gray[400]} />
|
style={styles.actionBtn}
|
||||||
|
hitSlop={HIT_SLOP}
|
||||||
|
accessibilityLabel="Supprimer la saisie"
|
||||||
|
onPress={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={20} color={Colors.error} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.screen}>
|
||||||
|
<AppBar title="Mon journal" subtitle="Vos émotions au fil du temps" />
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader label="Chargement du journal…" />
|
||||||
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={saisies || []}
|
data={saisies || []}
|
||||||
keyExtractor={(item) => String(item.id)}
|
keyExtractor={(item) => String(item.id)}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={Colors.primary} />}
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={Colors.primary} />
|
||||||
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={styles.empty}>
|
<EmptyState
|
||||||
<Ionicons name="book-outline" size={48} color={Colors.gray[300]} />
|
icon="book-outline"
|
||||||
<Text style={styles.emptyTitle}>Aucune saisie</Text>
|
title="Aucune saisie"
|
||||||
<Text style={styles.emptyText}>Commencez par enregistrer votre première émotion</Text>
|
message="Commencez par enregistrer votre première émotion."
|
||||||
</View>
|
actionLabel="Nouvelle saisie"
|
||||||
|
onAction={() => router.push('/(tabs)/journal/new')}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity style={styles.fab} onPress={() => router.push('/(tabs)/journal/new')}>
|
)}
|
||||||
<Ionicons name="add" size={28} color={Colors.white} />
|
<TouchableOpacity
|
||||||
|
style={styles.fab}
|
||||||
|
accessibilityLabel="Nouvelle saisie"
|
||||||
|
onPress={() => router.push('/(tabs)/journal/new')}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={30} color={Colors.white} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: Colors.white },
|
screen: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||||
listContent: { padding: 16, paddingBottom: 80 },
|
listContent: { padding: Spacing.lg, paddingBottom: 96 },
|
||||||
saisieCard: {
|
saisieCard: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.md, marginBottom: Spacing.md },
|
||||||
flexDirection: 'row', alignItems: 'flex-start', gap: 12, padding: 16,
|
emotionIcon: { width: 48, height: 48, borderRadius: Radius.sm, justifyContent: 'center', alignItems: 'center' },
|
||||||
backgroundColor: Colors.white, borderRadius: 4, marginBottom: 10,
|
|
||||||
borderWidth: 1, borderColor: Colors.gray[200],
|
|
||||||
},
|
|
||||||
emotionIcon: { width: 48, height: 48, borderRadius: 4, justifyContent: 'center', alignItems: 'center' },
|
|
||||||
emotionEmoji: { fontSize: 24 },
|
emotionEmoji: { fontSize: 24 },
|
||||||
saisieContent: { flex: 1 },
|
saisieContent: { flex: 1 },
|
||||||
saisieHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 },
|
saisieHeader: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, marginBottom: Spacing.sm, flexWrap: 'wrap' },
|
||||||
badge: { paddingHorizontal: 10, paddingVertical: 3, borderRadius: 4 },
|
intensityText: { fontSize: FontSize.xs, color: Colors.gray[500] },
|
||||||
badgeText: { fontSize: 13, fontWeight: '600' },
|
intensityBar: { height: 6, backgroundColor: Colors.gray[200], borderRadius: Radius.sm, marginBottom: Spacing.sm },
|
||||||
intensityText: { fontSize: 12, color: Colors.gray[400] },
|
intensityFill: { height: 6, borderRadius: Radius.sm },
|
||||||
intensityBar: { height: 4, backgroundColor: Colors.gray[200], borderRadius: 2, marginBottom: 6 },
|
note: { fontSize: FontSize.xs, color: Colors.gray[600], marginBottom: Spacing.xs, lineHeight: 19 },
|
||||||
intensityFill: { height: 4, borderRadius: 2 },
|
date: { fontSize: FontSize.xs, color: Colors.gray[400], textTransform: 'capitalize' },
|
||||||
note: { fontSize: 13, color: Colors.gray[600], marginBottom: 4 },
|
actions: { gap: Spacing.sm },
|
||||||
date: { fontSize: 12, color: Colors.gray[400] },
|
actionBtn: {
|
||||||
actions: { gap: 4 },
|
width: MIN_TOUCH,
|
||||||
actionBtn: { padding: 6, borderRadius: 8 },
|
height: MIN_TOUCH,
|
||||||
empty: { alignItems: 'center', paddingTop: 80 },
|
borderRadius: Radius.sm,
|
||||||
emptyTitle: { fontSize: 18, fontWeight: '600', color: Colors.gray[400], marginTop: 12 },
|
justifyContent: 'center',
|
||||||
emptyText: { fontSize: 14, color: Colors.gray[400], marginTop: 4 },
|
alignItems: 'center',
|
||||||
|
},
|
||||||
fab: {
|
fab: {
|
||||||
position: 'absolute', right: 20, bottom: 20,
|
position: 'absolute',
|
||||||
width: 56, height: 56, borderRadius: 4,
|
right: Spacing.lg,
|
||||||
backgroundColor: Colors.primary, justifyContent: 'center', alignItems: 'center',
|
bottom: Spacing.lg,
|
||||||
shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8,
|
width: 56,
|
||||||
elevation: 6,
|
height: 56,
|
||||||
|
borderRadius: Radius.sm,
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
...Shadow.raised,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, TextInput, ScrollView, Alert } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Alert } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useCreateSaisie } from '@/hooks/useTracker';
|
import { useCreateSaisie } from '@/hooks/useTracker';
|
||||||
import EmotionPicker from '@/components/EmotionPicker';
|
import EmotionPicker from '@/components/EmotionPicker';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { Spacing, Radius, FontSize, FontWeight } from '@/lib/theme';
|
||||||
|
import { Button, TextField } from '@/components/ui';
|
||||||
|
|
||||||
export default function NewEntryScreen() {
|
export default function NewEntryScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const createSaisie = useCreateSaisie();
|
const createSaisie = useCreateSaisie();
|
||||||
@@ -22,11 +25,9 @@ export default function NewEntryScreen() {
|
|||||||
note: note.trim() || undefined,
|
note: note.trim() || undefined,
|
||||||
date_saisie: new Date().toISOString().split('T')[0],
|
date_saisie: new Date().toISOString().split('T')[0],
|
||||||
});
|
});
|
||||||
Alert.alert('Succès', 'Saisie enregistrée !', [
|
Alert.alert('Succès', 'Saisie enregistrée !', [{ text: 'OK', onPress: () => router.back() }]);
|
||||||
{ text: 'OK', onPress: () => router.back() },
|
|
||||||
]);
|
|
||||||
} catch {
|
} catch {
|
||||||
Alert.alert('Erreur', 'Impossible d\'enregistrer la saisie');
|
Alert.alert('Erreur', "Impossible d'enregistrer la saisie");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export default function NewEntryScreen() {
|
|||||||
<View key={s} style={styles.stepRow}>
|
<View key={s} style={styles.stepRow}>
|
||||||
<View style={[styles.stepCircle, step >= s && styles.stepActive]}>
|
<View style={[styles.stepCircle, step >= s && styles.stepActive]}>
|
||||||
{step > s ? (
|
{step > s ? (
|
||||||
<Ionicons name="checkmark" size={16} color={Colors.white} />
|
<Ionicons name="checkmark" size={18} color={Colors.white} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={[styles.stepNumber, step >= s && styles.stepNumberActive]}>{s}</Text>
|
<Text style={[styles.stepNumber, step >= s && styles.stepNumberActive]}>{s}</Text>
|
||||||
)}
|
)}
|
||||||
@@ -48,23 +49,23 @@ export default function NewEntryScreen() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Step 1: Emotion */}
|
{/* Step 1: Émotion */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<View>
|
<View>
|
||||||
<Text style={styles.stepTitle}>Comment vous sentez-vous ?</Text>
|
<Text style={styles.stepTitle}>Comment vous sentez-vous ?</Text>
|
||||||
<EmotionPicker value={emotionId} onChange={setEmotionId} />
|
<EmotionPicker value={emotionId} onChange={setEmotionId} />
|
||||||
<TouchableOpacity
|
<View style={styles.navRowEnd}>
|
||||||
style={[styles.nextBtn, !emotionId && styles.btnDisabled]}
|
<Button
|
||||||
|
title="Suivant"
|
||||||
|
icon="arrow-forward"
|
||||||
onPress={() => emotionId && setStep(2)}
|
onPress={() => emotionId && setStep(2)}
|
||||||
disabled={!emotionId}
|
disabled={!emotionId}
|
||||||
>
|
/>
|
||||||
<Text style={styles.nextBtnText}>Suivant</Text>
|
</View>
|
||||||
<Ionicons name="arrow-forward" size={20} color={Colors.white} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Intensity */}
|
{/* Step 2: Intensité */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<View>
|
<View>
|
||||||
<Text style={styles.stepTitle}>À quel point ressentez-vous cette émotion ?</Text>
|
<Text style={styles.stepTitle}>À quel point ressentez-vous cette émotion ?</Text>
|
||||||
@@ -72,37 +73,14 @@ export default function NewEntryScreen() {
|
|||||||
<Text style={styles.intensityValue}>{intensite}</Text>
|
<Text style={styles.intensityValue}>{intensite}</Text>
|
||||||
<Text style={styles.intensityLabel}>/10</Text>
|
<Text style={styles.intensityLabel}>/10</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.sliderContainer}>
|
<IntensityPicker value={intensite} onChange={setIntensite} />
|
||||||
<Text style={styles.sliderLabel}>Faible</Text>
|
<View style={styles.scaleLabels}>
|
||||||
<View style={styles.sliderWrapper}>
|
<Text style={styles.scaleLabel}>Faible</Text>
|
||||||
<View style={styles.nativeSlider}>
|
<Text style={styles.scaleLabel}>Forte</Text>
|
||||||
<View style={styles.sliderTrack}>
|
|
||||||
<View style={[styles.sliderFill, { width: `${(intensite - 1) * 11.11}%` }]} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.sliderButtons}>
|
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={v}
|
|
||||||
style={[styles.sliderDot, intensite === v && styles.sliderDotActive]}
|
|
||||||
onPress={() => setIntensite(v)}
|
|
||||||
>
|
|
||||||
<Text style={[styles.sliderDotText, intensite === v && styles.sliderDotTextActive]}>{v}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.sliderLabel}>Forte</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.navRow}>
|
<View style={styles.navRow}>
|
||||||
<TouchableOpacity style={styles.backBtn} onPress={() => setStep(1)}>
|
<Button title="Retour" variant="secondary" icon="arrow-back" onPress={() => setStep(1)} />
|
||||||
<Ionicons name="arrow-back" size={20} color={Colors.primary} />
|
<Button title="Suivant" icon="arrow-forward" onPress={() => setStep(3)} />
|
||||||
<Text style={styles.backBtnText}>Retour</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={styles.nextBtn} onPress={() => setStep(3)}>
|
|
||||||
<Text style={styles.nextBtnText}>Suivant</Text>
|
|
||||||
<Ionicons name="arrow-forward" size={20} color={Colors.white} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -111,28 +89,22 @@ export default function NewEntryScreen() {
|
|||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<View>
|
<View>
|
||||||
<Text style={styles.stepTitle}>Souhaitez-vous ajouter un commentaire ?</Text>
|
<Text style={styles.stepTitle}>Souhaitez-vous ajouter un commentaire ?</Text>
|
||||||
<TextInput
|
<TextField
|
||||||
style={styles.textArea}
|
label="Note (optionnel)"
|
||||||
value={note}
|
value={note}
|
||||||
onChangeText={setNote}
|
onChangeText={setNote}
|
||||||
placeholder="Décrivez ce que vous ressentez, ce qui a déclenché cette émotion..."
|
placeholder="Décrivez ce que vous ressentez, ce qui a déclenché cette émotion…"
|
||||||
multiline
|
multiline
|
||||||
numberOfLines={5}
|
numberOfLines={5}
|
||||||
textAlignVertical="top"
|
|
||||||
/>
|
/>
|
||||||
<View style={styles.navRow}>
|
<View style={styles.navRow}>
|
||||||
<TouchableOpacity style={styles.backBtn} onPress={() => setStep(2)}>
|
<Button title="Retour" variant="secondary" icon="arrow-back" onPress={() => setStep(2)} />
|
||||||
<Ionicons name="arrow-back" size={20} color={Colors.primary} />
|
<Button
|
||||||
<Text style={styles.backBtnText}>Retour</Text>
|
title="Enregistrer"
|
||||||
</TouchableOpacity>
|
icon="checkmark"
|
||||||
<TouchableOpacity
|
loading={createSaisie.isPending}
|
||||||
style={[styles.submitBtn, createSaisie.isPending && styles.btnDisabled]}
|
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
disabled={createSaisie.isPending}
|
/>
|
||||||
>
|
|
||||||
<Ionicons name="checkmark" size={20} color={Colors.white} />
|
|
||||||
<Text style={styles.nextBtnText}>{createSaisie.isPending ? 'Envoi...' : 'Enregistrer'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -140,46 +112,68 @@ export default function NewEntryScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sélecteur d'intensité 1–10, boutons ≥40px, sur deux rangées (wrap). */
|
||||||
|
export function IntensityPicker({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||||
|
return (
|
||||||
|
<View style={pickerStyles.grid}>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => {
|
||||||
|
const active = value === v;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={v}
|
||||||
|
style={[pickerStyles.dot, active && pickerStyles.dotActive]}
|
||||||
|
onPress={() => onChange(v)}
|
||||||
|
accessibilityLabel={`Intensité ${v}`}
|
||||||
|
>
|
||||||
|
<Text style={[pickerStyles.dotText, active && pickerStyles.dotTextActive]}>{v}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickerStyles = StyleSheet.create({
|
||||||
|
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.sm, justifyContent: 'center' },
|
||||||
|
dot: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: Radius.sm,
|
||||||
|
backgroundColor: Colors.gray[100],
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.gray[200],
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dotActive: { backgroundColor: Colors.primary, borderColor: Colors.primary },
|
||||||
|
dotText: { fontSize: FontSize.sm, fontWeight: FontWeight.bold, color: Colors.gray[500] },
|
||||||
|
dotTextActive: { color: Colors.white },
|
||||||
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: Colors.white },
|
container: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||||
content: { padding: 20, paddingBottom: 40 },
|
content: { padding: Spacing.lg, paddingBottom: Spacing.xxxl },
|
||||||
stepper: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginBottom: 32 },
|
stepper: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginBottom: Spacing.xxl },
|
||||||
stepRow: { flexDirection: 'row', alignItems: 'center' },
|
stepRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
stepCircle: {
|
stepCircle: {
|
||||||
width: 32, height: 32, borderRadius: 16, backgroundColor: Colors.gray[200],
|
width: 36,
|
||||||
justifyContent: 'center', alignItems: 'center',
|
height: 36,
|
||||||
|
borderRadius: Radius.pill,
|
||||||
|
backgroundColor: Colors.gray[200],
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
stepActive: { backgroundColor: Colors.primary },
|
stepActive: { backgroundColor: Colors.primary },
|
||||||
stepNumber: { fontSize: 14, fontWeight: '700', color: Colors.gray[500] },
|
stepNumber: { fontSize: FontSize.md, fontWeight: FontWeight.bold, color: Colors.gray[500] },
|
||||||
stepNumberActive: { color: Colors.white },
|
stepNumberActive: { color: Colors.white },
|
||||||
stepLine: { width: 40, height: 3, backgroundColor: Colors.gray[200], marginHorizontal: 4 },
|
stepLine: { width: 40, height: 3, backgroundColor: Colors.gray[200], marginHorizontal: Spacing.xs },
|
||||||
stepLineActive: { backgroundColor: Colors.primary },
|
stepLineActive: { backgroundColor: Colors.primary },
|
||||||
stepTitle: { fontSize: 20, fontWeight: '700', color: Colors.black, marginBottom: 20 },
|
stepTitle: { fontSize: FontSize.xl, fontWeight: FontWeight.bold, color: Colors.black, marginBottom: Spacing.xl },
|
||||||
intensityContainer: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'center', marginBottom: 24 },
|
intensityContainer: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'center', marginBottom: Spacing.xl },
|
||||||
intensityValue: { fontSize: 64, fontWeight: '800', color: Colors.black },
|
intensityValue: { fontSize: FontSize.display, fontWeight: FontWeight.heavy, color: Colors.primary, lineHeight: 60 },
|
||||||
intensityLabel: { fontSize: 24, color: Colors.gray[400], marginLeft: 4 },
|
intensityLabel: { fontSize: FontSize.xxl, color: Colors.gray[400], marginLeft: Spacing.xs },
|
||||||
sliderContainer: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 32 },
|
scaleLabels: { flexDirection: 'row', justifyContent: 'space-between', marginTop: Spacing.md, marginBottom: Spacing.xxl },
|
||||||
sliderLabel: { fontSize: 12, color: Colors.gray[400] },
|
scaleLabel: { fontSize: FontSize.xs, color: Colors.gray[500] },
|
||||||
sliderWrapper: { flex: 1 },
|
navRow: { flexDirection: 'row', justifyContent: 'space-between', gap: Spacing.md, marginTop: Spacing.md },
|
||||||
nativeSlider: {},
|
navRowEnd: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: Spacing.xl },
|
||||||
sliderTrack: { height: 6, backgroundColor: Colors.gray[200], borderRadius: 3, marginBottom: 8 },
|
|
||||||
sliderFill: { height: 6, backgroundColor: Colors.primary, borderRadius: 3 },
|
|
||||||
sliderButtons: { flexDirection: 'row', justifyContent: 'space-between' },
|
|
||||||
sliderDot: { width: 28, height: 28, borderRadius: 4, backgroundColor: Colors.gray[100], justifyContent: 'center', alignItems: 'center' },
|
|
||||||
sliderDotActive: { backgroundColor: Colors.primary },
|
|
||||||
sliderDotText: { fontSize: 12, fontWeight: '600', color: Colors.gray[500] },
|
|
||||||
sliderDotTextActive: { color: Colors.white },
|
|
||||||
textArea: {
|
|
||||||
backgroundColor: Colors.gray[100], borderTopLeftRadius: 4, borderTopRightRadius: 4,
|
|
||||||
borderBottomWidth: 2, borderBottomColor: Colors.black,
|
|
||||||
padding: 16, fontSize: 15, color: Colors.black,
|
|
||||||
minHeight: 120, marginBottom: 24,
|
|
||||||
},
|
|
||||||
navRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 16 },
|
|
||||||
backBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, paddingHorizontal: 20, borderRadius: 4, backgroundColor: Colors.white, borderWidth: 1, borderColor: Colors.primary },
|
|
||||||
backBtnText: { fontSize: 15, fontWeight: '600', color: Colors.primary },
|
|
||||||
nextBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, paddingHorizontal: 24, borderRadius: 4, backgroundColor: Colors.primary },
|
|
||||||
submitBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, paddingHorizontal: 24, borderRadius: 4, backgroundColor: Colors.primary },
|
|
||||||
nextBtnText: { fontSize: 15, fontWeight: '700', color: Colors.white },
|
|
||||||
btnDisabled: { opacity: 0.5 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-nati
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useRapport } from '@/hooks/useRapport';
|
import { useRapport } from '@/hooks/useRapport';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { Spacing, Radius, FontSize, FontWeight } from '@/lib/theme';
|
||||||
|
import { AppBar, Card, Badge, SectionTitle, EmptyState, Loader } from '@/components/ui';
|
||||||
import EmotionPieChart from '@/components/EmotionPieChart';
|
import EmotionPieChart from '@/components/EmotionPieChart';
|
||||||
|
|
||||||
type Period = 'week' | 'month' | 'quarter' | 'year';
|
type Period = 'week' | 'month' | 'quarter' | 'year';
|
||||||
@@ -19,60 +21,64 @@ export default function ReportsScreen() {
|
|||||||
const { data: rapport, isLoading } = useRapport(period);
|
const { data: rapport, isLoading } = useRapport(period);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<View style={styles.screen}>
|
||||||
|
<AppBar title="Mes rapports" subtitle="Analyse de vos émotions" />
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
{/* Period selector */}
|
{/* Sélecteur de période — boutons segmentés */}
|
||||||
<View style={styles.periodRow}>
|
<View style={styles.segment}>
|
||||||
{PERIODS.map((p) => (
|
{PERIODS.map((p) => {
|
||||||
|
const active = period === p.value;
|
||||||
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={p.value}
|
key={p.value}
|
||||||
style={[styles.periodBtn, period === p.value && styles.periodBtnActive]}
|
style={[styles.segmentBtn, active && styles.segmentBtnActive]}
|
||||||
onPress={() => setPeriod(p.value)}
|
onPress={() => setPeriod(p.value)}
|
||||||
|
accessibilityLabel={p.label}
|
||||||
>
|
>
|
||||||
<Text style={[styles.periodText, period === p.value && styles.periodTextActive]}>
|
<Text style={[styles.segmentText, active && styles.segmentTextActive]}>{p.label}</Text>
|
||||||
{p.label}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View style={styles.loading}>
|
<Loader label="Chargement du rapport…" />
|
||||||
<Text style={styles.loadingText}>Chargement...</Text>
|
) : rapport && rapport.stats.total_saisies > 0 ? (
|
||||||
</View>
|
|
||||||
) : rapport ? (
|
|
||||||
<>
|
<>
|
||||||
{/* Stats cards */}
|
{/* Stats */}
|
||||||
|
<Card padding={Spacing.lg} style={styles.statsCard}>
|
||||||
<View style={styles.statsRow}>
|
<View style={styles.statsRow}>
|
||||||
<View style={styles.statCard}>
|
<View style={styles.statCell}>
|
||||||
<Ionicons name="bar-chart" size={20} color={Colors.primary} />
|
<Ionicons name="bar-chart" size={20} color={Colors.primary} />
|
||||||
<Text style={styles.statValue}>{rapport.stats.total_saisies}</Text>
|
<Text style={styles.statValue}>{rapport.stats.total_saisies}</Text>
|
||||||
<Text style={styles.statLabel}>Saisies</Text>
|
<Text style={styles.statLabel}>Saisies</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.statCard}>
|
<View style={styles.statDivider} />
|
||||||
<Ionicons name="trending-up" size={20} color={Colors.secondary} />
|
<View style={styles.statCell}>
|
||||||
|
<Ionicons name="trending-up" size={20} color={Colors.primary} />
|
||||||
<Text style={styles.statValue}>{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}</Text>
|
<Text style={styles.statValue}>{rapport.stats.intensite_moyenne?.toFixed(1) || '—'}</Text>
|
||||||
<Text style={styles.statLabel}>Intensité moy.</Text>
|
<Text style={styles.statLabel}>Intensité moy.</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.statCard}>
|
<View style={styles.statDivider} />
|
||||||
<Ionicons name="heart" size={20} color={rapport.stats.emotion_dominante?.couleur || Colors.blueFranceSun} />
|
<View style={styles.statCell}>
|
||||||
<Text style={styles.statValue}>{rapport.stats.emotion_dominante?.icone || '—'}</Text>
|
<Text style={styles.statEmoji}>{rapport.stats.emotion_dominante?.icone || '—'}</Text>
|
||||||
<Text style={styles.statLabel}>{rapport.stats.emotion_dominante?.nom || 'Aucune'}</Text>
|
<Text style={styles.statLabel} numberOfLines={1}>
|
||||||
</View>
|
{rapport.stats.emotion_dominante?.nom || 'Aucune'}
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Period info */}
|
|
||||||
<View style={styles.periodInfo}>
|
|
||||||
<Text style={styles.periodInfoText}>
|
|
||||||
Du {new Date(rapport.date_debut).toLocaleDateString('fr-FR')} au {new Date(rapport.date_fin).toLocaleDateString('fr-FR')}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.periodInfo}>
|
||||||
|
Du {new Date(rapport.date_debut).toLocaleDateString('fr-FR')} au{' '}
|
||||||
|
{new Date(rapport.date_fin).toLocaleDateString('fr-FR')}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Repartition */}
|
{/* Répartition */}
|
||||||
{rapport.repartition.length > 0 && (
|
{rapport.repartition.length > 0 && (
|
||||||
<View style={styles.section}>
|
<View style={styles.block}>
|
||||||
<Text style={styles.sectionTitle}>Répartition des émotions</Text>
|
<SectionTitle>Répartition des émotions</SectionTitle>
|
||||||
|
|
||||||
{/* Camembert (donut) — équivalent mobile du graphique Recharts du web */}
|
<Card padding={Spacing.lg}>
|
||||||
<EmotionPieChart
|
<EmotionPieChart
|
||||||
data={rapport.repartition.map((item) => ({
|
data={rapport.repartition.map((item) => ({
|
||||||
nom: item.nom,
|
nom: item.nom,
|
||||||
@@ -81,68 +87,73 @@ export default function ReportsScreen() {
|
|||||||
pourcentage: item.pourcentage,
|
pourcentage: item.pourcentage,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Text style={styles.subSectionTitle}>Détail par émotion</Text>
|
<View style={styles.detailHeader}>
|
||||||
{rapport.repartition.map((item) => (
|
<SectionTitle>Détail par émotion</SectionTitle>
|
||||||
<View key={item.nom} style={styles.repartitionItem}>
|
</View>
|
||||||
<View style={styles.repartitionHeader}>
|
<Card padding={Spacing.lg}>
|
||||||
<View style={[styles.colorDot, { backgroundColor: item.couleur }]} />
|
{rapport.repartition.map((item, idx) => (
|
||||||
<Text style={styles.repartitionName}>{item.nom}</Text>
|
<View
|
||||||
<Text style={styles.repartitionPct}>{item.pourcentage}%</Text>
|
key={item.nom}
|
||||||
|
style={[styles.repItem, idx > 0 && styles.repItemBorder]}
|
||||||
|
>
|
||||||
|
<View style={styles.repHeader}>
|
||||||
|
<Badge label={item.nom} color={item.couleur} />
|
||||||
|
<Text style={styles.repPct}>{item.pourcentage}%</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.progressBar}>
|
<View style={styles.progressBar}>
|
||||||
<View style={[styles.progressFill, { width: `${item.pourcentage}%`, backgroundColor: item.couleur }]} />
|
<View
|
||||||
|
style={[styles.progressFill, { width: `${item.pourcentage}%`, backgroundColor: item.couleur }]}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.repartitionFooter}>
|
<View style={styles.repFooter}>
|
||||||
<Text style={styles.repartitionDetail}>{item.count} saisies</Text>
|
<Text style={styles.repDetail}>{item.count} saisies</Text>
|
||||||
<Text style={styles.repartitionDetail}>Moy: {item.intensite_moyenne.toFixed(1)}/10</Text>
|
<Text style={styles.repDetail}>Moy. {item.intensite_moyenne.toFixed(1)}/10</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
</Card>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.empty}>
|
<EmptyState
|
||||||
<Ionicons name="bar-chart-outline" size={48} color={Colors.gray[300]} />
|
icon="bar-chart-outline"
|
||||||
<Text style={styles.emptyText}>Pas de données pour cette période</Text>
|
title="Aucune donnée"
|
||||||
</View>
|
message="Pas de saisie pour cette période. Changez de période ou ajoutez une émotion."
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: Colors.white },
|
screen: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||||
content: { padding: 20, paddingBottom: 40 },
|
container: { flex: 1 },
|
||||||
periodRow: { flexDirection: 'row', backgroundColor: Colors.gray[100], borderRadius: 4, padding: 4, marginBottom: 20 },
|
content: { padding: Spacing.lg, paddingBottom: Spacing.xxxl },
|
||||||
periodBtn: { flex: 1, paddingVertical: 10, alignItems: 'center', borderRadius: 4 },
|
segment: { flexDirection: 'row', backgroundColor: Colors.gray[100], borderRadius: Radius.sm, padding: Spacing.xs, marginBottom: Spacing.lg },
|
||||||
periodBtnActive: { backgroundColor: Colors.primary },
|
segmentBtn: { flex: 1, paddingVertical: Spacing.sm, alignItems: 'center', borderRadius: Radius.sm, minHeight: 40, justifyContent: 'center' },
|
||||||
periodText: { fontSize: 13, fontWeight: '600', color: Colors.gray[500] },
|
segmentBtnActive: { backgroundColor: Colors.primary },
|
||||||
periodTextActive: { color: Colors.white },
|
segmentText: { fontSize: FontSize.xs, fontWeight: FontWeight.semibold, color: Colors.gray[500] },
|
||||||
loading: { padding: 40, alignItems: 'center' },
|
segmentTextActive: { color: Colors.white },
|
||||||
loadingText: { color: Colors.gray[400] },
|
statsCard: { marginBottom: Spacing.xl },
|
||||||
statsRow: { flexDirection: 'row', gap: 10, marginBottom: 16 },
|
statsRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
statCard: {
|
statCell: { flex: 1, alignItems: 'center', gap: Spacing.xs },
|
||||||
flex: 1, backgroundColor: Colors.gray[50], borderRadius: 4, padding: 16,
|
statDivider: { width: 1, alignSelf: 'stretch', backgroundColor: Colors.gray[200], marginVertical: Spacing.xs },
|
||||||
alignItems: 'center', gap: 6, borderWidth: 1, borderColor: Colors.gray[200],
|
statValue: { fontSize: FontSize.xxl, fontWeight: FontWeight.heavy, color: Colors.black },
|
||||||
},
|
statEmoji: { fontSize: FontSize.xxl },
|
||||||
statValue: { fontSize: 22, fontWeight: '800', color: Colors.black },
|
statLabel: { fontSize: FontSize.tiny, color: Colors.gray[500], textAlign: 'center' },
|
||||||
statLabel: { fontSize: 11, color: Colors.gray[500] },
|
periodInfo: { fontSize: FontSize.xs, color: Colors.gray[400], textAlign: 'center', marginTop: Spacing.md },
|
||||||
periodInfo: { marginBottom: 20 },
|
block: { marginBottom: Spacing.xl },
|
||||||
periodInfoText: { fontSize: 13, color: Colors.gray[400], textAlign: 'center' },
|
detailHeader: { marginTop: Spacing.lg },
|
||||||
section: { marginBottom: 24 },
|
repItem: { paddingVertical: Spacing.md },
|
||||||
sectionTitle: { fontSize: 18, fontWeight: '700', color: Colors.black, marginBottom: 16 },
|
repItemBorder: { borderTopWidth: 1, borderTopColor: Colors.gray[200] },
|
||||||
subSectionTitle: { fontSize: 14, fontWeight: '700', color: Colors.gray[600], marginBottom: 12, marginTop: 4 },
|
repHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: Spacing.sm },
|
||||||
repartitionItem: { marginBottom: 16 },
|
repPct: { fontSize: FontSize.sm, fontWeight: FontWeight.bold, color: Colors.black },
|
||||||
repartitionHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
|
progressBar: { height: 6, backgroundColor: Colors.gray[200], borderRadius: Radius.sm, marginBottom: Spacing.xs },
|
||||||
colorDot: { width: 10, height: 10, borderRadius: 5, marginRight: 8 },
|
progressFill: { height: 6, borderRadius: Radius.sm },
|
||||||
repartitionName: { flex: 1, fontSize: 14, fontWeight: '600', color: Colors.black },
|
repFooter: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||||
repartitionPct: { fontSize: 14, fontWeight: '700', color: Colors.black },
|
repDetail: { fontSize: FontSize.xs, color: Colors.gray[500] },
|
||||||
progressBar: { height: 6, backgroundColor: Colors.gray[200], borderRadius: 3, marginBottom: 4 },
|
|
||||||
progressFill: { height: 6, borderRadius: 3 },
|
|
||||||
repartitionFooter: { flexDirection: 'row', justifyContent: 'space-between' },
|
|
||||||
repartitionDetail: { fontSize: 12, color: Colors.gray[400] },
|
|
||||||
empty: { alignItems: 'center', paddingTop: 60 },
|
|
||||||
emptyText: { fontSize: 16, color: Colors.gray[400], marginTop: 12 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import { useRouter } from 'expo-router';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { FontSize, FontWeight, MIN_TOUCH, Radius, Spacing } from '@/lib/theme';
|
||||||
|
import { AppBar, Badge, Button, Card, SectionTitle } from '@/components/ui';
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
|
const isAdmin = user?.role?.nom === 'administrateur';
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
Alert.alert('Déconnexion', 'Voulez-vous vous déconnecter ?', [
|
Alert.alert('Déconnexion', 'Voulez-vous vous déconnecter ?', [
|
||||||
{ text: 'Annuler', style: 'cancel' },
|
{ text: 'Annuler', style: 'cancel' },
|
||||||
@@ -22,86 +26,132 @@ export default function ProfileScreen() {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initiales = `${user?.prenom?.[0] ?? ''}${user?.nom?.[0] ?? ''}`.toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<View style={styles.screen}>
|
||||||
|
<AppBar title="Profil" />
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
{/* Avatar & Name */}
|
{/* Avatar & identité */}
|
||||||
<View style={styles.profileHeader}>
|
<Card style={styles.profileCard}>
|
||||||
<View style={styles.avatar}>
|
<View style={styles.avatar}>
|
||||||
<Text style={styles.avatarText}>
|
<Text style={styles.avatarText}>{initiales}</Text>
|
||||||
{user?.prenom?.[0]}{user?.nom?.[0]}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.name}>{user?.prenom} {user?.nom}</Text>
|
<Text style={styles.name}>
|
||||||
|
{user?.prenom} {user?.nom}
|
||||||
|
</Text>
|
||||||
<Text style={styles.email}>{user?.email}</Text>
|
<Text style={styles.email}>{user?.email}</Text>
|
||||||
<View style={styles.roleBadge}>
|
<View style={styles.roleBadge}>
|
||||||
<Text style={styles.roleText}>{user?.role?.nom || 'Membre'}</Text>
|
<Badge label={user?.role?.nom ?? 'Membre'} tone="primary" />
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Menu items */}
|
{/* Entrée admin — visible uniquement pour les administrateurs */}
|
||||||
<View style={styles.menuSection}>
|
{isAdmin ? (
|
||||||
<Text style={styles.sectionTitle}>Compte</Text>
|
<View style={styles.section}>
|
||||||
<View style={styles.menuCard}>
|
<SectionTitle>Administration</SectionTitle>
|
||||||
|
<Card onPress={() => router.push('/admin')} style={styles.adminCard}>
|
||||||
|
<View style={styles.adminIcon}>
|
||||||
|
<Ionicons name="shield-checkmark" size={24} color={Colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.adminContent}>
|
||||||
|
<Text style={styles.adminTitle}>Espace administration</Text>
|
||||||
|
<Text style={styles.adminSubtitle}>Gérer les utilisateurs, contenus et émotions</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={Colors.gray[400]} />
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Menu compte */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<SectionTitle>Compte</SectionTitle>
|
||||||
|
<Card padding={0} style={styles.menuCard}>
|
||||||
<MenuItem icon="person-outline" label="Informations personnelles" />
|
<MenuItem icon="person-outline" label="Informations personnelles" />
|
||||||
<MenuItem icon="lock-closed-outline" label="Changer le mot de passe" />
|
<MenuItem icon="lock-closed-outline" label="Changer le mot de passe" />
|
||||||
<MenuItem icon="shield-checkmark-outline" label="Données personnelles (RGPD)" />
|
<MenuItem icon="shield-checkmark-outline" label="Données personnelles (RGPD)" last />
|
||||||
</View>
|
</Card>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.menuSection}>
|
{/* Menu application */}
|
||||||
<Text style={styles.sectionTitle}>Application</Text>
|
<View style={styles.section}>
|
||||||
<View style={styles.menuCard}>
|
<SectionTitle>Application</SectionTitle>
|
||||||
|
<Card padding={0} style={styles.menuCard}>
|
||||||
<MenuItem icon="information-circle-outline" label="À propos de CESIZen" />
|
<MenuItem icon="information-circle-outline" label="À propos de CESIZen" />
|
||||||
<MenuItem icon="document-text-outline" label="Conditions d'utilisation" />
|
<MenuItem icon="document-text-outline" label="Conditions d'utilisation" last />
|
||||||
</View>
|
</Card>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Déconnexion */}
|
||||||
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout}>
|
<Button title="Se déconnecter" variant="danger" icon="log-out-outline" fullWidth onPress={handleLogout} />
|
||||||
<Ionicons name="log-out-outline" size={20} color={Colors.error} />
|
|
||||||
<Text style={styles.logoutText}>Se déconnecter</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<Text style={styles.version}>CESIZen v1.0.0</Text>
|
<Text style={styles.version}>CESIZen v1.0.0</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MenuItem({ icon, label }: { icon: keyof typeof Ionicons.glyphMap; label: string }) {
|
function MenuItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
last = false,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
label: string;
|
||||||
|
last?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity style={styles.menuItem}>
|
<TouchableOpacity style={[styles.menuItem, last && styles.menuItemLast]} accessibilityRole="button">
|
||||||
<Ionicons name={icon} size={20} color={Colors.gray[600]} />
|
<Ionicons name={icon} size={20} color={Colors.gray[600]} />
|
||||||
<Text style={styles.menuLabel}>{label}</Text>
|
<Text style={styles.menuLabel}>{label}</Text>
|
||||||
<Ionicons name="chevron-forward" size={18} color={Colors.gray[300]} />
|
<Ionicons name="chevron-forward" size={18} color={Colors.gray[400]} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: Colors.white },
|
screen: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||||
content: { padding: 20, paddingBottom: 40 },
|
container: { flex: 1 },
|
||||||
profileHeader: { alignItems: 'center', marginBottom: 32, marginTop: 12 },
|
content: { padding: Spacing.lg, paddingBottom: Spacing.xxxl, gap: Spacing.xl },
|
||||||
|
profileCard: { alignItems: 'center', paddingVertical: Spacing.xl },
|
||||||
avatar: {
|
avatar: {
|
||||||
width: 80, height: 80, borderRadius: 4, backgroundColor: Colors.primary,
|
width: 80,
|
||||||
justifyContent: 'center', alignItems: 'center', marginBottom: 12,
|
height: 80,
|
||||||
|
borderRadius: Radius.sm,
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: Spacing.md,
|
||||||
},
|
},
|
||||||
avatarText: { fontSize: 28, fontWeight: '800', color: Colors.white },
|
avatarText: { fontSize: FontSize.display, fontWeight: FontWeight.heavy, color: Colors.white },
|
||||||
name: { fontSize: 22, fontWeight: '700', color: Colors.black },
|
name: { fontSize: FontSize.xl, fontWeight: FontWeight.bold, color: Colors.black },
|
||||||
email: { fontSize: 14, color: Colors.gray[500], marginTop: 4 },
|
email: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: Spacing.xs },
|
||||||
roleBadge: { backgroundColor: `${Colors.primary}15`, paddingHorizontal: 12, paddingVertical: 4, borderRadius: 4, marginTop: 8 },
|
roleBadge: { marginTop: Spacing.sm },
|
||||||
roleText: { fontSize: 12, fontWeight: '600', color: Colors.primary, textTransform: 'capitalize' },
|
section: { gap: Spacing.sm },
|
||||||
menuSection: { marginBottom: 24 },
|
adminCard: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md, padding: Spacing.lg },
|
||||||
sectionTitle: { fontSize: 13, fontWeight: '700', color: Colors.gray[500], textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 },
|
adminIcon: {
|
||||||
menuCard: { backgroundColor: Colors.white, borderRadius: 4, overflow: 'hidden', borderWidth: 1, borderColor: Colors.gray[200] },
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: Radius.sm,
|
||||||
|
backgroundColor: '#E3E3FD',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
adminContent: { flex: 1 },
|
||||||
|
adminTitle: { fontSize: FontSize.body, fontWeight: FontWeight.bold, color: Colors.black, marginBottom: 2 },
|
||||||
|
adminSubtitle: { fontSize: FontSize.xs, color: Colors.gray[500], lineHeight: 18 },
|
||||||
|
menuCard: { overflow: 'hidden' },
|
||||||
menuItem: {
|
menuItem: {
|
||||||
flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 15, paddingHorizontal: 16,
|
flexDirection: 'row',
|
||||||
borderBottomWidth: 1, borderBottomColor: Colors.gray[200],
|
alignItems: 'center',
|
||||||
|
gap: Spacing.md,
|
||||||
|
minHeight: MIN_TOUCH,
|
||||||
|
paddingVertical: Spacing.md,
|
||||||
|
paddingHorizontal: Spacing.lg,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: Colors.gray[200],
|
||||||
},
|
},
|
||||||
menuLabel: { flex: 1, fontSize: 15, color: Colors.black },
|
menuItemLast: { borderBottomWidth: 0 },
|
||||||
logoutBtn: {
|
menuLabel: { flex: 1, fontSize: FontSize.body, color: Colors.black },
|
||||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
version: { textAlign: 'center', fontSize: FontSize.xs, color: Colors.gray[400] },
|
||||||
paddingVertical: 16, borderRadius: 4, backgroundColor: Colors.errorBg,
|
|
||||||
},
|
|
||||||
logoutText: { fontSize: 15, fontWeight: '600', color: Colors.error },
|
|
||||||
version: { textAlign: 'center', fontSize: 12, color: Colors.gray[400], marginTop: 20 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Linking, Alert } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, Linking, Alert } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
|
||||||
|
import { AppBar, Button, Card } from '@/components/ui';
|
||||||
|
|
||||||
type Contact = {
|
type Contact = {
|
||||||
nom: string;
|
nom: string;
|
||||||
@@ -65,61 +67,78 @@ export default function UrgenceScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<View style={styles.screen}>
|
||||||
|
<AppBar title="Urgence" subtitle="Vous n'êtes pas seul(e)" />
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
<View style={styles.header}>
|
<Text style={styles.intro}>
|
||||||
<View style={styles.iconBadge}>
|
|
||||||
<Ionicons name="heart" size={20} color={Colors.error} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.headerTitle}>Vous n'êtes pas seul(e)</Text>
|
|
||||||
<Text style={styles.headerSubtitle}>
|
|
||||||
Si vous ou un proche traversez une période difficile, ces services sont là pour vous écouter.
|
Si vous ou un proche traversez une période difficile, ces services sont là pour vous écouter.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
|
||||||
|
|
||||||
{CONTACTS.map((c) => (
|
{CONTACTS.map((c) => (
|
||||||
<TouchableOpacity key={c.numero} style={styles.card} onPress={() => appeler(c.numero, c.nom)}>
|
<Card key={c.numero} style={styles.card}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
<View style={styles.cardIcon}>
|
<View style={styles.cardIcon}>
|
||||||
<Ionicons name="call" size={22} color={Colors.error} />
|
<Ionicons name="call" size={22} color={Colors.error} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.cardContent}>
|
<View style={styles.cardHeaderText}>
|
||||||
<Text style={styles.cardName}>{c.nom}</Text>
|
<Text style={styles.cardName}>{c.nom}</Text>
|
||||||
<Text style={styles.cardNumber}>{formatNumero(c.numero)}</Text>
|
<Text style={styles.cardNumber}>{formatNumero(c.numero)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
<Text style={styles.cardDescription}>{c.description}</Text>
|
<Text style={styles.cardDescription}>{c.description}</Text>
|
||||||
|
<View style={styles.cardAvailabilityRow}>
|
||||||
|
<Ionicons name="time-outline" size={14} color={Colors.gray[500]} />
|
||||||
<Text style={styles.cardAvailability}>{c.disponibilite}</Text>
|
<Text style={styles.cardAvailability}>{c.disponibilite}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={18} color={Colors.gray[300]} />
|
<Button
|
||||||
</TouchableOpacity>
|
title={`Appeler le ${formatNumero(c.numero)}`}
|
||||||
|
variant="danger"
|
||||||
|
icon="call"
|
||||||
|
fullWidth
|
||||||
|
onPress={() => appeler(c.numero, c.nom)}
|
||||||
|
style={styles.cardButton}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<View style={styles.warning}>
|
<Card style={styles.warning} padding={Spacing.lg}>
|
||||||
<Ionicons name="warning" size={18} color={Colors.error} />
|
<View style={styles.warningRow}>
|
||||||
|
<Ionicons name="warning" size={20} color={Colors.error} />
|
||||||
<Text style={styles.warningText}>
|
<Text style={styles.warningText}>
|
||||||
En cas d'urgence vitale, appelez immédiatement le <Text style={styles.bold}>15</Text> (SAMU) ou le <Text style={styles.bold}>112</Text>.
|
En cas d'urgence vitale, appelez immédiatement le <Text style={styles.bold}>15</Text> (SAMU) ou le{' '}
|
||||||
|
<Text style={styles.bold}>112</Text>.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</Card>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: Colors.white },
|
screen: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||||
content: { padding: 16, paddingBottom: 32 },
|
container: { flex: 1 },
|
||||||
header: { marginBottom: 20, alignItems: 'flex-start' },
|
content: { padding: Spacing.lg, paddingBottom: Spacing.xxl, gap: Spacing.md },
|
||||||
iconBadge: { width: 40, height: 40, borderRadius: 4, backgroundColor: Colors.errorBg, justifyContent: 'center', alignItems: 'center', marginBottom: 12 },
|
intro: { fontSize: FontSize.sm, color: Colors.gray[500], lineHeight: 20, marginBottom: Spacing.xs },
|
||||||
headerTitle: { fontSize: 22, fontWeight: '800', color: Colors.black, marginBottom: 6 },
|
card: { padding: Spacing.lg, gap: Spacing.sm },
|
||||||
headerSubtitle: { fontSize: 14, color: Colors.gray[500], lineHeight: 20 },
|
cardHeader: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md },
|
||||||
card: {
|
cardIcon: {
|
||||||
flexDirection: 'row', alignItems: 'center', gap: 14, padding: 16,
|
width: 44,
|
||||||
backgroundColor: Colors.white, borderRadius: 4, marginBottom: 10,
|
height: 44,
|
||||||
borderWidth: 1, borderColor: Colors.gray[200],
|
borderRadius: Radius.sm,
|
||||||
|
backgroundColor: Colors.errorBg,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
cardIcon: { width: 44, height: 44, borderRadius: 4, backgroundColor: Colors.errorBg, justifyContent: 'center', alignItems: 'center' },
|
cardHeaderText: { flex: 1 },
|
||||||
cardContent: { flex: 1 },
|
cardName: { fontSize: FontSize.md, fontWeight: FontWeight.bold, color: Colors.black, marginBottom: 2 },
|
||||||
cardName: { fontSize: 14, fontWeight: '700', color: Colors.black, marginBottom: 2 },
|
cardNumber: { fontSize: FontSize.xl, fontWeight: FontWeight.heavy, color: Colors.error },
|
||||||
cardNumber: { fontSize: 20, fontWeight: '800', color: Colors.error, marginBottom: 6 },
|
cardDescription: { fontSize: FontSize.xs, color: Colors.gray[600], lineHeight: 19 },
|
||||||
cardDescription: { fontSize: 12, color: Colors.gray[500], lineHeight: 17 },
|
cardAvailabilityRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.xs },
|
||||||
cardAvailability: { fontSize: 11, color: Colors.primary, marginTop: 4, fontWeight: '600' },
|
cardAvailability: { fontSize: FontSize.tiny, color: Colors.gray[500], fontWeight: FontWeight.semibold },
|
||||||
warning: { marginTop: 8, padding: 14, borderRadius: 4, backgroundColor: Colors.errorBg, borderWidth: 1, borderColor: Colors.error, flexDirection: 'row', gap: 10, alignItems: 'flex-start' },
|
cardButton: { marginTop: Spacing.xs },
|
||||||
warningText: { flex: 1, fontSize: 13, color: Colors.error, lineHeight: 19 },
|
warning: { backgroundColor: Colors.errorBg, borderColor: Colors.error },
|
||||||
bold: { fontWeight: '800' },
|
warningRow: { flexDirection: 'row', gap: Spacing.md, alignItems: 'flex-start' },
|
||||||
|
warningText: { flex: 1, fontSize: FontSize.xs, color: Colors.error, lineHeight: 19 },
|
||||||
|
bold: { fontWeight: FontWeight.heavy },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
import { View, Text, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
||||||
import { Link, useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
|
||||||
|
import { AppBar, Button, Card, TextField } from '@/components/ui';
|
||||||
|
|
||||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
@@ -28,20 +30,27 @@ export default function ForgotPasswordScreen() {
|
|||||||
if (submitted) {
|
if (submitted) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.success}>
|
<AppBar title="Demande enregistrée" />
|
||||||
<View style={styles.successIcon}>
|
<View style={styles.successWrap}>
|
||||||
|
<View style={styles.iconCircle}>
|
||||||
<Ionicons name="mail-open" size={32} color={Colors.primary} />
|
<Ionicons name="mail-open" size={32} color={Colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.successTitle}>Demande enregistrée</Text>
|
<Text style={styles.successTitle}>Vérifiez votre boîte mail</Text>
|
||||||
<Text style={styles.successText}>
|
<Text style={styles.successText}>
|
||||||
Si l'adresse <Text style={styles.bold}>{email}</Text> est associée à un compte CESIZen, un email contenant les instructions de réinitialisation sera envoyé.
|
Si l'adresse <Text style={styles.bold}>{email}</Text> est associée à un compte CESIZen,
|
||||||
|
un email contenant les instructions de réinitialisation sera envoyé.
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.successText}>
|
<Text style={styles.successText}>
|
||||||
Si vous ne recevez rien sous 24h, contactez votre administrateur.
|
Si vous ne recevez rien sous 24h, contactez votre administrateur.
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity style={styles.button} onPress={() => router.replace('/auth/login')}>
|
<View style={styles.successAction}>
|
||||||
<Text style={styles.buttonText}>Retour à la connexion</Text>
|
<Button
|
||||||
</TouchableOpacity>
|
title="Retour à la connexion"
|
||||||
|
icon="arrow-back-outline"
|
||||||
|
onPress={() => router.replace('/auth/login')}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -49,64 +58,64 @@ export default function ForgotPasswordScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
|
<AppBar title="Mot de passe oublié" subtitle="Réinitialisez votre accès" />
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||||
<View style={styles.header}>
|
<Text style={styles.intro}>
|
||||||
<Text style={styles.logo}>Mot de passe oublié ?</Text>
|
Entrez votre adresse email. Si elle est associée à un compte, vous recevrez les
|
||||||
<Text style={styles.subtitle}>
|
instructions pour réinitialiser votre mot de passe.
|
||||||
Entrez votre adresse email. Si elle est associée à un compte, vous recevrez les instructions pour réinitialiser votre mot de passe.
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.form}>
|
<Card padding={Spacing.xl} elevated>
|
||||||
<View style={styles.inputGroup}>
|
<TextField
|
||||||
<Text style={styles.label}>Email</Text>
|
label="Email"
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={email}
|
value={email}
|
||||||
onChangeText={(v) => { setEmail(v); setError(null); }}
|
onChangeText={(v) => { setEmail(v); setError(null); }}
|
||||||
placeholder="votre@email.com"
|
placeholder="votre@email.com"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoComplete="email"
|
||||||
|
error={error ?? undefined}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.button} onPress={handleSubmit}>
|
<Button
|
||||||
<Text style={styles.buttonText}>Envoyer les instructions</Text>
|
title="Envoyer les instructions"
|
||||||
</TouchableOpacity>
|
onPress={handleSubmit}
|
||||||
|
icon="paper-plane-outline"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={styles.backRow}>
|
||||||
<Link href="/auth/login" asChild>
|
<Button
|
||||||
<TouchableOpacity>
|
title="Retour à la connexion"
|
||||||
<Text style={styles.link}>Retour à la connexion</Text>
|
variant="tertiary"
|
||||||
</TouchableOpacity>
|
size="sm"
|
||||||
</Link>
|
onPress={() => router.replace('/auth/login')}
|
||||||
</View>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
</Card>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: Colors.white },
|
container: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||||
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: 24 },
|
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: Spacing.xl },
|
||||||
header: { alignItems: 'center', marginBottom: 32 },
|
intro: { fontSize: FontSize.sm, color: Colors.gray[600], lineHeight: 20, marginBottom: Spacing.xl, textAlign: 'center' },
|
||||||
logo: { fontSize: 26, fontWeight: '800', color: Colors.black, textAlign: 'center' },
|
backRow: { alignItems: 'center', marginTop: Spacing.sm },
|
||||||
subtitle: { fontSize: 14, color: Colors.gray[500], marginTop: 10, textAlign: 'center', lineHeight: 20 },
|
successWrap: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: Spacing.xl },
|
||||||
form: { backgroundColor: Colors.gray[50], borderRadius: 4, padding: 24, borderWidth: 1, borderColor: Colors.gray[200] },
|
iconCircle: {
|
||||||
inputGroup: { marginBottom: 16 },
|
width: 72,
|
||||||
label: { fontSize: 14, fontWeight: '600', color: Colors.black, marginBottom: 6 },
|
height: 72,
|
||||||
input: { backgroundColor: Colors.gray[100], borderTopLeftRadius: 4, borderTopRightRadius: 4, borderBottomWidth: 2, borderBottomColor: Colors.black, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black },
|
borderRadius: Radius.pill,
|
||||||
errorText: { color: Colors.error, marginTop: 6, fontSize: 12 },
|
backgroundColor: '#E3E3FD',
|
||||||
button: { backgroundColor: Colors.primary, borderRadius: 4, paddingVertical: 16, alignItems: 'center', marginTop: 8 },
|
alignItems: 'center',
|
||||||
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.white },
|
justifyContent: 'center',
|
||||||
footer: { alignItems: 'center', marginTop: 20 },
|
marginBottom: Spacing.lg,
|
||||||
link: { color: Colors.primary, fontSize: 14, fontWeight: '600', textDecorationLine: 'underline' },
|
},
|
||||||
success: { padding: 24, alignItems: 'center', flex: 1, justifyContent: 'center' },
|
successTitle: { fontSize: FontSize.xl, fontWeight: FontWeight.heavy, color: Colors.black, marginBottom: Spacing.md, textAlign: 'center' },
|
||||||
successIcon: { width: 72, height: 72, borderRadius: 999, backgroundColor: `${Colors.primary}15`, justifyContent: 'center', alignItems: 'center', marginBottom: 20 },
|
successText: { fontSize: FontSize.sm, color: Colors.gray[600], textAlign: 'center', lineHeight: 20, marginBottom: Spacing.md },
|
||||||
successTitle: { fontSize: 22, fontWeight: '800', color: Colors.black, marginBottom: 12 },
|
bold: { fontWeight: FontWeight.bold, color: Colors.black },
|
||||||
successText: { fontSize: 14, color: Colors.gray[600], textAlign: 'center', lineHeight: 20, marginBottom: 12 },
|
successAction: { alignSelf: 'stretch', marginTop: Spacing.lg },
|
||||||
bold: { fontWeight: '700', color: Colors.black },
|
|
||||||
});
|
});
|
||||||
|
|||||||
+75
-75
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
import { View, Text, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
||||||
import { Link, useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { getApiError } from '@/lib/api';
|
import { getApiError } from '@/lib/api';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
import RepubliqueHeader from '@/components/RepubliqueHeader';
|
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
|
||||||
|
import { AppBar, Button, Card, TextField } from '@/components/ui';
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -12,18 +14,22 @@ export default function LoginScreen() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
|
setError(null);
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
|
setError('Veuillez remplir tous les champs.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
router.replace('/(tabs)');
|
router.replace('/(tabs)');
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
Alert.alert('Erreur', getApiError(error).message || 'Identifiants incorrects');
|
const message = getApiError(err).message || 'Identifiants incorrects';
|
||||||
|
setError(message);
|
||||||
|
Alert.alert('Erreur', message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -31,62 +37,69 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
<RepubliqueHeader />
|
<AppBar title="Connexion" subtitle="Accédez à votre espace personnel" />
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||||
<View style={styles.header}>
|
<View style={styles.intro}>
|
||||||
<Text style={styles.logo}>CESIZen</Text>
|
<Text style={styles.welcome}>Bienvenue sur CESIZen</Text>
|
||||||
<Text style={styles.subtitle}>Votre compagnon de santé mentale</Text>
|
<Text style={styles.welcomeSub}>Votre compagnon de santé mentale.</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.form}>
|
<Card padding={Spacing.xl} elevated>
|
||||||
<Text style={styles.title}>Connexion</Text>
|
{error ? (
|
||||||
|
<View style={styles.errorBanner}>
|
||||||
|
<Ionicons name="alert-circle" size={18} color={Colors.error} />
|
||||||
|
<Text style={styles.errorBannerText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View style={styles.inputGroup}>
|
<TextField
|
||||||
<Text style={styles.label}>Email</Text>
|
label="Email"
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={email}
|
value={email}
|
||||||
onChangeText={setEmail}
|
onChangeText={(v) => { setEmail(v); setError(null); }}
|
||||||
placeholder="votre@email.com"
|
placeholder="votre@email.com"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoComplete="email"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.inputGroup}>
|
<TextField
|
||||||
<Text style={styles.label}>Mot de passe</Text>
|
label="Mot de passe"
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={(v) => { setPassword(v); setError(null); }}
|
||||||
placeholder="Votre mot de passe"
|
placeholder="Votre mot de passe"
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
|
autoComplete="password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Se connecter"
|
||||||
|
onPress={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
icon="log-in-outline"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.forgotRow}>
|
||||||
|
<Button
|
||||||
|
title="Mot de passe oublié ?"
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => router.push('/auth/forgot-password')}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
</Card>
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.button, loading && styles.buttonDisabled]}
|
|
||||||
onPress={handleLogin}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<Text style={styles.buttonText}>{loading ? 'Connexion...' : 'Se connecter'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<Link href="/auth/forgot-password" asChild>
|
|
||||||
<TouchableOpacity style={styles.forgotLink}>
|
|
||||||
<Text style={styles.link}>Mot de passe oublié ?</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<Text style={styles.footerText}>Pas encore de compte ?</Text>
|
<Text style={styles.footerText}>Pas encore de compte ?</Text>
|
||||||
<Link href="/auth/register" asChild>
|
<Button
|
||||||
<TouchableOpacity>
|
title="Créer un compte"
|
||||||
<Text style={styles.link}>S'inscrire</Text>
|
variant="secondary"
|
||||||
</TouchableOpacity>
|
onPress={() => router.push('/auth/register')}
|
||||||
</Link>
|
icon="person-add-outline"
|
||||||
</View>
|
fullWidth
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
@@ -94,37 +107,24 @@ export default function LoginScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: Colors.white },
|
container: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||||
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: 24 },
|
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: Spacing.xl },
|
||||||
header: { alignItems: 'center', marginBottom: 40 },
|
intro: { alignItems: 'center', marginBottom: Spacing.xl },
|
||||||
logo: { fontSize: 36, fontWeight: '800', color: Colors.primary },
|
welcome: { fontSize: FontSize.xxl, fontWeight: FontWeight.heavy, color: Colors.black, textAlign: 'center' },
|
||||||
subtitle: { fontSize: 16, color: Colors.gray[500], marginTop: 8 },
|
welcomeSub: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: Spacing.xs, textAlign: 'center' },
|
||||||
form: { backgroundColor: Colors.gray[50], borderRadius: 4, padding: 24, borderWidth: 1, borderColor: Colors.gray[200] },
|
errorBanner: {
|
||||||
title: { fontSize: 24, fontWeight: '700', color: Colors.black, marginBottom: 24 },
|
flexDirection: 'row',
|
||||||
inputGroup: { marginBottom: 16 },
|
|
||||||
label: { fontSize: 14, fontWeight: '600', color: Colors.black, marginBottom: 6 },
|
|
||||||
input: {
|
|
||||||
backgroundColor: Colors.gray[100],
|
|
||||||
borderTopLeftRadius: 4,
|
|
||||||
borderTopRightRadius: 4,
|
|
||||||
borderBottomWidth: 2,
|
|
||||||
borderBottomColor: Colors.black,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 14,
|
|
||||||
fontSize: 16,
|
|
||||||
color: Colors.black,
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
backgroundColor: Colors.primary,
|
|
||||||
borderRadius: 4,
|
|
||||||
paddingVertical: 16,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: 8,
|
gap: Spacing.sm,
|
||||||
|
backgroundColor: Colors.errorBg,
|
||||||
|
borderRadius: Radius.sm,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: Colors.error,
|
||||||
|
padding: Spacing.md,
|
||||||
|
marginBottom: Spacing.lg,
|
||||||
},
|
},
|
||||||
buttonDisabled: { opacity: 0.6 },
|
errorBannerText: { flex: 1, fontSize: FontSize.xs, color: Colors.error, fontWeight: FontWeight.medium },
|
||||||
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.white },
|
forgotRow: { alignItems: 'center', marginTop: Spacing.sm },
|
||||||
forgotLink: { alignItems: 'center', marginTop: 12 },
|
footer: { marginTop: Spacing.xl, alignItems: 'center' },
|
||||||
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 16 },
|
footerText: { fontSize: FontSize.sm, color: Colors.gray[500], marginBottom: Spacing.sm },
|
||||||
footerText: { color: Colors.gray[500], fontSize: 14 },
|
|
||||||
link: { color: Colors.primary, fontSize: 14, fontWeight: '600', textDecorationLine: 'underline' },
|
|
||||||
});
|
});
|
||||||
|
|||||||
+115
-76
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
import { View, Text, StyleSheet, Alert, KeyboardAvoidingView, Platform, ScrollView, Pressable } from 'react-native';
|
||||||
import { Link, useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { getApiError } from '@/lib/api';
|
import { getApiError } from '@/lib/api';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
import RepubliqueHeader from '@/components/RepubliqueHeader';
|
import { FontSize, FontWeight, MIN_TOUCH, Radius, Spacing } from '@/lib/theme';
|
||||||
|
import { AppBar, Button, Card, TextField } from '@/components/ui';
|
||||||
|
|
||||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
@@ -60,70 +61,112 @@ export default function RegisterScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const consentError = errors.consentement || errors.consentement_rgpd;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
<RepubliqueHeader />
|
<AppBar title="Inscription" subtitle="Créez votre compte CESIZen" />
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||||
<View style={styles.header}>
|
<Card padding={Spacing.xl} elevated>
|
||||||
<Text style={styles.logo}>CESIZen</Text>
|
<View style={styles.nameRow}>
|
||||||
<Text style={styles.subtitle}>Créez votre compte</Text>
|
<TextField
|
||||||
|
label="Nom"
|
||||||
|
value={nom}
|
||||||
|
onChangeText={setNom}
|
||||||
|
placeholder="Dupont"
|
||||||
|
autoCapitalize="words"
|
||||||
|
error={errors.nom}
|
||||||
|
required
|
||||||
|
style={styles.nameField}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Prénom"
|
||||||
|
value={prenom}
|
||||||
|
onChangeText={setPrenom}
|
||||||
|
placeholder="Jean"
|
||||||
|
autoCapitalize="words"
|
||||||
|
error={errors.prenom}
|
||||||
|
required
|
||||||
|
style={styles.nameField}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.form}>
|
<TextField
|
||||||
<Text style={styles.title}>Inscription</Text>
|
label="Email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="votre@email.com"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
error={errors.email}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
<View style={styles.row}>
|
<TextField
|
||||||
<View style={[styles.inputGroup, { flex: 1, marginRight: 8 }]}>
|
label="Mot de passe"
|
||||||
<Text style={styles.label}>Nom</Text>
|
value={password}
|
||||||
<TextInput style={styles.input} value={nom} onChangeText={setNom} placeholder="Dupont" />
|
onChangeText={setPassword}
|
||||||
{errors.nom && <Text style={styles.errorText}>{errors.nom}</Text>}
|
placeholder="Mot de passe"
|
||||||
</View>
|
secureTextEntry
|
||||||
<View style={[styles.inputGroup, { flex: 1, marginLeft: 8 }]}>
|
error={errors.password}
|
||||||
<Text style={styles.label}>Prénom</Text>
|
helper="Au moins 8 caractères"
|
||||||
<TextInput style={styles.input} value={prenom} onChangeText={setPrenom} placeholder="Jean" />
|
required
|
||||||
{errors.prenom && <Text style={styles.errorText}>{errors.prenom}</Text>}
|
/>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.inputGroup}>
|
<TextField
|
||||||
<Text style={styles.label}>Email</Text>
|
label="Confirmer le mot de passe"
|
||||||
<TextInput style={styles.input} value={email} onChangeText={setEmail} placeholder="votre@email.com" keyboardType="email-address" autoCapitalize="none" autoCorrect={false} />
|
value={passwordConfirmation}
|
||||||
{errors.email && <Text style={styles.errorText}>{errors.email}</Text>}
|
onChangeText={setPasswordConfirmation}
|
||||||
</View>
|
placeholder="Confirmer"
|
||||||
|
secureTextEntry
|
||||||
|
error={errors.passwordConfirmation}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
<View style={styles.inputGroup}>
|
<Pressable
|
||||||
<Text style={styles.label}>Mot de passe (min. 8 caractères)</Text>
|
style={styles.consentRow}
|
||||||
<TextInput style={styles.input} value={password} onChangeText={setPassword} placeholder="Mot de passe" secureTextEntry />
|
onPress={() => setConsentement(!consentement)}
|
||||||
{errors.password && <Text style={styles.errorText}>{errors.password}</Text>}
|
accessibilityRole="checkbox"
|
||||||
</View>
|
accessibilityState={{ checked: consentement }}
|
||||||
|
>
|
||||||
<View style={styles.inputGroup}>
|
<Ionicons
|
||||||
<Text style={styles.label}>Confirmer le mot de passe</Text>
|
name={consentement ? 'checkbox' : 'square-outline'}
|
||||||
<TextInput style={styles.input} value={passwordConfirmation} onChangeText={setPasswordConfirmation} placeholder="Confirmer" secureTextEntry />
|
size={24}
|
||||||
{errors.passwordConfirmation && <Text style={styles.errorText}>{errors.passwordConfirmation}</Text>}
|
color={consentement ? Colors.primary : Colors.gray[500]}
|
||||||
</View>
|
style={styles.consentIcon}
|
||||||
|
/>
|
||||||
<TouchableOpacity style={styles.consentRow} onPress={() => setConsentement(!consentement)} activeOpacity={0.7}>
|
|
||||||
<View style={[styles.checkbox, consentement && styles.checkboxChecked]}>
|
|
||||||
{consentement && <Ionicons name="checkmark" size={16} color={Colors.white} />}
|
|
||||||
</View>
|
|
||||||
<Text style={styles.consentText}>
|
<Text style={styles.consentText}>
|
||||||
J'accepte le traitement de mes données de bien-être conformément à la <Text style={styles.consentLink}>politique de confidentialité</Text> (RGPD).
|
J'accepte le traitement de mes données de bien-être conformément à la{' '}
|
||||||
|
<Text style={styles.consentLink}>politique de confidentialité</Text> (RGPD).
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
{errors.consentement && <Text style={styles.errorText}>{errors.consentement}</Text>}
|
{consentError ? (
|
||||||
{errors.consentement_rgpd && <Text style={styles.errorText}>{errors.consentement_rgpd}</Text>}
|
<View style={styles.consentErrorRow}>
|
||||||
|
<Ionicons name="alert-circle" size={14} color={Colors.error} />
|
||||||
|
<Text style={styles.consentErrorText}>{consentError}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TouchableOpacity style={[styles.button, loading && styles.buttonDisabled]} onPress={handleRegister} disabled={loading}>
|
<View style={styles.submit}>
|
||||||
<Text style={styles.buttonText}>{loading ? 'Inscription...' : 'S\'inscrire'}</Text>
|
<Button
|
||||||
</TouchableOpacity>
|
title="Créer mon compte"
|
||||||
|
onPress={handleRegister}
|
||||||
|
loading={loading}
|
||||||
|
icon="person-add-outline"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<Text style={styles.footerText}>Déjà un compte ?</Text>
|
<Text style={styles.footerText}>Déjà un compte ?</Text>
|
||||||
<Link href="/auth/login" asChild>
|
<Button
|
||||||
<TouchableOpacity><Text style={styles.link}>Se connecter</Text></TouchableOpacity>
|
title="Se connecter"
|
||||||
</Link>
|
variant="tertiary"
|
||||||
</View>
|
size="sm"
|
||||||
|
onPress={() => router.push('/auth/login')}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
@@ -131,27 +174,23 @@ export default function RegisterScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: Colors.white },
|
container: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||||
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: 24 },
|
scrollContent: { flexGrow: 1, justifyContent: 'center', padding: Spacing.xl },
|
||||||
header: { alignItems: 'center', marginBottom: 32 },
|
nameRow: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.md },
|
||||||
logo: { fontSize: 36, fontWeight: '800', color: Colors.primary },
|
nameField: { flexGrow: 1, flexBasis: 130 },
|
||||||
subtitle: { fontSize: 16, color: Colors.gray[500], marginTop: 8 },
|
consentRow: {
|
||||||
form: { backgroundColor: Colors.gray[50], borderRadius: 4, padding: 24, borderWidth: 1, borderColor: Colors.gray[200] },
|
flexDirection: 'row',
|
||||||
title: { fontSize: 24, fontWeight: '700', color: Colors.black, marginBottom: 24 },
|
alignItems: 'flex-start',
|
||||||
row: { flexDirection: 'row' },
|
gap: Spacing.sm,
|
||||||
inputGroup: { marginBottom: 16 },
|
paddingVertical: Spacing.sm,
|
||||||
label: { fontSize: 14, fontWeight: '600', color: Colors.black, marginBottom: 6 },
|
minHeight: MIN_TOUCH,
|
||||||
input: { backgroundColor: Colors.gray[100], borderTopLeftRadius: 4, borderTopRightRadius: 4, borderBottomWidth: 2, borderBottomColor: Colors.black, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: Colors.black },
|
},
|
||||||
errorText: { color: Colors.error, marginTop: 4, fontSize: 12 },
|
consentIcon: { marginTop: 1 },
|
||||||
consentRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 10, marginVertical: 12 },
|
consentText: { flex: 1, fontSize: FontSize.xs, color: Colors.black, lineHeight: 19 },
|
||||||
checkbox: { width: 22, height: 22, borderRadius: 4, borderWidth: 2, borderColor: Colors.black, backgroundColor: Colors.white, justifyContent: 'center', alignItems: 'center', marginTop: 1 },
|
consentLink: { color: Colors.primary, fontWeight: FontWeight.semibold, textDecorationLine: 'underline' },
|
||||||
checkboxChecked: { backgroundColor: Colors.primary, borderColor: Colors.primary },
|
consentErrorRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.xs, marginTop: Spacing.xs },
|
||||||
consentText: { flex: 1, fontSize: 13, color: Colors.black, lineHeight: 18 },
|
consentErrorText: { fontSize: FontSize.xs, color: Colors.error },
|
||||||
consentLink: { color: Colors.primary, fontWeight: '600', textDecorationLine: 'underline' },
|
submit: { marginTop: Spacing.lg },
|
||||||
button: { backgroundColor: Colors.primary, borderRadius: 4, paddingVertical: 16, alignItems: 'center', marginTop: 12 },
|
footer: { marginTop: Spacing.xl, alignItems: 'center' },
|
||||||
buttonDisabled: { opacity: 0.6 },
|
footerText: { fontSize: FontSize.sm, color: Colors.gray[500] },
|
||||||
buttonText: { fontSize: 16, fontWeight: '700', color: Colors.white },
|
|
||||||
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 },
|
|
||||||
footerText: { color: Colors.gray[500], fontSize: 14 },
|
|
||||||
link: { color: Colors.primary, fontSize: 14, fontWeight: '600', textDecorationLine: 'underline' },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useEmotions } from '@/hooks/useEmotions';
|
import { useEmotions } from '@/hooks/useEmotions';
|
||||||
import { Colors } from '@/lib/colors';
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { Spacing, Radius, FontSize, FontWeight, MIN_TOUCH, HIT_SLOP } from '@/lib/theme';
|
||||||
|
import { Loader } from '@/components/ui';
|
||||||
import type { Emotion } from '@/types';
|
import type { Emotion } from '@/types';
|
||||||
|
|
||||||
interface EmotionPickerProps {
|
interface EmotionPickerProps {
|
||||||
@@ -14,40 +17,29 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
|
|||||||
const [selectedParent, setSelectedParent] = useState<Emotion | null>(null);
|
const [selectedParent, setSelectedParent] = useState<Emotion | null>(null);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <Loader label="Chargement des émotions…" />;
|
||||||
<View style={styles.loading}>
|
|
||||||
<ActivityIndicator size="large" color={Colors.primary} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedParent?.enfants && selectedParent.enfants.length > 0) {
|
if (selectedParent?.enfants && selectedParent.enfants.length > 0) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<TouchableOpacity style={styles.backBtn} onPress={() => setSelectedParent(null)}>
|
<TouchableOpacity style={styles.backBtn} hitSlop={HIT_SLOP} onPress={() => setSelectedParent(null)}>
|
||||||
<Text style={styles.backText}>← Retour aux émotions</Text>
|
<Ionicons name="arrow-back" size={16} color={Colors.primary} />
|
||||||
|
<Text style={styles.backText}>Retour aux émotions</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.subTitle}>
|
<Text style={styles.subTitle}>
|
||||||
Précisez votre émotion de{' '}
|
Précisez votre émotion de{' '}
|
||||||
<Text style={{ color: selectedParent.couleur, fontWeight: '700' }}>{selectedParent.nom}</Text> :
|
<Text style={[styles.subTitleAccent, { color: selectedParent.couleur }]}>{selectedParent.nom}</Text> :
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.grid}>
|
<View style={styles.grid}>
|
||||||
{selectedParent.enfants.map((child) => {
|
{selectedParent.enfants.map((child) => (
|
||||||
const isSelected = value === child.id;
|
<EmotionTile
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={child.id}
|
key={child.id}
|
||||||
style={[
|
emotion={child}
|
||||||
styles.emotionCard,
|
selected={value === child.id}
|
||||||
isSelected && { borderColor: child.couleur, borderWidth: 2.5 },
|
|
||||||
]}
|
|
||||||
onPress={() => onChange(child.id)}
|
onPress={() => onChange(child.id)}
|
||||||
>
|
/>
|
||||||
<Text style={styles.emotionEmoji}>{child.icone || '🔵'}</Text>
|
))}
|
||||||
<Text style={styles.emotionName}>{child.nom}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -58,14 +50,12 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
|
|||||||
{emotions?.map((emotion) => {
|
{emotions?.map((emotion) => {
|
||||||
const hasChildren = emotion.enfants && emotion.enfants.length > 0;
|
const hasChildren = emotion.enfants && emotion.enfants.length > 0;
|
||||||
const isSelected = value === emotion.id || emotion.enfants?.some((e) => e.id === value);
|
const isSelected = value === emotion.id || emotion.enfants?.some((e) => e.id === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<EmotionTile
|
||||||
key={emotion.id}
|
key={emotion.id}
|
||||||
style={[
|
emotion={emotion}
|
||||||
styles.emotionCard,
|
selected={!!isSelected}
|
||||||
isSelected && { borderColor: emotion.couleur, borderWidth: 2.5 },
|
hasChildren={hasChildren}
|
||||||
]}
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
setSelectedParent(emotion);
|
setSelectedParent(emotion);
|
||||||
@@ -73,34 +63,62 @@ export default function EmotionPicker({ value, onChange }: EmotionPickerProps) {
|
|||||||
onChange(emotion.id);
|
onChange(emotion.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Text style={styles.emotionEmoji}>{emotion.icone || '🔵'}</Text>
|
|
||||||
<Text style={styles.emotionName}>{emotion.nom}</Text>
|
|
||||||
{hasChildren && <Text style={styles.arrow}>▼</Text>}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EmotionTile({
|
||||||
|
emotion,
|
||||||
|
selected,
|
||||||
|
hasChildren,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
emotion: Emotion;
|
||||||
|
selected: boolean;
|
||||||
|
hasChildren?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.tile,
|
||||||
|
selected && { borderColor: emotion.couleur, borderWidth: 2.5, backgroundColor: `${emotion.couleur}12` },
|
||||||
|
]}
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityLabel={emotion.nom}
|
||||||
|
>
|
||||||
|
<Text style={styles.tileEmoji}>{emotion.icone || '🔵'}</Text>
|
||||||
|
<Text style={styles.tileName} numberOfLines={2}>
|
||||||
|
{emotion.nom}
|
||||||
|
</Text>
|
||||||
|
{hasChildren && <Ionicons name="chevron-down" size={12} color={Colors.gray[400]} style={styles.chevron} />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
loading: { padding: 40, alignItems: 'center' },
|
backBtn: { flexDirection: 'row', alignItems: 'center', gap: Spacing.xs, marginBottom: Spacing.md, minHeight: MIN_TOUCH },
|
||||||
backBtn: { marginBottom: 12 },
|
backText: { fontSize: FontSize.sm, fontWeight: FontWeight.semibold, color: Colors.primary },
|
||||||
backText: { fontSize: 14, color: Colors.primary, textDecorationLine: 'underline' },
|
subTitle: { fontSize: FontSize.sm, color: Colors.gray[600], marginBottom: Spacing.md },
|
||||||
subTitle: { fontSize: 14, color: Colors.gray[600], marginBottom: 12 },
|
subTitleAccent: { fontWeight: FontWeight.bold },
|
||||||
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.md, justifyContent: 'space-between' },
|
||||||
emotionCard: {
|
tile: {
|
||||||
width: '30%',
|
width: '30%',
|
||||||
|
minHeight: 92,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
borderRadius: 4,
|
borderRadius: Radius.sm,
|
||||||
padding: 14,
|
paddingVertical: Spacing.md,
|
||||||
|
paddingHorizontal: Spacing.sm,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
justifyContent: 'center',
|
||||||
|
gap: Spacing.xs,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderColor: Colors.gray[200],
|
borderColor: Colors.gray[200],
|
||||||
},
|
},
|
||||||
emotionEmoji: { fontSize: 30 },
|
tileEmoji: { fontSize: 30 },
|
||||||
emotionName: { fontSize: 12, fontWeight: '600', color: Colors.black, textAlign: 'center' },
|
tileName: { fontSize: FontSize.xs, fontWeight: FontWeight.semibold, color: Colors.black, textAlign: 'center' },
|
||||||
arrow: { fontSize: 10, color: Colors.gray[400] },
|
chevron: { marginTop: 2 },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user