Files
haptika/src/features/coherence/MyCoherenceScreen.tsx
T
2026-07-31 16:20:10 +02:00

319 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CoherenceSessionDurationSeconds>(initialDurationSeconds);
const [running, setRunning] = useState(false);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [latestFrame, setLatestFrame] = useState<PPGFrameUpdate | null>(null);
const [scoreSnapshot, setScoreSnapshot] = useState<CoherenceScoreSnapshot | null>(null);
const [waveform, setWaveform] = useState<number[]>([]);
const [error, setError] = useState<string | null>(null);
const startFrameTimeRef = useRef<number | null>(null);
const startedAtRef = useRef<string>('');
const beatsRef = useRef<CoherenceBeat[]>([]);
const timelineRef = useRef<CoherenceScoreSnapshot[]>([]);
const lastScoreAtRef = useRef(-Infinity);
const smoothedScoreRef = useRef<number | null>(null);
const terminalRef = useRef(false);
const stopNative = useCallback(() => {
try { haptikaPPG.cancelMeasurement(); } catch { /* already stopped */ }
setRunning(false);
}, []);
const persistAndLeave = useCallback(async (completed: boolean): Promise<void> => {
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 (
<View style={[styles.root, {paddingTop: insets.top + spacing.lg}]}>
<Text style={styles.eyebrow}>BIOFEEDBACK RESPIRATOIRE</Text>
<Text style={styles.title}>Ma Cohérence</Text>
<Text style={styles.subtitle}>
La caméra estime votre pouls pendant qu’un rythme 5–5 guide votre respiration. Aucune image n’est enregistrée.
</Text>
<View style={styles.durationCard}>
<Text style={styles.cardLabel}>DURÉE DE LA SÉANCE</Text>
<View style={styles.segmented}>
{([180, 300] as const).map(value => (
<Pressable key={value} onPress={() => setDurationSeconds(value)} style={[styles.segment, durationSeconds === value && styles.segmentSelected]}>
<Text style={[styles.segmentText, durationSeconds === value && styles.segmentTextSelected]}>{value / 60} min</Text>
</Pressable>
))}
</View>
</View>
<View style={styles.notice}><Text style={styles.noticeText}>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.</Text></View>
{error !== null ? <Text style={styles.error}>{error}</Text> : null}
<PrimaryButton label="Commencer" onPress={start} />
<PrimaryButton label="Retour" variant="ghost" onPress={() => onCancelled({startedAt: '', completedAt: new Date().toISOString(), elapsedSeconds: 0, completed: false}, null)} />
</View>
);
}
return (
<View style={styles.root}>
<PPGCameraView
isActive
cameraPosition={cameraPosition}
torchEnabled={cameraPosition === 'back'}
lockOptics={latestFrame?.contactState === 'valid'}
onCameraStarted={() => undefined}
onFrameUpdate={onFrameUpdate}
onInterrupted={() => persistAndLeave(false)}
onError={handleCameraError}
/>
<View style={styles.canvas} />
<View style={[styles.sessionContent, {paddingTop: insets.top + spacing.lg, paddingBottom: insets.bottom + spacing.md}]}>
<View style={styles.sessionHeader}>
<View><Text style={styles.eyebrow}>MA COHÉRENCE · DIRECT</Text><Text style={styles.time}>{Math.max(0, Math.ceil(durationSeconds - elapsedSeconds))} s</Text></View>
<View style={styles.qualityPill}><View style={[styles.qualityDot, {backgroundColor: (scoreSnapshot?.quality ?? 0) >= 0.65 ? colors.good : colors.medium}]} /><Text style={styles.qualityText}>Signal {Math.round((scoreSnapshot?.quality ?? 0) * 100)} %</Text></View>
</View>
<View style={styles.orbStage}>
<View style={[styles.orbOuter, {transform: [{scale: orbScale}], opacity: 0.55 + (displayedScore ?? 0) / 220}]}>
<View style={styles.orbInner} />
</View>
<View style={styles.orbCopy}>
<Text style={styles.phase}>{inhaling ? 'Inspirez' : 'Expirez'}</Text>
<Text style={styles.phaseCount}>{Math.ceil(5 - (breathingPosition % 5))}</Text>
</View>
</View>
<View style={styles.scoreRow}>
<View><Text style={styles.cardLabel}>SYNCHRONISATION</Text><Text style={styles.score}>{displayedScore === null ? '—' : displayedScore}</Text></View>
<View style={styles.scoreCopy}><Text style={styles.scoreLabel}>{coherenceScoreLabel(displayedScore)}</Text><Text style={styles.bpm}>{scoreSnapshot?.bpm ?? '—'} BPM</Text></View>
</View>
<View style={styles.waveCard}>
<Svg width="100%" height={74} viewBox="0 0 320 74"><Path d={path} stroke={colors.accent} strokeWidth={2.2} fill="none" /></Svg>
<Text style={styles.waveCaption}>Variation optique indicative · pas un tracé ECG</Text>
</View>
<View style={styles.progressTrack}><View style={[styles.progressFill, {width: `${progress * 100}%`}]} /></View>
<Text style={styles.sessionHint}>{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é.'}</Text>
<PrimaryButton label="Arrêter" variant="secondary" onPress={() => persistAndLeave(false)} />
</View>
</View>
);
}
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'},
});