import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {AppState, Pressable, StyleSheet, Text, View} from 'react-native'; import Svg, {Path} from 'react-native-svg'; import {haptikaPPG, type PPGFrameUpdate} from 'react-native-haptika-ppg'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {colors, radii, spacing} from '../../theme'; import {PPGCameraView} from '../ppg/camera/PPGCameraView'; import type {PPGCameraError} from '../ppg/camera/cameraTypes'; import {PrimaryButton} from '../ppg/components/PrimaryButton'; import { calculateCoherenceScore, coherenceScoreLabel, smoothCoherenceScore, } from './coherenceEngine'; import {coherenceSessionRepository} from './coherenceSessionRepository'; import type { CoherenceBeat, CoherenceScoreSnapshot, CoherenceSessionDurationSeconds, StoredCoherenceSession, } from './coherenceTypes'; export interface MyCoherenceSummary { startedAt: string; completedAt: string; elapsedSeconds: number; completed: boolean; } interface MyCoherenceScreenProps { cameraPosition: 'front' | 'back'; initialDurationSeconds?: CoherenceSessionDurationSeconds; beforeMeasurementId?: string | null; regulationSessionId?: string | null; onCompleted: (summary: MyCoherenceSummary, session: StoredCoherenceSession) => void; onCancelled: (summary: MyCoherenceSummary, session: StoredCoherenceSession | null) => void; } function waveformPath(values: readonly number[], width: number, height: number): string { if (values.length < 2) return ''; const finiteValues = values.filter(Number.isFinite); const minimum = Math.min(...finiteValues); const maximum = Math.max(...finiteValues); const range = Math.max(maximum - minimum, 0.00001); return values.map((value, index) => { const x = (index / (values.length - 1)) * width; const y = height - ((value - minimum) / range) * height; return `${index === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); } export function MyCoherenceScreen({ cameraPosition, initialDurationSeconds = 180, beforeMeasurementId = null, regulationSessionId = null, onCompleted, onCancelled, }: MyCoherenceScreenProps): React.JSX.Element { const insets = useSafeAreaInsets(); const [durationSeconds, setDurationSeconds] = useState(initialDurationSeconds); const [running, setRunning] = useState(false); const [elapsedSeconds, setElapsedSeconds] = useState(0); const [latestFrame, setLatestFrame] = useState(null); const [scoreSnapshot, setScoreSnapshot] = useState(null); const [waveform, setWaveform] = useState([]); const [error, setError] = useState(null); const startFrameTimeRef = useRef(null); const startedAtRef = useRef(''); const beatsRef = useRef([]); const timelineRef = useRef([]); const lastScoreAtRef = useRef(-Infinity); const smoothedScoreRef = useRef(null); const terminalRef = useRef(false); const stopNative = useCallback(() => { try { haptikaPPG.cancelMeasurement(); } catch { /* already stopped */ } setRunning(false); }, []); const persistAndLeave = useCallback(async (completed: boolean): Promise => { if (terminalRef.current) return; terminalRef.current = true; stopNative(); const completedAt = new Date().toISOString(); const summary: MyCoherenceSummary = { startedAt: startedAtRef.current || completedAt, completedAt, elapsedSeconds, completed, }; let stored: StoredCoherenceSession | null = null; try { stored = await coherenceSessionRepository.save({ regulationSessionId, beforeMeasurementId, cameraPosition, plannedDurationSeconds: durationSeconds, actualDurationSeconds: elapsedSeconds, startedAt: summary.startedAt, completedAt, completed, timeline: timelineRef.current, }); } catch { // A local persistence failure must not keep the camera active. } if (completed && stored !== null) onCompleted(summary, stored); else onCancelled(summary, stored); }, [beforeMeasurementId, cameraPosition, durationSeconds, elapsedSeconds, onCancelled, onCompleted, regulationSessionId, stopNative]); useEffect(() => { const subscription = AppState.addEventListener('change', next => { if (running && next !== 'active') persistAndLeave(false); }); return () => subscription.remove(); }, [persistAndLeave, running]); useEffect(() => stopNative, [stopNative]); const start = useCallback(() => { terminalRef.current = false; startFrameTimeRef.current = null; startedAtRef.current = new Date().toISOString(); beatsRef.current = []; timelineRef.current = []; lastScoreAtRef.current = -Infinity; smoothedScoreRef.current = null; setElapsedSeconds(0); setScoreSnapshot(null); setWaveform([]); setError(null); try { haptikaPPG.startMeasurement({ sessionPurpose: 'coherence', targetSampleRate: 30, minimumFrequencyHz: 0.7, maximumFrequencyHz: 3.5, stabilizationSeconds: 0, measurementSeconds: durationSeconds, emitEveryFrames: 3, minimumContactConfidence: 0.48, }); setRunning(true); } catch (caught) { setError(caught instanceof Error ? caught.message : 'Impossible de démarrer la caméra.'); } }, [durationSeconds]); const onFrameUpdate = useCallback((update: PPGFrameUpdate): void => { if (!running || terminalRef.current) return; setLatestFrame(update); const sessionStart = startFrameTimeRef.current ?? update.timestampSeconds; startFrameTimeRef.current = sessionStart; const elapsed = Math.max(0, update.timestampSeconds - sessionStart); setElapsedSeconds(elapsed); setWaveform(current => [...current.slice(-89), update.waveformValue]); update.acceptedBeats.forEach(beat => { if (beat.confidence >= 0.35 && Number.isFinite(beat.intervalMs)) { beatsRef.current.push({ timestampSeconds: beat.timestampSeconds, intervalMs: beat.intervalMs, confidence: beat.confidence, }); } }); const quality = Math.min( 1, Math.max(0, update.liveWindowQuality ?? update.contactConfidence * (1 - update.motionArtifactScore)), ); if (elapsed - lastScoreAtRef.current >= 2) { lastScoreAtRef.current = elapsed; const calculated = calculateCoherenceScore({ beats: beatsRef.current, nowSeconds: update.timestampSeconds, sessionStartedAtSeconds: sessionStart, quality, }); const smoothed = smoothCoherenceScore(smoothedScoreRef.current, calculated.score); smoothedScoreRef.current = smoothed; const point = {...calculated, score: smoothed}; timelineRef.current.push(point); setScoreSnapshot(point); } if (update.thermalState === 'serious' || update.thermalState === 'critical') { setError('L’iPhone a trop chauffé. La séance est arrêtée pour votre confort.'); persistAndLeave(false); return; } if (elapsed >= durationSeconds) persistAndLeave(true); }, [durationSeconds, persistAndLeave, running]); const handleCameraError = useCallback((cameraError: PPGCameraError): void => { setError(cameraError.message); persistAndLeave(false); }, [persistAndLeave]); const breathingPosition = elapsedSeconds % 10; const inhaling = breathingPosition < 5; const phaseProgress = inhaling ? breathingPosition / 5 : (breathingPosition - 5) / 5; const orbScale = inhaling ? 0.72 + 0.28 * phaseProgress : 1 - 0.28 * phaseProgress; const progress = Math.min(1, elapsedSeconds / durationSeconds); const displayedScore = scoreSnapshot?.score ?? null; const path = useMemo(() => waveformPath(waveform, 320, 74), [waveform]); if (!running) { return ( BIOFEEDBACK RESPIRATOIRE Ma Cohérence La caméra estime votre pouls pendant qu’un rythme 5–5 guide votre respiration. Aucune image n’est enregistrée. DURÉE DE LA SÉANCE {([180, 300] as const).map(value => ( setDurationSeconds(value)} style={[styles.segment, durationSeconds === value && styles.segmentSelected]}> {value / 60} min ))} Pendant la séance, Haptika ne produit aucune vibration afin de ne pas perturber le signal caméra. Le score est expérimental et n’est pas une mesure médicale. {error !== null ? {error} : null} onCancelled({startedAt: '', completedAt: new Date().toISOString(), elapsedSeconds: 0, completed: false}, null)} /> ); } return ( undefined} onFrameUpdate={onFrameUpdate} onInterrupted={() => persistAndLeave(false)} onError={handleCameraError} /> MA COHÉRENCE · DIRECT{Math.max(0, Math.ceil(durationSeconds - elapsedSeconds))} s = 0.65 ? colors.good : colors.medium}]} />Signal {Math.round((scoreSnapshot?.quality ?? 0) * 100)} % {inhaling ? 'Inspirez' : 'Expirez'} {Math.ceil(5 - (breathingPosition % 5))} SYNCHRONISATION{displayedScore === null ? '—' : displayedScore} {coherenceScoreLabel(displayedScore)}{scoreSnapshot?.bpm ?? '—'} BPM Variation optique indicative · pas un tracé ECG {scoreSnapshot?.status === 'poorSignal' ? 'Signal momentanément insuffisant — la séance continue.' : scoreSnapshot?.status === 'calibrating' ? 'Calibration en cours · premier score après environ 60 secondes.' : 'Synchronisation avec le rythme respiratoire guidé.'} persistAndLeave(false)} /> ); } const styles = StyleSheet.create({ root: {flex: 1, backgroundColor: colors.background, paddingHorizontal: spacing.lg, gap: spacing.lg}, canvas: {position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: colors.background}, eyebrow: {color: colors.accent, fontSize: 10, fontWeight: '900', letterSpacing: 1.8}, title: {color: colors.text, fontSize: 42, lineHeight: 48, fontWeight: '900'}, subtitle: {color: colors.muted, fontSize: 16, lineHeight: 24}, durationCard: {padding: spacing.md, gap: spacing.md, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface}, cardLabel: {color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.3}, segmented: {flexDirection: 'row', padding: 4, borderRadius: radii.md, backgroundColor: colors.background}, segment: {flex: 1, minHeight: 46, alignItems: 'center', justifyContent: 'center', borderRadius: radii.sm}, segmentSelected: {backgroundColor: colors.accent}, segmentText: {color: colors.muted, fontWeight: '800'}, segmentTextSelected: {color: colors.background}, notice: {padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surfaceElevated}, noticeText: {color: colors.muted, fontSize: 13, lineHeight: 20}, error: {color: colors.poor, fontSize: 14, lineHeight: 20}, sessionContent: {flex: 1, justifyContent: 'space-between', gap: spacing.sm}, sessionHeader: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start'}, time: {color: colors.text, fontSize: 17, fontWeight: '800', marginTop: 4, fontVariant: ['tabular-nums']}, qualityPill: {flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.sm, minHeight: 34, borderRadius: radii.pill, borderWidth: 1, borderColor: colors.border}, qualityDot: {width: 7, height: 7, borderRadius: 4}, qualityText: {color: colors.muted, fontSize: 12, fontVariant: ['tabular-nums']}, orbStage: {height: 260, alignItems: 'center', justifyContent: 'center'}, orbOuter: {width: 230, height: 230, borderRadius: 115, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.accent, backgroundColor: '#072443'}, orbInner: {width: 130, height: 130, borderRadius: 65, backgroundColor: colors.accentSoft, borderWidth: 2, borderColor: '#B8EEFF'}, orbCopy: {position: 'absolute', alignItems: 'center'}, phase: {color: colors.text, fontSize: 23, fontWeight: '800'}, phaseCount: {color: colors.accent, fontSize: 36, fontWeight: '300', fontVariant: ['tabular-nums']}, scoreRow: {flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between', paddingHorizontal: spacing.sm}, score: {color: colors.text, fontSize: 62, lineHeight: 66, fontWeight: '900', fontVariant: ['tabular-nums']}, scoreCopy: {alignItems: 'flex-end', paddingBottom: 8, maxWidth: '62%'}, scoreLabel: {color: colors.accent, fontSize: 15, lineHeight: 20, fontWeight: '800', textAlign: 'right'}, bpm: {color: colors.muted, marginTop: 4, fontSize: 13, fontVariant: ['tabular-nums']}, waveCard: {height: 112, paddingHorizontal: spacing.sm, paddingTop: spacing.sm, borderRadius: radii.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface}, waveCaption: {color: colors.muted, fontSize: 10, textAlign: 'center'}, progressTrack: {height: 5, borderRadius: 3, backgroundColor: colors.surfaceElevated, overflow: 'hidden'}, progressFill: {height: 5, borderRadius: 3, backgroundColor: colors.accent}, sessionHint: {color: colors.muted, fontSize: 12, lineHeight: 17, textAlign: 'center'}, });