Files
haptika/src/features/ppg/screens/PPGFailureScreen.tsx
T
2026-07-31 16:20:10 +02:00

243 lines
7.2 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, {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,
},
});