854 lines
30 KiB
TypeScript
854 lines
30 KiB
TypeScript
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 { colors } from './src/theme';
|
||
import { OnboardingScreen } from './src/features/onboarding/OnboardingScreen';
|
||
import {
|
||
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';
|
||
|
||
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="light-content"
|
||
translucent
|
||
backgroundColor="transparent"
|
||
/>
|
||
<SafeAreaView
|
||
style={styles.safeArea}
|
||
edges={['right', 'bottom', 'left']}>
|
||
<HaptikaApp />
|
||
</SafeAreaView>
|
||
</SafeAreaProvider>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<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({
|
||
safeArea: {
|
||
flex: 1,
|
||
backgroundColor: colors.background,
|
||
},
|
||
loadingResult: {
|
||
flex: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: colors.background,
|
||
},
|
||
});
|
||
|
||
export default App;
|