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,242 @@
import React, {useState} from 'react';
import {Linking, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {colors, radii, spacing} from '../../../theme';
import {PrimaryButton} from '../components/PrimaryButton';
import type {
MeasurementState,
PPGProcessingResult,
SignalQualityIssue,
} from '../measurement/measurementTypes';
interface PPGFailureScreenProps {
state: MeasurementState;
message: string;
result?: PPGProcessingResult | null;
showOpenSettings?: boolean;
onRetry: () => void;
onBack: () => void;
}
const ISSUE_LABELS: Record<SignalQualityIssue, string> = {
contactInsufficient: 'contact caméra insuffisant',
contactInterrupted: 'contact interrompu',
durationInsufficient: 'durée utile insuffisante',
droppedFrames: 'images perdues',
timestampJitter: 'cadence caméra irrégulière',
longGap: 'interruption trop longue',
saturation: 'image trop lumineuse / saturée',
motionArtifacts: 'mouvements détectés',
lowPulsatileAmplitude: 'pulsations trop faibles',
lowSignalToNoiseRatio: 'signal trop bruité',
tooFewPeaks: 'trop peu de battements détectés',
tooManyRejectedPeaks: 'trop de battements rejetés',
tooManyRejectedIntervals: 'intervalles trop irréguliers',
methodDisagreement: 'estimations du rythme incohérentes',
channelInstability: 'couleur du signal instable',
nonFiniteProcessingResult: 'calcul du signal impossible',
};
const percent = (value: number): string => `${Math.round(value * 100)} %`;
function qualityLabel(result: PPGProcessingResult): string {
switch (result.quality.level) {
case 'good':
return 'bonne';
case 'medium':
return 'moyenne';
case 'poor':
return 'faible';
default:
return 'invalide';
}
}
export function PPGFailureScreen({
state,
message,
result = null,
showOpenSettings = false,
onRetry,
onBack,
}: PPGFailureScreenProps): React.JSX.Element {
const thermal = state === 'thermalStop';
const insets = useSafeAreaInsets();
const [showDiagnostic, setShowDiagnostic] = useState(false);
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.container,
{paddingTop: insets.top + spacing.lg},
]}
showsVerticalScrollIndicator={false}>
<View style={styles.icon}>
<Text style={styles.iconText}>{thermal ? '°' : '!'}</Text>
</View>
<Text style={styles.title}>
{thermal ? 'L’iPhone doit refroidir' : 'Mesure non exploitable'}
</Text>
<Text style={styles.message}>{message}</Text>
{!thermal && result !== null ? (
<Pressable
accessibilityRole="button"
accessibilityState={{expanded: showDiagnostic}}
onPress={() => setShowDiagnostic(value => !value)}
style={styles.diagnosticToggle}>
<Text style={styles.tipTitle}>Détails techniques</Text>
<Text style={styles.diagnosticChevron}>{showDiagnostic ? '−' : '+'}</Text>
</Pressable>
) : null}
{!thermal && result !== null && showDiagnostic ? (
<View style={styles.diagnosticCard}>
<Text style={styles.tipTitle}>Diagnostic de cette mesure</Text>
<Text style={styles.diagnosticLine}>
Qualité : {percent(result.quality.score)} ({qualityLabel(result)})
</Text>
<Text style={styles.diagnosticLine}>
Signal utile : {result.quality.usableDurationSeconds.toFixed(1)} s ·
mouvement : {percent(result.quality.motionArtifactRatio)}
</Text>
<Text style={styles.diagnosticLine}>
Saturation : {percent(result.quality.saturationRatio)} · pertes :{' '}
{percent(result.quality.droppedFrameRatio)}
</Text>
<Text style={styles.diagnosticLine}>
SNR :{' '}
{result.quality.signalToNoiseRatio === null
? 'indisponible'
: result.quality.signalToNoiseRatio.toFixed(1)}{' '}
· battements : {result.bpm.validPeakCount}
</Text>
<Text style={styles.diagnosticLine}>
Trames : {result.rawSampleCount} · rééchantillonnées :{' '}
{result.resampledSampleCount}
</Text>
{result.quality.issues.length > 0 ? (
<Text style={styles.issueText}>
Cause(s) :{' '}
{result.quality.issues.map(issue => ISSUE_LABELS[issue]).join(', ')}
</Text>
) : null}
</View>
) : null}
<View style={styles.tipCard}>
<Text style={styles.tipTitle}>Pour le prochain essai</Text>
<Text style={styles.tipText}>
Posez le doigt doucement, couvrez entièrement la caméra choisie et
gardez la main immobile. Avec la caméra arrière, couvrez aussi le flash.
</Text>
</View>
<PrimaryButton
label={thermal ? 'Réessayer plus tard' : 'Recommencer'}
onPress={onRetry}
/>
{showOpenSettings ? (
<PrimaryButton
label="Ouvrir les réglages de l’iPhone"
variant="secondary"
onPress={() => Linking.openSettings().catch(() => undefined)}
/>
) : null}
<PrimaryButton label="Retour" variant="ghost" onPress={onBack} />
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: {
flex: 1,
backgroundColor: colors.background,
},
container: {
flexGrow: 1,
padding: spacing.lg,
justifyContent: 'center',
backgroundColor: colors.background,
},
icon: {
width: 60,
height: 60,
borderRadius: 30,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: colors.poor,
backgroundColor: colors.accentSoft,
},
iconText: {
color: colors.poor,
fontSize: 27,
fontWeight: '900',
},
title: {
color: colors.text,
fontSize: 32,
lineHeight: 37,
fontWeight: '800',
marginTop: spacing.lg,
},
message: {
color: colors.muted,
fontSize: 15,
lineHeight: 22,
marginTop: spacing.sm,
},
diagnosticCard: {
padding: spacing.md,
borderRadius: radii.md,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.accent,
marginTop: spacing.sm,
},
diagnosticToggle: {
minHeight: 54,
marginTop: spacing.lg,
paddingHorizontal: spacing.md,
borderRadius: radii.md,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
diagnosticChevron: {
color: colors.accent,
fontSize: 25,
fontWeight: '300',
},
diagnosticLine: {
color: colors.text,
fontSize: 14,
lineHeight: 21,
marginTop: spacing.xs,
},
issueText: {
color: colors.poor,
fontSize: 14,
lineHeight: 20,
fontWeight: '700',
marginTop: spacing.sm,
},
tipCard: {
padding: spacing.md,
borderRadius: radii.md,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
marginVertical: spacing.lg,
},
tipTitle: {
color: colors.text,
fontWeight: '700',
},
tipText: {
color: colors.muted,
lineHeight: 20,
marginTop: spacing.xs,
},
});