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