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