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
+832 -24
View File
@@ -1,44 +1,852 @@
/**
* Sample React Native App
* https://github.com/facebook/react-native
*
* @format
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, StatusBar, StyleSheet, View } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { NewAppScreen } from '@react-native/new-app-screen';
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native';
import { colors } from './src/theme';
import { OnboardingScreen } from './src/features/onboarding/OnboardingScreen';
import {
SafeAreaProvider,
useSafeAreaInsets,
} from 'react-native-safe-area-context';
onboardingRepository,
type OnboardingAnswers,
} from './src/features/onboarding/onboardingRepository';
import { CardiacMirrorScreen } from './src/features/cardiacMirror/CardiacMirrorScreen';
import { MyCoherenceScreen } from './src/features/coherence/MyCoherenceScreen';
import { coherenceSessionRepository } from './src/features/coherence/coherenceSessionRepository';
import {
HapticWaveExerciseScreen,
type HapticExerciseSummary,
} from './src/features/cardiacMirror/HapticWaveExerciseScreen';
import {
getRegulationProtocol,
recommendRegulationProtocol,
type HapticRegulationProtocolKind,
} from './src/features/cardiacMirror/regulationProtocols';
import { compareRegulationMeasurements } from './src/features/cardiacMirror/regulationComparison';
import { regulationSessionRepository } from './src/features/cardiacMirror/regulationSessionRepository';
import type {
RegulationComparison,
RegulationSession,
} from './src/features/cardiacMirror/regulationSessionTypes';
import { usePPGCamera } from './src/features/ppg/camera/usePPGCamera';
import { evaluateHealthKitContext } from './src/features/ppg/health/healthContextEvaluator';
import { healthKitService } from './src/features/ppg/health/healthKitService';
import type { HealthKitContext } from './src/features/ppg/health/healthTypes';
import { PPG_ALGORITHM_VERSION } from './src/features/ppg/measurement/measurementConfiguration';
import { usePPGMeasurement } from './src/features/ppg/measurement/usePPGMeasurement';
import { PPGFailureScreen } from './src/features/ppg/screens/PPGFailureScreen';
import { PPGHistoryScreen } from './src/features/ppg/screens/PPGHistoryScreen';
import { PPGMeasurementScreen } from './src/features/ppg/screens/PPGMeasurementScreen';
import { PPGPreparationScreen } from './src/features/ppg/screens/PPGPreparationScreen';
import { PPGResultScreen } from './src/features/ppg/screens/PPGResultScreen';
import { calculatePotentialStress } from './src/features/ppg/stress/potentialStressCalculator';
import type { BaselineCalculationResult } from './src/features/ppg/stress/baselineTypes';
import { baselineRepository } from './src/features/ppg/storage/baselineRepository';
import { acquisitionPreferencesRepository } from './src/features/ppg/storage/acquisitionPreferencesRepository';
import { measurementRepository } from './src/features/ppg/storage/measurementRepository';
import { settingsRepository } from './src/features/ppg/storage/settingsRepository';
import type {
DataRetentionPolicy,
MeasurementSelfReport,
StoredMeasurement,
} from './src/features/ppg/storage/storageTypes';
import {calculateWatchBaseline, type WatchBaselineV1} from './src/features/watch/watchBaseline';
import {watchRepository} from './src/features/watch/watchRepository';
import {watchSyncCoordinator} from './src/features/watch/watchService';
import type {
WatchConnectionState,
WatchMeasurementSummaryV1,
WatchRegulationSessionV1,
WatchSyncEnvelopeV1,
WatchTombstoneV1,
} from './src/features/watch/watchTypes';
function App() {
const isDarkMode = useColorScheme() === 'dark';
type AppView = 'measurement' | 'history' | 'cardiacMirror' | 'hapticExercise' | 'myCoherence';
type FinalizationState = 'idle' | 'saving' | 'failed';
type SelfReportState = 'idle' | 'saving' | 'saved' | 'failed';
type OnboardingStatus = 'loading' | 'required' | 'completed';
const EMPTY_WATCH_CONNECTION: WatchConnectionState = {
supported: false,
paired: false,
watchAppInstalled: false,
reachable: false,
activationState: 'notActivated',
latestEventId: null,
latestEventAt: null,
};
function createMeasurementId(): string {
return `ppg-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function App(): React.JSX.Element {
return (
<SafeAreaProvider>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<AppContent />
<StatusBar
barStyle="light-content"
translucent
backgroundColor="transparent"
/>
<SafeAreaView
style={styles.safeArea}
edges={['right', 'bottom', 'left']}>
<HaptikaApp />
</SafeAreaView>
</SafeAreaProvider>
);
}
function AppContent() {
const safeAreaInsets = useSafeAreaInsets();
function HaptikaApp(): React.JSX.Element {
const cameraPermission = usePPGCamera();
const measurement = usePPGMeasurement();
const [onboardingStatus, setOnboardingStatus] =
useState<OnboardingStatus>('loading');
const [onboardingAnswers, setOnboardingAnswers] = useState<
OnboardingAnswers | undefined
>();
const [appView, setAppView] = useState<AppView>('measurement');
const [includeHealthKit, setIncludeHealthKit] = useState(false);
const [sensitiveContact, setSensitiveContact] = useState(true);
const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>(
'front',
);
const [atRestSelection, setAtRestSelection] = useState<boolean | null>(null);
const [history, setHistory] = useState<StoredMeasurement[]>([]);
const [watchHistory, setWatchHistory] = useState<WatchMeasurementSummaryV1[]>([]);
const [watchRegulationSessions, setWatchRegulationSessions] = useState<WatchRegulationSessionV1[]>([]);
const [watchBaseline, setWatchBaseline] = useState<WatchBaselineV1 | null>(null);
const [watchConnection, setWatchConnection] = useState<WatchConnectionState>(EMPTY_WATCH_CONNECTION);
const [saveMindAndBodyWorkouts, setSaveMindAndBodyWorkouts] = useState(true);
const [retentionPolicy, setRetentionPolicy] =
useState<DataRetentionPolicy>('forever');
const [baselineProgress, setBaselineProgress] =
useState<BaselineCalculationResult | null>(null);
const [currentMeasurement, setCurrentMeasurement] =
useState<StoredMeasurement | null>(null);
const [exerciseProtocol, setExerciseProtocol] =
useState<HapticRegulationProtocolKind>('coherence55');
const [pendingProtocolIntent, setPendingProtocolIntent] =
useState<HapticRegulationProtocolKind | null>(null);
const [regulationBeforeMeasurement, setRegulationBeforeMeasurement] =
useState<StoredMeasurement | null>(null);
const [pendingRegulationSession, setPendingRegulationSession] =
useState<RegulationSession | null>(null);
const [pendingCoherenceSessionId, setPendingCoherenceSessionId] = useState<
string | null
>(null);
const [regulationComparison, setRegulationComparison] =
useState<RegulationComparison | null>(null);
const [regulationSessions, setRegulationSessions] = useState<
RegulationSession[]
>([]);
const [selfReportState, setSelfReportState] =
useState<SelfReportState>('idle');
const [finalizationState, setFinalizationState] =
useState<FinalizationState>('idle');
const finalizingResultRef = useRef(false);
const measurementAtRestRef = useRef(false);
const postRegulationMeasurementRef = useRef(false);
const refreshHistory = useCallback(async (): Promise<void> => {
setHistory(await measurementRepository.getAll());
}, []);
const refreshWatchData = useCallback(async (): Promise<void> => {
const [measurements, sessions] = await Promise.all([
watchRepository.getMeasurements(),
watchRepository.getRegulationSessions(),
]);
setWatchHistory(measurements);
setWatchRegulationSessions(sessions);
setWatchBaseline(calculateWatchBaseline(measurements));
setWatchConnection(await watchSyncCoordinator.connectionState());
}, []);
const refreshBaseline =
useCallback(async (): Promise<BaselineCalculationResult> => {
const result = await baselineRepository.calculate({
measurementHourLocal: new Date().getHours(),
cameraPosition,
});
setBaselineProgress(result);
return result;
}, [cameraPosition]);
const refreshRegulationSessions = useCallback(async (): Promise<void> => {
setRegulationSessions(await regulationSessionRepository.getAll());
}, []);
useEffect(() => {
onboardingRepository
.get()
.then(state => {
if (state !== null) {
setOnboardingAnswers(state);
}
setOnboardingStatus(
state?.completed === true ? 'completed' : 'required',
);
})
.catch(() => setOnboardingStatus('required'));
settingsRepository.get().then(settings => {
setIncludeHealthKit(settings.includeHealthKitContext);
setSaveMindAndBodyWorkouts(settings.saveMindAndBodyWorkouts);
watchSyncCoordinator.updatePreferences({
schemaVersion: 1,
saveMindAndBodyWorkouts: settings.saveMindAndBodyWorkouts,
protocolCatalogVersion: '1',
updatedAt: new Date().toISOString(),
});
});
acquisitionPreferencesRepository.get().then(preferences => {
setCameraPosition(preferences.cameraPosition);
setSensitiveContact(preferences.sensitiveContact);
});
refreshHistory();
refreshBaseline();
refreshRegulationSessions();
refreshWatchData();
watchSyncCoordinator.start(refreshWatchData);
measurementRepository.getRetentionPolicy().then(setRetentionPolicy);
const connectionTimer = setInterval(() => {
watchSyncCoordinator.connectionState().then(setWatchConnection).catch(() => undefined);
}, 2_000);
return () => {
clearInterval(connectionTimer);
watchSyncCoordinator.stop();
};
}, [refreshBaseline, refreshHistory, refreshRegulationSessions, refreshWatchData]);
useEffect(() => {
if (measurement.snapshot.value === 'cancelled') {
measurement.reset();
}
}, [measurement, measurement.snapshot.value]);
useEffect(() => {
const result = measurement.snapshot.context.result;
if (
measurement.snapshot.value !== 'completed' ||
result === null ||
finalizingResultRef.current ||
finalizationState !== 'idle' ||
currentMeasurement !== null
) {
return;
}
finalizingResultRef.current = true;
setFinalizationState('saving');
(async (): Promise<void> => {
let healthContext: HealthKitContext | null = null;
let contextConsistency = 0.5;
if (
measurement.snapshot.context.healthPermission === 'requestCompleted'
) {
try {
healthContext = await healthKitService.fetchContext(14);
if (healthContext !== null) {
const evaluation = evaluateHealthKitContext(healthContext);
if (evaluation.hasUsableContext) {
contextConsistency = evaluation.contextConfidence;
}
}
} catch {
healthContext = null;
}
}
const baselineResult = await baselineRepository.calculate({
measurementHourLocal: new Date().getHours(),
cameraPosition,
});
const potentialStress = calculatePotentialStress({
atRest: measurementAtRestRef.current,
bpm: result.bpm.bpm,
estimatedRmssdMs: result.prv.estimatedRmssdMs,
prvReliable: result.prv.reliable,
validIntervalCount: result.prv.validIntervalCount,
signalQuality: result.quality.score,
usableDurationSeconds: result.quality.usableDurationSeconds,
peakConfidence: result.bpm.confidence,
methodAgreementScore: result.quality.methodAgreementScore,
contextConsistency,
baseline: baselineResult.baseline,
});
const record: StoredMeasurement = {
schemaVersion: 1,
algorithmVersion: PPG_ALGORITHM_VERSION,
id: createMeasurementId(),
acquisition: {
cameraPosition,
torchUsed: cameraPosition === 'back',
},
measuredAt: new Date().toISOString(),
atRest: measurementAtRestRef.current,
baselineEligible:
measurementAtRestRef.current && !postRegulationMeasurementRef.current,
cameraResult: result,
potentialStress,
healthKitContext: healthContext,
selfReport: null,
};
await measurementRepository.add(record);
if (
pendingRegulationSession !== null &&
regulationBeforeMeasurement !== null
) {
try {
await regulationSessionRepository.complete(
pendingRegulationSession.id,
record.id,
record.measuredAt,
);
if (pendingCoherenceSessionId !== null) {
await coherenceSessionRepository.attachAfterMeasurement(
pendingCoherenceSessionId,
record.id,
);
}
await refreshRegulationSessions();
} catch {
// The physiological result remains usable even if the local journey
// link cannot be persisted. Never discard a completed measurement.
}
setRegulationComparison(
compareRegulationMeasurements(regulationBeforeMeasurement, record),
);
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
}
await refreshBaseline();
setCurrentMeasurement(record);
if (pendingProtocolIntent !== null && pendingRegulationSession === null) {
setExerciseProtocol(pendingProtocolIntent);
setRegulationBeforeMeasurement(record);
setPendingProtocolIntent(null);
setAppView('hapticExercise');
}
setSelfReportState('idle');
refreshHistory().catch(() => undefined);
})()
.catch(() => {
setFinalizationState('failed');
})
.finally(() => {
finalizingResultRef.current = false;
});
}, [
currentMeasurement,
cameraPosition,
finalizationState,
measurement.snapshot.context.healthPermission,
measurement.snapshot.context.result,
measurement.snapshot.value,
pendingRegulationSession,
pendingCoherenceSessionId,
pendingProtocolIntent,
regulationBeforeMeasurement,
refreshHistory,
refreshBaseline,
refreshRegulationSessions,
]);
const handleHealthKitPreference = useCallback((value: boolean): void => {
setIncludeHealthKit(value);
settingsRepository.setIncludeHealthKitContext(value);
}, []);
const handleSaveMindAndBodyPreference = useCallback((value: boolean): void => {
setSaveMindAndBodyWorkouts(value);
settingsRepository.setSaveMindAndBodyWorkouts(value).catch(() => undefined);
watchSyncCoordinator.updatePreferences({
schemaVersion: 1,
saveMindAndBodyWorkouts: value,
protocolCatalogVersion: '1',
updatedAt: new Date().toISOString(),
});
}, []);
const handleOnboardingHealthKit = useCallback(async (): Promise<boolean> => {
if (!healthKitService.isAvailable()) {
return false;
}
try {
const accepted = await healthKitService.requestReadAuthorization();
if (accepted) {
setIncludeHealthKit(true);
await settingsRepository.setIncludeHealthKitContext(true);
}
return accepted;
} catch {
return false;
}
}, []);
const handleCameraPositionPreference = useCallback(
(value: 'back' | 'front'): void => {
setCameraPosition(value);
acquisitionPreferencesRepository
.set({ cameraPosition: value, sensitiveContact })
.catch(() => undefined);
},
[sensitiveContact],
);
const handleSensitiveContactPreference = useCallback(
(value: boolean): void => {
setSensitiveContact(value);
acquisitionPreferencesRepository
.set({ cameraPosition, sensitiveContact: value })
.catch(() => undefined);
},
[cameraPosition],
);
const startMeasurement = useCallback((atRestOverride?: boolean): void => {
const selectedAtRest = atRestOverride ?? atRestSelection;
if (selectedAtRest === null) return;
setAppView('measurement');
setCurrentMeasurement(null);
setSelfReportState('idle');
setFinalizationState('idle');
finalizingResultRef.current = false;
measurementAtRestRef.current = selectedAtRest;
postRegulationMeasurementRef.current = pendingRegulationSession !== null;
if (pendingRegulationSession === null) {
setRegulationComparison(null);
setRegulationBeforeMeasurement(null);
}
measurement.start({
includeHealthKitContext: includeHealthKit,
sensitiveContact,
requestCameraPermission: cameraPermission.requestCameraPermission,
});
}, [
cameraPermission.requestCameraPermission,
atRestSelection,
includeHealthKit,
measurement,
pendingRegulationSession,
sensitiveContact,
]);
const restartMeasurement = useCallback((): void => {
measurement.reset();
setCurrentMeasurement(null);
setAtRestSelection(null);
setFinalizationState('idle');
finalizingResultRef.current = false;
postRegulationMeasurementRef.current = false;
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
setRegulationBeforeMeasurement(null);
setRegulationComparison(null);
}, [measurement]);
const retryMeasurement = useCallback((): void => {
setCurrentMeasurement(null);
finalizingResultRef.current = false;
measurement.retry();
if (measurement.snapshot.context.cameraPermission !== 'granted') {
Promise.resolve().then(() => startMeasurement());
}
}, [measurement, startMeasurement]);
const backToPreparation = useCallback((): void => {
measurement.reset();
setCurrentMeasurement(null);
setAtRestSelection(null);
setFinalizationState('idle');
setSelfReportState('idle');
setAppView('measurement');
finalizingResultRef.current = false;
postRegulationMeasurementRef.current = false;
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
setRegulationBeforeMeasurement(null);
setRegulationComparison(null);
}, [measurement]);
const startRegulationJourney = useCallback(
(protocolKind: HapticRegulationProtocolKind): void => {
if (currentMeasurement === null) return;
setExerciseProtocol(protocolKind);
setRegulationBeforeMeasurement(currentMeasurement);
setRegulationComparison(null);
setAppView('hapticExercise');
},
[currentMeasurement],
);
const completeRegulationExercise = useCallback(
(summary: HapticExerciseSummary, coherenceSessionId?: string): void => {
if (regulationBeforeMeasurement === null || !summary.completed) return;
(async (): Promise<void> => {
const created = await regulationSessionRepository.create({
protocolKind: summary.protocolKind,
beforeMeasurementId: regulationBeforeMeasurement.id,
startedAt: summary.startedAt,
});
if (coherenceSessionId !== undefined) {
await coherenceSessionRepository.attachRegulationSession(
coherenceSessionId,
created.id,
);
setPendingCoherenceSessionId(coherenceSessionId);
} else {
setPendingCoherenceSessionId(null);
}
const awaitingAfter =
await regulationSessionRepository.markAwaitingAfter(
created.id,
summary.completedAt,
);
await refreshRegulationSessions();
setPendingRegulationSession(awaitingAfter);
measurement.reset();
setCurrentMeasurement(null);
setAtRestSelection(true);
setFinalizationState('idle');
setSelfReportState('idle');
finalizingResultRef.current = false;
postRegulationMeasurementRef.current = true;
setAppView('measurement');
})().catch(() => {
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
setAppView('measurement');
});
},
[measurement, refreshRegulationSessions, regulationBeforeMeasurement],
);
const saveSelfReport = useCallback(
(report: MeasurementSelfReport): void => {
if (currentMeasurement === null) return;
setSelfReportState('saving');
measurementRepository
.attachSelfReport(currentMeasurement.id, report)
.then(async () => {
const baselineEligible =
currentMeasurement.atRest &&
regulationComparison === null &&
![
report.recentExercise,
report.recentCaffeine,
report.recentMeal,
report.movementDuringMeasurement,
report.phoneUnusuallyWarm,
].some(value => value === true);
setCurrentMeasurement({
...currentMeasurement,
selfReport: report,
baselineEligible,
});
setSelfReportState('saved');
await Promise.all([refreshHistory(), refreshBaseline()]);
})
.catch(() => setSelfReportState('failed'));
},
[currentMeasurement, refreshBaseline, refreshHistory, regulationComparison],
);
const deleteMeasurement = useCallback(
async (measurementId: string): Promise<void> => {
await Promise.all([
measurementRepository.deleteById(measurementId),
regulationSessionRepository.deleteRelatedToMeasurement(measurementId),
coherenceSessionRepository.deleteRelatedToMeasurement(measurementId),
]);
await Promise.all([
refreshHistory(),
refreshBaseline(),
refreshRegulationSessions(),
]);
},
[refreshBaseline, refreshHistory, refreshRegulationSessions],
);
const transferWatchTombstone = useCallback((tombstone: WatchTombstoneV1): void => {
const envelope: WatchSyncEnvelopeV1<WatchTombstoneV1> = {
schemaVersion: 1,
eventId: `iphone-delete-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
deviceId: 'iphone',
sequence: 0,
sentAt: new Date().toISOString(),
kind: 'tombstone',
payload: tombstone,
};
watchSyncCoordinator.transferEvent(envelope);
}, []);
const deleteWatchMeasurement = useCallback(async (measurementId: string): Promise<void> => {
const tombstone = await watchRepository.deleteMeasurement(measurementId);
transferWatchTombstone(tombstone);
await refreshWatchData();
}, [refreshWatchData, transferWatchTombstone]);
const clearMeasurementData = useCallback(async (): Promise<void> => {
const [, , , watchTombstones] = await Promise.all([
measurementRepository.clearAll(),
regulationSessionRepository.clearAll(),
coherenceSessionRepository.clearAll(),
watchRepository.clearAll(),
]);
watchTombstones.forEach(transferWatchTombstone);
setCurrentMeasurement(null);
await Promise.all([
refreshHistory(),
refreshBaseline(),
refreshRegulationSessions(),
refreshWatchData(),
]);
}, [refreshBaseline, refreshHistory, refreshRegulationSessions, refreshWatchData, transferWatchTombstone]);
const changeRetentionPolicy = useCallback(
async (policy: DataRetentionPolicy): Promise<void> => {
await measurementRepository.setRetentionPolicy(policy);
setRetentionPolicy(policy);
await Promise.all([refreshHistory(), refreshBaseline()]);
},
[refreshBaseline, refreshHistory],
);
if (onboardingStatus === 'loading') {
return (
<View style={styles.loadingResult}>
<ActivityIndicator size="large" color={colors.accent} />
</View>
);
}
if (onboardingStatus === 'required') {
return (
<OnboardingScreen
initialAnswers={onboardingAnswers}
onRequestHealthKit={handleOnboardingHealthKit}
onComplete={answers => {
setOnboardingAnswers(answers);
setOnboardingStatus('completed');
}}
/>
);
}
if (appView === 'history') {
return (
<PPGHistoryScreen
measurements={history}
watchMeasurements={watchHistory}
baselineProgress={baselineProgress}
watchBaseline={watchBaseline}
regulationSessions={regulationSessions}
watchRegulationSessions={watchRegulationSessions}
watchConnectionState={watchConnection}
saveMindAndBodyWorkouts={saveMindAndBodyWorkouts}
retentionPolicy={retentionPolicy}
onDeleteMeasurement={deleteMeasurement}
onDeleteWatchMeasurement={deleteWatchMeasurement}
onClearAll={clearMeasurementData}
onSaveMindAndBodyWorkoutsChange={handleSaveMindAndBodyPreference}
onRetentionPolicyChange={changeRetentionPolicy}
onBack={() => {
setRegulationBeforeMeasurement(null);
setPendingRegulationSession(null);
setAppView('measurement');
}}
/>
);
}
if (appView === 'cardiacMirror') {
return <CardiacMirrorScreen onBack={() => setAppView('measurement')} />;
}
if (appView === 'hapticExercise') {
return (
<HapticWaveExerciseScreen
initialProtocolKind={exerciseProtocol}
onBack={() => setAppView('measurement')}
onCompleted={completeRegulationExercise}
onRequestStart={protocol => {
if (currentMeasurement === null) {
setPendingProtocolIntent(protocol.kind);
setExerciseProtocol(protocol.kind);
setAtRestSelection(true);
setAppView('measurement');
return true;
}
setExerciseProtocol(protocol.kind);
setRegulationBeforeMeasurement(currentMeasurement);
if (protocol.mode === 'livePPG') {
cameraPermission.requestCameraPermission().then(granted => {
if (granted) setAppView('myCoherence');
});
return true;
}
return false;
}}
onCancelled={() => {
setRegulationBeforeMeasurement(null);
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
}}
/>
);
}
if (appView === 'myCoherence') {
return (
<MyCoherenceScreen
cameraPosition={cameraPosition}
beforeMeasurementId={regulationBeforeMeasurement?.id ?? null}
onCompleted={(summary, session) => {
completeRegulationExercise(
{
protocolKind: 'myCoherence',
startedAt: summary.startedAt,
completedAt: summary.completedAt,
elapsedSeconds: summary.elapsedSeconds,
completed: summary.completed,
},
session.id,
);
}}
onCancelled={() => {
setRegulationBeforeMeasurement(null);
setAppView('hapticExercise');
}}
/>
);
}
const state = measurement.snapshot.value;
if (
state === 'preparingCamera' ||
state === 'waitingForFinger' ||
state === 'stabilizing' ||
state === 'measuring' ||
state === 'processing'
) {
return (
<PPGMeasurementScreen
state={state}
latestFrame={measurement.latestFrame}
phaseProgress={measurement.phaseProgress}
isCameraActive={measurement.isCameraActive}
torchEnabled={measurement.torchEnabled}
cameraPosition={cameraPosition}
onCameraStarted={measurement.onCameraStarted}
onFrameUpdate={measurement.onFrameUpdate}
onInterrupted={measurement.onCameraInterrupted}
onCameraError={measurement.onCameraError}
onCancel={measurement.cancel}
/>
);
}
if (state === 'completed') {
if (currentMeasurement === null) {
if (finalizationState === 'failed') {
return (
<PPGFailureScreen
state="error"
message="Le résultat n’a pas pu être enregistré localement. Vous pouvez réessayer sans relancer la caméra."
onRetry={() => setFinalizationState('idle')}
onBack={backToPreparation}
/>
);
}
return (
<View style={styles.loadingResult}>
<ActivityIndicator size="large" color={colors.accent} />
</View>
);
}
return (
<PPGResultScreen
cameraResult={currentMeasurement.cameraResult}
measurementHistory={history}
potentialStress={currentMeasurement.potentialStress}
healthKitContext={currentMeasurement.healthKitContext}
baselineProgress={baselineProgress}
regulationComparison={regulationComparison}
recommendedProtocolKind={recommendRegulationProtocol(
currentMeasurement.potentialStress.index,
)}
selfReportSaved={selfReportState === 'saved'}
selfReportSaving={selfReportState === 'saving'}
selfReportError={selfReportState === 'failed'}
onSaveSelfReport={saveSelfReport}
onStartRegulation={startRegulationJourney}
onRestart={restartMeasurement}
onDone={backToPreparation}
/>
);
}
if (
state === 'invalid' ||
state === 'thermalStop' ||
state === 'interrupted' ||
state === 'error'
) {
return (
<PPGFailureScreen
state={state}
message={
measurement.snapshot.context.failure?.message ??
'Le signal n’a pas permis de produire un résultat fiable.'
}
result={measurement.snapshot.context.result}
showOpenSettings={
measurement.snapshot.context.failure?.code ===
'cameraPermissionDenied'
}
onRetry={retryMeasurement}
onBack={backToPreparation}
/>
);
}
return (
<View style={styles.container}>
<NewAppScreen
templateFileName="App.tsx"
safeAreaInsets={safeAreaInsets}
/>
</View>
<PPGPreparationScreen
includeHealthKit={includeHealthKit}
baselineProgress={baselineProgress}
postExerciseProtocolTitle={
pendingRegulationSession === null
? null
: getRegulationProtocol(pendingRegulationSession.protocolKind).title
}
onIncludeHealthKitChange={handleHealthKitPreference}
sensitiveContact={sensitiveContact}
onSensitiveContactChange={handleSensitiveContactPreference}
cameraPosition={cameraPosition}
onCameraPositionChange={handleCameraPositionPreference}
atRest={atRestSelection}
onAtRestChange={setAtRestSelection}
onStart={startMeasurement}
onOpenHistory={() => setAppView('history')}
onOpenCardiacMirror={() => setAppView('cardiacMirror')}
onOpenHapticExercise={() => setAppView('hapticExercise')}
isStarting={
state === 'requestingCameraPermission' ||
state === 'requestingHealthPermission'
}
/>
);
}
const styles = StyleSheet.create({
container: {
safeArea: {
flex: 1,
backgroundColor: colors.background,
},
loadingResult: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
},
});