feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -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'},
|
||||
});
|
||||
Reference in New Issue
Block a user