From 2e96e07c7e093c0b43d780604aea927e1ae6d468 Mon Sep 17 00:00:00 2001 From: Azmog <80716388+AzmogEx@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:14:55 +0200 Subject: [PATCH] feat(mobile): fondation design system DSFR (theme + composants UI) + hooks admin + route admin Co-Authored-By: Claude Opus 4.8 (1M context) --- mobile/app/_layout.tsx | 1 + mobile/components/ui/AppBar.tsx | 78 ++++++++++++++++ mobile/components/ui/Badge.tsx | 56 ++++++++++++ mobile/components/ui/Button.tsx | 93 +++++++++++++++++++ mobile/components/ui/Card.tsx | 42 +++++++++ mobile/components/ui/EmptyState.tsx | 47 ++++++++++ mobile/components/ui/Loader.tsx | 18 ++++ mobile/components/ui/SectionTitle.tsx | 19 ++++ mobile/components/ui/TextField.tsx | 122 +++++++++++++++++++++++++ mobile/components/ui/index.ts | 9 ++ mobile/hooks/useAdmin.ts | 123 ++++++++++++++++++++++++++ mobile/lib/theme.ts | 66 ++++++++++++++ 12 files changed, 674 insertions(+) create mode 100644 mobile/components/ui/AppBar.tsx create mode 100644 mobile/components/ui/Badge.tsx create mode 100644 mobile/components/ui/Button.tsx create mode 100644 mobile/components/ui/Card.tsx create mode 100644 mobile/components/ui/EmptyState.tsx create mode 100644 mobile/components/ui/Loader.tsx create mode 100644 mobile/components/ui/SectionTitle.tsx create mode 100644 mobile/components/ui/TextField.tsx create mode 100644 mobile/components/ui/index.ts create mode 100644 mobile/hooks/useAdmin.ts create mode 100644 mobile/lib/theme.ts diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 862f1f7..d5cc56a 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -48,6 +48,7 @@ export default function RootLayout() { + diff --git a/mobile/components/ui/AppBar.tsx b/mobile/components/ui/AppBar.tsx new file mode 100644 index 0000000..3f36c92 --- /dev/null +++ b/mobile/components/ui/AppBar.tsx @@ -0,0 +1,78 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { Colors } from '@/lib/colors'; +import { FontSize, FontWeight, Spacing } from '@/lib/theme'; + +interface AppBarProps { + /** Titre de la page, affiché sous le bandeau institutionnel. */ + title?: string; + subtitle?: string; + /** Élément optionnel aligné à droite du bandeau (ex. bouton). */ + right?: React.ReactNode; +} + +/** + * Bandeau institutionnel République Française réutilisable, à placer en haut + * de chaque écran principal. Reprend le bloc-marque de l'État (DSFR) + le nom + * du service, avec un titre/sous-titre de page optionnel. + */ +export default function AppBar({ title, subtitle, right }: AppBarProps) { + return ( + + + + RÉPUBLIQUE + FRANÇAISE + + + CESIZen + Santé mentale + + {right ? {right} : null} + + {title ? ( + + {title} + {subtitle ? {subtitle} : null} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + backgroundColor: Colors.white, + borderBottomWidth: 1, + borderBottomColor: Colors.gray[200], + }, + brandRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: Spacing.lg, + paddingVertical: Spacing.md, + }, + marianne: { + paddingRight: Spacing.md, + marginRight: Spacing.md, + borderRightWidth: 1, + borderRightColor: Colors.gray[200], + }, + republique: { + fontSize: FontSize.tiny, + fontWeight: FontWeight.bold, + color: Colors.black, + letterSpacing: 0.5, + lineHeight: 13, + }, + serviceBlock: { flex: 1 }, + serviceName: { fontSize: FontSize.lg, fontWeight: FontWeight.heavy, color: Colors.primary }, + serviceTagline: { fontSize: FontSize.tiny, color: Colors.gray[500], marginTop: 1 }, + right: { marginLeft: Spacing.sm }, + titleRow: { + paddingHorizontal: Spacing.lg, + paddingBottom: Spacing.md, + paddingTop: Spacing.xs, + }, + title: { fontSize: FontSize.xxl, fontWeight: FontWeight.bold, color: Colors.black }, + subtitle: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: 2 }, +}); diff --git a/mobile/components/ui/Badge.tsx b/mobile/components/ui/Badge.tsx new file mode 100644 index 0000000..0aa1409 --- /dev/null +++ b/mobile/components/ui/Badge.tsx @@ -0,0 +1,56 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { Colors } from '@/lib/colors'; +import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme'; + +type Tone = 'neutral' | 'success' | 'error' | 'info' | 'warning' | 'primary'; + +interface BadgeProps { + label: string; + tone?: Tone; + /** Couleur personnalisée (ex. couleur d'une émotion) — prioritaire sur `tone`. */ + color?: string; + uppercase?: boolean; +} + +/** Pastille d'état DSFR, taille homogène. */ +export default function Badge({ label, tone = 'neutral', color, uppercase = false }: BadgeProps) { + const palette = color ? { bg: hexWithAlpha(color, 0.14), text: darken(color) } : TONES[tone]; + return ( + + + {label} + + + ); +} + +const TONES: Record = { + neutral: { bg: Colors.gray[100], text: Colors.gray[600] }, + success: { bg: Colors.successBg, text: Colors.success }, + error: { bg: Colors.errorBg, text: Colors.error }, + info: { bg: '#E8EDFF', text: Colors.info }, + warning: { bg: '#FFE9C7', text: Colors.warning }, + primary: { bg: '#E3E3FD', text: Colors.primary }, +}; + +/** Ajoute un canal alpha (0–1) à une couleur hex #RRGGBB. */ +function hexWithAlpha(hex: string, alpha: number): string { + const a = Math.round(alpha * 255).toString(16).padStart(2, '0'); + return `${hex}${a}`; +} + +/** Pour le texte sur fond teinté, on garde la couleur d'origine (lisible sur 14% d'alpha). */ +function darken(hex: string): string { + return hex; +} + +const styles = StyleSheet.create({ + badge: { + alignSelf: 'flex-start', + paddingHorizontal: Spacing.sm, + paddingVertical: 3, + borderRadius: Radius.sm, + }, + text: { fontSize: FontSize.tiny, fontWeight: FontWeight.bold }, + uppercase: { textTransform: 'uppercase', letterSpacing: 0.5 }, +}); diff --git a/mobile/components/ui/Button.tsx b/mobile/components/ui/Button.tsx new file mode 100644 index 0000000..ee859b8 --- /dev/null +++ b/mobile/components/ui/Button.tsx @@ -0,0 +1,93 @@ +import { ActivityIndicator, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '@/lib/colors'; +import { FontSize, FontWeight, MIN_TOUCH, Radius, Spacing } from '@/lib/theme'; + +type Variant = 'primary' | 'secondary' | 'danger' | 'tertiary'; +type Size = 'md' | 'sm'; + +interface ButtonProps { + title: string; + onPress?: () => void; + variant?: Variant; + size?: Size; + loading?: boolean; + disabled?: boolean; + /** Nom d'icône Ionicons affichée à gauche du libellé. */ + icon?: keyof typeof Ionicons.glyphMap; + fullWidth?: boolean; + style?: StyleProp; +} + +/** + * Bouton DSFR. 4 variantes : primary (bleu plein), secondary (contour), + * danger (rouge plein), tertiary (texte seul). Hauteur ≥ 44px (accessibilité). + */ +export default function Button({ + title, + onPress, + variant = 'primary', + size = 'md', + loading = false, + disabled = false, + icon, + fullWidth = false, + style, +}: ButtonProps) { + const isDisabled = disabled || loading; + const palette = VARIANTS[variant]; + const minHeight = size === 'sm' ? 40 : MIN_TOUCH; + const fontSize = size === 'sm' ? FontSize.sm : FontSize.md; + + return ( + [ + styles.base, + { + minHeight, + paddingVertical: size === 'sm' ? Spacing.sm : Spacing.md, + backgroundColor: palette.bg, + borderColor: palette.border, + borderWidth: palette.border === 'transparent' ? 0 : 1, + }, + fullWidth && styles.fullWidth, + pressed && !isDisabled && { opacity: 0.85 }, + isDisabled && styles.disabled, + style, + ]} + > + {loading ? ( + + ) : ( + + {icon && } + {title} + + )} + + ); +} + +const VARIANTS: Record = { + primary: { bg: Colors.primary, text: Colors.white, border: 'transparent' }, + secondary: { bg: Colors.white, text: Colors.primary, border: Colors.primary }, + danger: { bg: Colors.error, text: Colors.white, border: 'transparent' }, + tertiary: { bg: 'transparent', text: Colors.primary, border: 'transparent' }, +}; + +const styles = StyleSheet.create({ + base: { + borderRadius: Radius.sm, + paddingHorizontal: Spacing.xl, + alignItems: 'center', + justifyContent: 'center', + }, + fullWidth: { alignSelf: 'stretch', width: '100%' }, + disabled: { opacity: 0.45 }, + content: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm }, + label: { fontWeight: FontWeight.semibold }, +}); diff --git a/mobile/components/ui/Card.tsx b/mobile/components/ui/Card.tsx new file mode 100644 index 0000000..4f83a27 --- /dev/null +++ b/mobile/components/ui/Card.tsx @@ -0,0 +1,42 @@ +import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; +import { Colors } from '@/lib/colors'; +import { Radius, Shadow, Spacing } from '@/lib/theme'; + +interface CardProps { + children: React.ReactNode; + onPress?: () => void; + /** Padding interne (défaut lg = 16). Mettre 0 pour gérer soi-même. */ + padding?: number; + /** Légère ombre portée. */ + elevated?: boolean; + style?: StyleProp; +} + +/** Conteneur DSFR : fond blanc, bordure grise fine, coins peu arrondis. */ +export default function Card({ children, onPress, padding = Spacing.lg, elevated = false, style }: CardProps) { + const content = [styles.card, { padding }, elevated && Shadow.card, style]; + + if (onPress) { + return ( + [content, pressed && styles.pressed]} + > + {children} + + ); + } + + return {children}; +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: Colors.white, + borderWidth: 1, + borderColor: Colors.gray[200], + borderRadius: Radius.sm, + }, + pressed: { backgroundColor: Colors.gray[50] }, +}); diff --git a/mobile/components/ui/EmptyState.tsx b/mobile/components/ui/EmptyState.tsx new file mode 100644 index 0000000..c117d2b --- /dev/null +++ b/mobile/components/ui/EmptyState.tsx @@ -0,0 +1,47 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '@/lib/colors'; +import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme'; +import Button from './Button'; + +interface EmptyStateProps { + icon?: keyof typeof Ionicons.glyphMap; + title: string; + message?: string; + actionLabel?: string; + onAction?: () => void; +} + +/** État vide homogène : icône, titre, message et action optionnelle. */ +export default function EmptyState({ icon = 'document-text-outline', title, message, actionLabel, onAction }: EmptyStateProps) { + return ( + + + + + {title} + {message ? {message} : null} + {actionLabel && onAction ? ( + +