Mobile app: - Révision flashcards : liste decks, session flip-card SM-2, couleurs harmonisées web - Génération flashcards depuis note (FlashcardSheet + route /api/mobile/flashcards/generate) - Audio Whisper : hook useAudioRecorder reécrit, MicButton avec erreurs - IA : AISheet (améliorer/clarifier/résumer), TitleSheet (titre automatique) - Suppression note (soft delete + confirmation Alert) - Note du jour : titre lisible + HTML (plus JSON TipTap brut) - Parser TipTap→HTML côté mobile (tipTapToHtml) - Icône 🎓 dans header note → génération flashcards - Endpoint flashcardGenerate dans config.ts Web fixes: - Bug flashcards groupées par carnet → deck par note (migration + schema) - Bug filtre 'cartes dues' ignoré (suppression fallback buildSessionQueue) - Suppression UI création deck manuelle (inutile) - Fix setViewType is not defined dans home-client.tsx Drag handle menu: - Fix : clearNodes() avant transformation (heading→liste/code/citation) - Ajout : option 'Texte' (paragraphe) dans Transformer en - Ajout : Monter / Descendre le bloc - Ajout : Copier le contenu du bloc - Fix : sous-menu hover stable (délai 200ms) - Fix : Supprimer en rouge via classe --danger (plus :first-child) - i18n : nouvelles clés dans 15 locales Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
238 lines
9.3 KiB
TypeScript
238 lines
9.3 KiB
TypeScript
import { useState, useCallback, useRef } from 'react'
|
|
import {
|
|
View, Text, StyleSheet, TouchableOpacity, ActivityIndicator,
|
|
Animated, ScrollView,
|
|
} from 'react-native'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { useLocalSearchParams, useRouter } from 'expo-router'
|
|
import { ArrowLeft, GraduationCap } from 'lucide-react-native'
|
|
import { C } from '@/lib/theme'
|
|
import { apiFetch } from '@/lib/api'
|
|
import { ENDPOINTS } from '@/lib/config'
|
|
import { useFocusEffect } from 'expo-router'
|
|
|
|
interface Card {
|
|
id: string
|
|
front: string
|
|
back: string
|
|
interval: number
|
|
type?: string
|
|
}
|
|
|
|
type SessionState = 'loading' | 'reviewing' | 'done' | 'error'
|
|
|
|
const GRADE_LABELS: { grade: 1 | 2 | 3 | 4; label: string; color: string; bg: string; border: string }[] = [
|
|
{ grade: 1, label: 'Oublié', color: '#dc2626', bg: 'rgba(239,68,68,0.10)', border: 'rgba(239,68,68,0.30)' },
|
|
{ grade: 2, label: 'Difficile',color: '#b45309', bg: 'rgba(245,158,11,0.10)', border: 'rgba(245,158,11,0.30)' },
|
|
{ grade: 3, label: 'Bien', color: '#047857', bg: 'rgba(16,185,129,0.10)', border: 'rgba(16,185,129,0.30)' },
|
|
{ grade: 4, label: 'Parfait', color: '#A47148', bg: 'rgba(164,113,72,0.10)', border: 'rgba(164,113,72,0.30)' },
|
|
]
|
|
|
|
export default function SessionScreen() {
|
|
const router = useRouter()
|
|
const params = useLocalSearchParams<{ deckId: string; deckName: string }>()
|
|
const { deckId, deckName } = params
|
|
|
|
const [state, setState] = useState<SessionState>('loading')
|
|
const [cards, setCards] = useState<Card[]>([])
|
|
const [index, setIndex] = useState(0)
|
|
const [flipped, setFlipped] = useState(false)
|
|
const [reviewed, setReviewed] = useState(0)
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
|
|
|
// Animation flip
|
|
const flipAnim = useRef(new Animated.Value(0)).current
|
|
const frontInterp = flipAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] })
|
|
const backInterp = flipAnim.interpolate({ inputRange: [0, 1], outputRange: ['180deg', '360deg'] })
|
|
|
|
const loadSession = useCallback(async () => {
|
|
if (!deckId) return
|
|
setState('loading')
|
|
setIndex(0)
|
|
setFlipped(false)
|
|
setReviewed(0)
|
|
flipAnim.setValue(0)
|
|
try {
|
|
const res = await apiFetch(ENDPOINTS.flashcardSession(deckId))
|
|
const data = await res.json()
|
|
if (!res.ok) throw new Error(data.error ?? `Erreur ${res.status}`)
|
|
setCards(data.cards ?? [])
|
|
setState(data.cards?.length === 0 ? 'done' : 'reviewing')
|
|
} catch (e: any) {
|
|
setErrorMsg(e.message ?? 'Erreur')
|
|
setState('error')
|
|
}
|
|
}, [deckId])
|
|
|
|
useFocusEffect(useCallback(() => { loadSession() }, [loadSession]))
|
|
|
|
const flip = () => {
|
|
if (flipped) return
|
|
setFlipped(true)
|
|
Animated.spring(flipAnim, { toValue: 1, useNativeDriver: true, friction: 8 }).start()
|
|
}
|
|
|
|
const grade = async (g: 1 | 2 | 3 | 4) => {
|
|
const card = cards[index]
|
|
if (!card) return
|
|
try {
|
|
await apiFetch(ENDPOINTS.flashcardReview, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ cardId: card.id, grade: g }),
|
|
})
|
|
} catch { /* silencieux — on avance quand même */ }
|
|
|
|
const next = index + 1
|
|
setReviewed((r) => r + 1)
|
|
if (next >= cards.length) {
|
|
setState('done')
|
|
} else {
|
|
setIndex(next)
|
|
setFlipped(false)
|
|
Animated.timing(flipAnim, { toValue: 0, duration: 0, useNativeDriver: true }).start()
|
|
}
|
|
}
|
|
|
|
const current = cards[index]
|
|
const progress = cards.length > 0 ? index / cards.length : 0
|
|
|
|
return (
|
|
<SafeAreaView style={s.safe}>
|
|
{/* Header */}
|
|
<View style={s.header}>
|
|
<TouchableOpacity onPress={() => router.back()} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
|
<ArrowLeft size={22} color={C.ink} />
|
|
</TouchableOpacity>
|
|
<Text style={s.headerTitle} numberOfLines={1}>{deckName ?? 'Révision'}</Text>
|
|
{state === 'reviewing' && (
|
|
<Text style={s.counter}>{index + 1}/{cards.length}</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Barre de progression */}
|
|
{state === 'reviewing' && (
|
|
<View style={s.progressBarWrap}>
|
|
<View style={[s.progressBarFill, { width: `${Math.round(progress * 100)}%` }]} />
|
|
</View>
|
|
)}
|
|
|
|
{/* Contenu */}
|
|
{state === 'loading' && (
|
|
<View style={s.center}>
|
|
<ActivityIndicator color={C.brand} size="large" />
|
|
</View>
|
|
)}
|
|
|
|
{state === 'error' && (
|
|
<View style={s.center}>
|
|
<Text style={s.errorTxt}>{errorMsg}</Text>
|
|
<TouchableOpacity onPress={loadSession} style={s.retryBtn}>
|
|
<Text style={s.retryTxt}>Réessayer</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{state === 'done' && (
|
|
<View style={s.center}>
|
|
<GraduationCap size={56} color={C.brand} />
|
|
<Text style={s.doneTitle}>Session terminée !</Text>
|
|
<Text style={s.doneSub}>
|
|
{reviewed > 0 ? `${reviewed} carte${reviewed > 1 ? 's' : ''} révisée${reviewed > 1 ? 's' : ''}` : 'Tout est à jour 🎉'}
|
|
</Text>
|
|
<TouchableOpacity onPress={() => router.back()} style={s.doneBtn}>
|
|
<Text style={s.doneBtnTxt}>Retour aux paquets</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{state === 'reviewing' && current && (
|
|
<View style={s.sessionWrap}>
|
|
{/* Carte flip */}
|
|
<TouchableOpacity activeOpacity={0.95} onPress={flip} style={s.cardWrap}>
|
|
{/* Face avant */}
|
|
<Animated.View style={[s.card, s.cardFront, { transform: [{ rotateY: frontInterp }] }]}>
|
|
<ScrollView contentContainerStyle={s.cardContent} showsVerticalScrollIndicator={false}>
|
|
<Text style={s.cardLabel}>Question</Text>
|
|
<Text style={s.cardText}>{current.front}</Text>
|
|
</ScrollView>
|
|
{!flipped && (
|
|
<View style={s.tapHint}>
|
|
<Text style={s.tapTxt}>Appuyez pour révéler la réponse</Text>
|
|
</View>
|
|
)}
|
|
</Animated.View>
|
|
|
|
{/* Face arrière */}
|
|
<Animated.View style={[s.card, s.cardBack, { transform: [{ rotateY: backInterp }] }]}>
|
|
<ScrollView contentContainerStyle={s.cardContent} showsVerticalScrollIndicator={false}>
|
|
<Text style={s.cardLabel}>Réponse</Text>
|
|
<Text style={s.cardText}>{current.back}</Text>
|
|
</ScrollView>
|
|
</Animated.View>
|
|
</TouchableOpacity>
|
|
|
|
{/* Boutons de note (visibles seulement après flip) */}
|
|
{flipped && (
|
|
<View style={s.gradeRow}>
|
|
{GRADE_LABELS.map(({ grade: g, label, color, bg, border }) => (
|
|
<TouchableOpacity
|
|
key={g}
|
|
style={[s.gradeBtn, { backgroundColor: bg, borderColor: border }]}
|
|
onPress={() => grade(g)}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Text style={[s.gradeNum, { color }]}>{g}</Text>
|
|
<Text style={[s.gradeLbl, { color }]}>{label}</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
const s = StyleSheet.create({
|
|
safe: { flex: 1, backgroundColor: C.paper },
|
|
header: {
|
|
flexDirection: 'row', alignItems: 'center', gap: 12,
|
|
paddingHorizontal: 20, paddingVertical: 14,
|
|
borderBottomWidth: 1, borderBottomColor: C.border,
|
|
},
|
|
headerTitle: { flex: 1, fontSize: 17, fontWeight: '700', color: C.ink },
|
|
counter: { fontSize: 13, color: C.concrete, fontWeight: '600' },
|
|
progressBarWrap: { height: 3, backgroundColor: C.border },
|
|
progressBarFill: { height: 3, backgroundColor: C.brand },
|
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
|
|
sessionWrap: { flex: 1, padding: 20, justifyContent: 'space-between' },
|
|
cardWrap: { flex: 1, marginBottom: 16 },
|
|
card: {
|
|
position: 'absolute', inset: 0,
|
|
borderRadius: 18, borderWidth: 1, borderColor: C.border,
|
|
backgroundColor: C.surface,
|
|
backfaceVisibility: 'hidden',
|
|
padding: 24,
|
|
},
|
|
cardFront: { zIndex: 1 },
|
|
cardBack: { backgroundColor: '#f0f7ff' },
|
|
cardContent: { flexGrow: 1, justifyContent: 'center', alignItems: 'center', paddingBottom: 48 },
|
|
cardLabel: { fontSize: 11, fontWeight: '700', color: C.brand, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 16 },
|
|
cardText: { fontSize: 20, color: C.ink, textAlign: 'center', lineHeight: 30, fontWeight: '500' },
|
|
tapHint: { position: 'absolute', bottom: 20, left: 0, right: 0, alignItems: 'center' },
|
|
tapTxt: { fontSize: 12, color: C.concrete, fontStyle: 'italic' },
|
|
gradeRow: { flexDirection: 'row', gap: 8 },
|
|
gradeBtn: {
|
|
flex: 1, alignItems: 'center', paddingVertical: 12, borderRadius: 14, borderWidth: 1.5,
|
|
},
|
|
gradeNum: { fontSize: 18, fontWeight: '800' },
|
|
gradeLbl: { fontSize: 10, fontWeight: '600', marginTop: 2 },
|
|
doneTitle: { fontSize: 24, fontWeight: '800', color: C.ink, marginTop: 20, marginBottom: 8 },
|
|
doneSub: { fontSize: 15, color: C.concrete, marginBottom: 32 },
|
|
doneBtn: { backgroundColor: C.brand, paddingHorizontal: 28, paddingVertical: 14, borderRadius: 14 },
|
|
doneBtnTxt: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
|
errorTxt: { fontSize: 15, color: '#e11d48', textAlign: 'center', marginBottom: 12 },
|
|
retryBtn: { backgroundColor: C.brand, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 },
|
|
retryTxt: { color: '#fff', fontWeight: '600' },
|
|
})
|