Files
Momento/memento-mobile/app/revision/session.tsx
Antigravity 0fa8978395
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped
feat: mobile app complet + flashcards fixes + drag handle améliorations
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>
2026-05-29 18:49:40 +00:00

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' },
})