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