feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
@@ -0,0 +1,318 @@
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'},
});